diff --git a/.github/dotslash-zsh-config.json b/.github/dotslash-zsh-config.json new file mode 100644 index 0000000000..db2c416401 --- /dev/null +++ b/.github/dotslash-zsh-config.json @@ -0,0 +1,23 @@ +{ + "outputs": { + "codex-zsh": { + "platforms": { + "macos-aarch64": { + "name": "codex-zsh-aarch64-apple-darwin.tar.gz", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh" + }, + "linux-x86_64": { + "name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh" + }, + "linux-aarch64": { + "name": "codex-zsh-aarch64-unknown-linux-musl.tar.gz", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh" + } + } + } + } +} diff --git a/.github/scripts/build-zsh-release-artifact.sh b/.github/scripts/build-zsh-release-artifact.sh new file mode 100755 index 0000000000..4fc3db3903 --- /dev/null +++ b/.github/scripts/build-zsh-release-artifact.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "$#" -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +archive_path="$1" +workspace="${GITHUB_WORKSPACE:?missing GITHUB_WORKSPACE}" +zsh_commit="${ZSH_COMMIT:?missing ZSH_COMMIT}" +zsh_patch="${ZSH_PATCH:?missing ZSH_PATCH}" +temp_root="${RUNNER_TEMP:-/tmp}" +work_root="$(mktemp -d "${temp_root%/}/codex-zsh-release.XXXXXX")" +trap 'rm -rf "$work_root"' EXIT + +source_root="${work_root}/zsh" +package_root="${work_root}/codex-zsh" +wrapper_path="${work_root}/exec-wrapper" +stdout_path="${work_root}/stdout.txt" +wrapper_log_path="${work_root}/wrapper.log" + +git clone https://git.code.sf.net/p/zsh/code "$source_root" +cd "$source_root" +git checkout "$zsh_commit" +git apply "${workspace}/${zsh_patch}" +./Util/preconfig +./configure + +cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" +make -j"${cores}" + +cat > "$wrapper_path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" +printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG" +file="$1" +shift +if [[ "$#" -eq 0 ]]; then + exec "$file" +fi +arg0="$1" +shift +exec -a "$arg0" "$file" "$@" +EOF +chmod +x "$wrapper_path" + +CODEX_WRAPPER_LOG="$wrapper_log_path" \ +EXEC_WRAPPER="$wrapper_path" \ +"${source_root}/Src/zsh" -fc '/bin/echo smoke-zsh' > "$stdout_path" + +grep -Fx "smoke-zsh" "$stdout_path" +grep -Fx "/bin/echo" "$wrapper_log_path" + +mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")" +cp "${source_root}/Src/zsh" "$package_root/bin/zsh" +chmod +x "$package_root/bin/zsh" + +(cd "$work_root" && tar -czf "${workspace}/${archive_path}" codex-zsh) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f23a999d64..bbd2df27cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v6 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 9db72b1d21..c203e2b742 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -547,7 +547,7 @@ jobs: tests: name: Tests β€” ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 30 + timeout-minutes: ${{ matrix.runner == 'windows-arm64' && 35 || 30 }} needs: changed if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} defaults: diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml new file mode 100644 index 0000000000..a0f71aa736 --- /dev/null +++ b/.github/workflows/rust-release-zsh.yml @@ -0,0 +1,95 @@ +name: rust-release-zsh + +on: + workflow_call: + +env: + ZSH_COMMIT: 77045ef899e53b9598bebc5a41db93a548a40ca6 + ZSH_PATCH: codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch + +jobs: + linux: + name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: ubuntu-24.04 + image: ubuntu:24.04 + archive_name: codex-zsh-x86_64-unknown-linux-musl.tar.gz + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: ubuntu-24.04 + image: arm64v8/ubuntu:24.04 + archive_name: codex-zsh-aarch64-unknown-linux-musl.tar.gz + + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + autoconf \ + bison \ + build-essential \ + ca-certificates \ + gettext \ + git \ + libncursesw5-dev + + - uses: actions/checkout@v6 + + - name: Build, smoke-test, and stage zsh artifact + shell: bash + run: | + "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ + "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" + + - uses: actions/upload-artifact@v7 + with: + name: codex-zsh-${{ matrix.target }} + path: dist/zsh/${{ matrix.target }}/* + + darwin: + name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + variant: macos-15 + archive_name: codex-zsh-aarch64-apple-darwin.tar.gz + + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if ! command -v autoconf >/dev/null 2>&1; then + brew install autoconf + fi + + - uses: actions/checkout@v6 + + - name: Build, smoke-test, and stage zsh artifact + shell: bash + run: | + "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ + "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" + + - uses: actions/upload-artifact@v7 + with: + name: codex-zsh-${{ matrix.target }} + path: dist/zsh/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 4a3b35bd1c..1ec9bd28ba 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -389,15 +389,6 @@ jobs: release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} secrets: inherit - shell-tool-mcp: - name: shell-tool-mcp - needs: tag-check - uses: ./.github/workflows/shell-tool-mcp.yml - with: - release-tag: ${{ github.ref_name }} - publish: true - secrets: inherit - argument-comment-lint-release-assets: name: argument-comment-lint release assets needs: tag-check @@ -405,12 +396,17 @@ jobs: with: publish: true + zsh-release-assets: + name: zsh release assets + needs: tag-check + uses: ./.github/workflows/rust-release-zsh.yml + release: needs: - build - build-windows - - shell-tool-mcp - argument-comment-lint-release-assets + - zsh-release-assets name: release runs-on: ubuntu-latest permissions: @@ -453,11 +449,8 @@ jobs: - name: List run: ls -R dist/ - # This is a temporary fix: we should modify shell-tool-mcp.yml so these - # files do not end up in dist/ in the first place. - name: Delete entries from dist/ that should not go in the release run: | - rm -rf dist/shell-tool-mcp* rm -rf dist/windows-binaries* # cargo-timing.html appears under multiple target-specific directories. # If included in files: dist/**, release upload races on duplicate @@ -499,7 +492,7 @@ jobs: fi - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false @@ -547,6 +540,13 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json + - uses: facebook/dotslash-publish-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag: ${{ github.ref_name }} + config: .github/dotslash-zsh-config.json + - uses: facebook/dotslash-publish-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -693,7 +693,7 @@ jobs: steps: - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@19e706d4c9121098010096f9c495a70a7518b30f + uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 with: identifier: OpenAI.Codex version: ${{ needs.release.outputs.version }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 5d13042fbe..c5026fe8c5 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -23,7 +23,7 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 with: run_install: false diff --git a/.github/workflows/shell-tool-mcp-ci.yml b/.github/workflows/shell-tool-mcp-ci.yml deleted file mode 100644 index 739cc6e409..0000000000 --- a/.github/workflows/shell-tool-mcp-ci.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: shell-tool-mcp CI - -on: - push: - paths: - - "shell-tool-mcp/**" - - ".github/workflows/shell-tool-mcp-ci.yml" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - pull_request: - paths: - - "shell-tool-mcp/**" - - ".github/workflows/shell-tool-mcp-ci.yml" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - -env: - NODE_VERSION: 22 - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - cache: "pnpm" - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Format check - run: pnpm --filter @openai/codex-shell-tool-mcp run format - - - name: Run tests - run: pnpm --filter @openai/codex-shell-tool-mcp test - - - name: Build - run: pnpm --filter @openai/codex-shell-tool-mcp run build diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml deleted file mode 100644 index a79f6221fa..0000000000 --- a/.github/workflows/shell-tool-mcp.yml +++ /dev/null @@ -1,553 +0,0 @@ -name: shell-tool-mcp - -on: - workflow_call: - inputs: - release-version: - description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v. - required: false - type: string - release-tag: - description: Tag name to use when downloading release artifacts (defaults to rust-v). - required: false - type: string - publish: - description: Whether to publish to npm when the version is releasable. - required: false - default: true - type: boolean - -env: - NODE_VERSION: 22 - -jobs: - metadata: - runs-on: ubuntu-latest - outputs: - version: ${{ steps.compute.outputs.version }} - release_tag: ${{ steps.compute.outputs.release_tag }} - should_publish: ${{ steps.compute.outputs.should_publish }} - npm_tag: ${{ steps.compute.outputs.npm_tag }} - steps: - - name: Compute version and tags - id: compute - env: - RELEASE_TAG_INPUT: ${{ inputs.release-tag }} - RELEASE_VERSION_INPUT: ${{ inputs.release-version }} - run: | - set -euo pipefail - - version="$RELEASE_VERSION_INPUT" - release_tag="$RELEASE_TAG_INPUT" - - if [[ -z "$version" ]]; then - if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then - version="${release_tag#rust-v}" - elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then - version="${GITHUB_REF_NAME#rust-v}" - release_tag="${GITHUB_REF_NAME}" - else - echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag." - exit 1 - fi - fi - - if [[ -z "$release_tag" ]]; then - release_tag="rust-v${version}" - fi - - npm_tag="" - should_publish="false" - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - should_publish="true" - elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - should_publish="true" - npm_tag="alpha" - fi - - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" - echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT" - echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT" - - bash-linux: - name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - container: - image: ${{ matrix.image }} - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: ubuntu-24.04 - image: ubuntu:24.04 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: ubuntu-22.04 - image: ubuntu:22.04 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: debian-12 - image: debian:12 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: debian-11 - image: debian:11 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: centos-9 - image: quay.io/centos/centos:stream9 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-24.04 - image: arm64v8/ubuntu:24.04 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-22.04 - image: arm64v8/ubuntu:22.04 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-20.04 - image: arm64v8/ubuntu:20.04 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: debian-12 - image: arm64v8/debian:12 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: debian-11 - image: arm64v8/debian:11 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: centos-9 - image: quay.io/centos/centos:stream9 - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev - elif command -v dnf >/dev/null 2>&1; then - dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel - elif command -v yum >/dev/null 2>&1; then - yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel - else - echo "Unsupported package manager in container" - exit 1 - fi - - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Build patched Bash - shell: bash - run: | - set -euo pipefail - git clone https://git.savannah.gnu.org/git/bash /tmp/bash - cd /tmp/bash - git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b - git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" - ./configure --without-bash-malloc - cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" - make -j"${cores}" - - dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" - mkdir -p "$dest" - cp bash "$dest/bash" - - - uses: actions/upload-artifact@v7 - with: - name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} - path: artifacts/** - if-no-files-found: error - - bash-darwin: - name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - variant: macos-15 - - runner: macos-14 - target: aarch64-apple-darwin - variant: macos-14 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Build patched Bash - shell: bash - run: | - set -euo pipefail - git clone https://git.savannah.gnu.org/git/bash /tmp/bash - cd /tmp/bash - git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b - git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch" - ./configure --without-bash-malloc - cores="$(getconf _NPROCESSORS_ONLN)" - make -j"${cores}" - - dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}" - mkdir -p "$dest" - cp bash "$dest/bash" - - - uses: actions/upload-artifact@v7 - with: - name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} - path: artifacts/** - if-no-files-found: error - - zsh-linux: - name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - container: - image: ${{ matrix.image }} - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: ubuntu-24.04 - image: ubuntu:24.04 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: ubuntu-22.04 - image: ubuntu:22.04 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: debian-12 - image: debian:12 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: debian-11 - image: debian:11 - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: centos-9 - image: quay.io/centos/centos:stream9 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-24.04 - image: arm64v8/ubuntu:24.04 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-22.04 - image: arm64v8/ubuntu:22.04 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-20.04 - image: arm64v8/ubuntu:20.04 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: debian-12 - image: arm64v8/debian:12 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: debian-11 - image: arm64v8/debian:11 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: centos-9 - image: quay.io/centos/centos:stream9 - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev - elif command -v dnf >/dev/null 2>&1; then - dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel - elif command -v yum >/dev/null 2>&1; then - yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel - else - echo "Unsupported package manager in container" - exit 1 - fi - - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Build patched zsh - shell: bash - run: | - set -euo pipefail - git clone https://git.code.sf.net/p/zsh/code /tmp/zsh - cd /tmp/zsh - git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 - git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" - ./Util/preconfig - ./configure - cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" - make -j"${cores}" - - dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}" - mkdir -p "$dest" - cp Src/zsh "$dest/zsh" - - - name: Smoke test zsh exec wrapper - shell: bash - run: | - set -euo pipefail - tmpdir="$(mktemp -d)" - cat > "$tmpdir/exec-wrapper" <<'EOF' - #!/usr/bin/env bash - set -euo pipefail - : "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" - printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG" - file="$1" - shift - if [[ "$#" -eq 0 ]]; then - exec "$file" - fi - arg0="$1" - shift - exec -a "$arg0" "$file" "$@" - EOF - chmod +x "$tmpdir/exec-wrapper" - - CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \ - EXEC_WRAPPER="$tmpdir/exec-wrapper" \ - /tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt" - - grep -Fx "smoke-zsh" "$tmpdir/stdout.txt" - grep -Fx "/bin/echo" "$tmpdir/wrapper.log" - - - uses: actions/upload-artifact@v7 - with: - name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }} - path: artifacts/** - if-no-files-found: error - - zsh-darwin: - name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - variant: macos-15 - - runner: macos-14 - target: aarch64-apple-darwin - variant: macos-14 - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - if ! command -v autoconf >/dev/null 2>&1; then - brew install autoconf - fi - - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Build patched zsh - shell: bash - run: | - set -euo pipefail - git clone https://git.code.sf.net/p/zsh/code /tmp/zsh - cd /tmp/zsh - git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 - git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" - ./Util/preconfig - ./configure - cores="$(getconf _NPROCESSORS_ONLN)" - make -j"${cores}" - - dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}" - mkdir -p "$dest" - cp Src/zsh "$dest/zsh" - - - name: Smoke test zsh exec wrapper - shell: bash - run: | - set -euo pipefail - tmpdir="$(mktemp -d)" - cat > "$tmpdir/exec-wrapper" <<'EOF' - #!/usr/bin/env bash - set -euo pipefail - : "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" - printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG" - file="$1" - shift - if [[ "$#" -eq 0 ]]; then - exec "$file" - fi - arg0="$1" - shift - exec -a "$arg0" "$file" "$@" - EOF - chmod +x "$tmpdir/exec-wrapper" - - CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \ - EXEC_WRAPPER="$tmpdir/exec-wrapper" \ - /tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt" - - grep -Fx "smoke-zsh" "$tmpdir/stdout.txt" - grep -Fx "/bin/echo" "$tmpdir/wrapper.log" - - - uses: actions/upload-artifact@v7 - with: - name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }} - path: artifacts/** - if-no-files-found: error - - package: - name: Package npm module - needs: - - metadata - - bash-linux - - bash-darwin - - zsh-linux - - zsh-darwin - runs-on: ubuntu-latest - env: - PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install JavaScript dependencies - run: pnpm install --frozen-lockfile - - - name: Build (shell-tool-mcp) - run: pnpm --filter @openai/codex-shell-tool-mcp run build - - - name: Download build artifacts - uses: actions/download-artifact@v8 - with: - path: artifacts - - - name: Assemble staging directory - id: staging - shell: bash - run: | - set -euo pipefail - staging="${STAGING_DIR}" - mkdir -p "$staging" "$staging/vendor" - cp shell-tool-mcp/README.md "$staging/" - cp shell-tool-mcp/package.json "$staging/" - - found_vendor="false" - shopt -s nullglob - for vendor_dir in artifacts/*/vendor; do - rsync -av "$vendor_dir/" "$staging/vendor/" - found_vendor="true" - done - if [[ "$found_vendor" == "false" ]]; then - echo "No vendor payloads were downloaded." - exit 1 - fi - - node - <<'NODE' - import fs from "node:fs"; - import path from "node:path"; - - const stagingDir = process.env.STAGING_DIR; - const version = process.env.PACKAGE_VERSION; - const pkgPath = path.join(stagingDir, "package.json"); - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - pkg.version = version; - fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); - NODE - - echo "dir=$staging" >> "$GITHUB_OUTPUT" - env: - STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp - - - name: Ensure binaries are executable - env: - STAGING_DIR: ${{ steps.staging.outputs.dir }} - run: | - set -euo pipefail - chmod +x \ - "$STAGING_DIR"/vendor/*/bash/*/bash \ - "$STAGING_DIR"/vendor/*/zsh/*/zsh - - - name: Create npm tarball - shell: bash - env: - STAGING_DIR: ${{ steps.staging.outputs.dir }} - run: | - set -euo pipefail - mkdir -p dist/npm - pack_info=$(cd "$STAGING_DIR" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm") - filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') - mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" - - - uses: actions/upload-artifact@v7 - with: - name: codex-shell-tool-mcp-npm - path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz - if-no-files-found: error - - publish: - name: Publish npm package - needs: - - metadata - - package - if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }} - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - steps: - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: https://registry.npmjs.org - scope: "@openai" - - # Trusted publishing requires npm CLI version 11.5.1 or later. - - name: Update npm - run: npm install -g npm@latest - - - name: Download npm tarball - uses: actions/download-artifact@v8 - with: - name: codex-shell-tool-mcp-npm - path: dist/npm - - - name: Publish to npm - env: - NPM_TAG: ${{ needs.metadata.outputs.npm_tag }} - VERSION: ${{ needs.metadata.outputs.version }} - shell: bash - run: | - set -euo pipefail - tag_args=() - if [[ -n "${NPM_TAG}" ]]; then - tag_args+=(--tag "${NPM_TAG}") - fi - npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}" diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 778dee3ea7..33244c1185 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -380,7 +380,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1444,10 +1444,12 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-git-utils", "codex-login", "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-sandboxing", "codex-shell-command", "codex-state", "codex-utils-absolute-path", @@ -1488,7 +1490,6 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-core", - "codex-features", "codex-feedback", "codex-protocol", "futures", @@ -1509,6 +1510,7 @@ dependencies = [ "anyhow", "clap", "codex-experimental-api-macros", + "codex-git-utils", "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1643,7 +1645,7 @@ dependencies = [ "clap", "codex-connectors", "codex-core", - "codex-git", + "codex-git-utils", "codex-utils-cargo-bin", "codex-utils-cli", "pretty_assertions", @@ -1678,6 +1680,7 @@ dependencies = [ "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-sandboxing", "codex-state", "codex-stdio-to-uds", "codex-terminal-detection", @@ -1767,6 +1770,7 @@ dependencies = [ "codex-client", "codex-cloud-tasks-client", "codex-core", + "codex-git-utils", "codex-login", "codex-tui", "codex-utils-cli", @@ -1793,7 +1797,7 @@ dependencies = [ "async-trait", "chrono", "codex-backend-client", - "codex-git", + "codex-git-utils", "diffy", "serde", "serde_json", @@ -1877,14 +1881,15 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", - "codex-file-search", - "codex-git", + "codex-git-utils", "codex-hooks", "codex-login", "codex-network-proxy", "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-rollout", + "codex-sandboxing", "codex-secrets", "codex-shell-command", "codex-shell-escalation", @@ -1897,6 +1902,8 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-home-dir", "codex-utils-image", + "codex-utils-output-truncation", + "codex-utils-path", "codex-utils-pty", "codex-utils-readiness", "codex-utils-stream-parser", @@ -1944,7 +1951,6 @@ dependencies = [ "test-case", "test-log", "thiserror 2.0.18", - "time", "tokio", "tokio-tungstenite", "tokio-util", @@ -1991,14 +1997,13 @@ dependencies = [ "codex-cloud-requirements", "codex-core", "codex-feedback", + "codex-git-utils", "codex-otel", "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", - "codex-utils-elapsed", "codex-utils-oss", - "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -2006,10 +2011,8 @@ dependencies = [ "owo-colors", "predicates", "pretty_assertions", - "rmcp", "serde", "serde_json", - "shlex", "supports-color 3.0.2", "tempfile", "tokio", @@ -2135,10 +2138,12 @@ dependencies = [ ] [[package]] -name = "codex-git" +name = "codex-git-utils" version = "0.0.0" dependencies = [ "assert_matches", + "codex-utils-absolute-path", + "futures", "once_cell", "pretty_assertions", "regex", @@ -2146,6 +2151,7 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.18", + "tokio", "ts-rs", "walkdir", ] @@ -2391,9 +2397,10 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-execpolicy", - "codex-git", + "codex-git-utils", "codex-utils-absolute-path", "codex-utils-image", + "codex-utils-string", "icu_decimal", "icu_locale_core", "icu_provider", @@ -2462,6 +2469,48 @@ dependencies = [ "which 8.0.0", ] +[[package]] +name = "codex-rollout" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-file-search", + "codex-git-utils", + "codex-login", + "codex-otel", + "codex-protocol", + "codex-state", + "codex-utils-path", + "codex-utils-string", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "time", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "codex-sandboxing" +version = "0.0.0" +dependencies = [ + "codex-network-proxy", + "codex-protocol", + "codex-utils-absolute-path", + "dirs", + "dunce", + "libc", + "pretty_assertions", + "serde_json", + "tempfile", + "tracing", + "url", +] + [[package]] name = "codex-secrets" version = "0.0.0" @@ -2469,6 +2518,7 @@ dependencies = [ "age", "anyhow", "base64 0.22.1", + "codex-git-utils", "codex-keyring-store", "keyring", "pretty_assertions", @@ -2539,6 +2589,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "codex-git-utils", "codex-protocol", "dirs", "log", @@ -2605,6 +2656,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-git-utils", "codex-login", "codex-otel", "codex-protocol", @@ -2698,6 +2750,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-git-utils", "codex-login", "codex-otel", "codex-protocol", @@ -2865,6 +2918,25 @@ dependencies = [ "codex-ollama", ] +[[package]] +name = "codex-utils-output-truncation" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-utils-string", + "pretty_assertions", +] + +[[package]] +name = "codex-utils-path" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "dunce", + "pretty_assertions", + "tempfile", +] + [[package]] name = "codex-utils-pty" version = "0.0.0" @@ -2934,6 +3006,13 @@ dependencies = [ "regex-lite", ] +[[package]] +name = "codex-utils-template" +version = "0.0.0" +dependencies = [ + "pretty_assertions", +] + [[package]] name = "codex-v8-poc" version = "0.0.0" @@ -3821,7 +3900,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4066,7 +4145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5511,7 +5590,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6274,7 +6353,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6918,7 +6997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -8340,7 +8419,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9773,7 +9852,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11219,7 +11298,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 524b61e3b8..3d2ee99482 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -40,8 +40,10 @@ members = [ "ollama", "process-hardening", "protocol", + "rollout", "rmcp-client", "responses-api-proxy", + "sandboxing", "stdio-to-uds", "otel", "tui", @@ -49,7 +51,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", - "utils/git", + "git-utils", "utils/cache", "utils/image", "utils/json-to-toml", @@ -64,8 +66,11 @@ members = [ "utils/sleep-inhibitor", "utils/approval-presets", "utils/oss", + "utils/output-truncation", + "utils/path-utils", "utils/fuzzy-match", "utils/stream-parser", + "utils/template", "codex-client", "codex-api", "state", @@ -116,7 +121,7 @@ codex-experimental-api-macros = { path = "codex-experimental-api-macros" } codex-feedback = { path = "feedback" } codex-features = { path = "features" } codex-file-search = { path = "file-search" } -codex-git = { path = "utils/git" } +codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } @@ -128,8 +133,10 @@ codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } +codex-rollout = { path = "rollout" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } +codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } codex-shell-command = { path = "shell-command" } codex-shell-escalation = { path = "shell-escalation" } @@ -152,12 +159,15 @@ codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-oss = { path = "utils/oss" } +codex-utils-output-truncation = { path = "utils/output-truncation" } +codex-utils-path = { path = "utils/path-utils" } codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-stream-parser = { path = "utils/stream-parser" } +codex-utils-template = { path = "utils/template" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } @@ -288,7 +298,7 @@ supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" tempfile = "3.23.0" -tar = "0.4.44" +tar = "0.4.45" test-log = "0.2.19" textwrap = "0.16.2" thiserror = "2.0.17" @@ -375,7 +385,7 @@ ignored = [ "icu_provider", "openssl-sys", "codex-utils-readiness", - "codex-secrets", + "codex-utils-template", "codex-v8-poc", ] diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index 5a3a1aa73f..a0b98c0fec 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -16,7 +16,6 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-core = { workspace = true } -codex-features = { workspace = true } codex-feedback = { workspace = true } codex-protocol = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index acf9c77a10..115e9808f0 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -35,19 +35,14 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_core::AuthManager; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; -use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; -use codex_features::Feature; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use serde::de::DeserializeOwned; @@ -73,7 +68,6 @@ pub type RequestResult = std::result::Result; pub enum AppServerEvent { Lagged { skipped: usize }, ServerNotification(ServerNotification), - LegacyNotification(JSONRPCNotification), ServerRequest(ServerRequest), Disconnected { message: String }, } @@ -85,9 +79,6 @@ impl From for AppServerEvent { InProcessServerEvent::ServerNotification(notification) => { Self::ServerNotification(notification) } - InProcessServerEvent::LegacyNotification(notification) => { - Self::LegacyNotification(notification) - } InProcessServerEvent::ServerRequest(request) => Self::ServerRequest(request), } } @@ -97,19 +88,12 @@ fn event_requires_delivery(event: &InProcessServerEvent) -> bool { // These terminal events drive surface shutdown/completion state. Dropping // them under backpressure can leave exec/TUI waiting forever even though // the underlying turn has already ended. - match event { + matches!( + event, InProcessServerEvent::ServerNotification( codex_app_server_protocol::ServerNotification::TurnCompleted(_), - ) => true, - InProcessServerEvent::LegacyNotification(notification) => matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ), - _ => false, - } + ) + ) } /// Layered error for [`InProcessAppServerClient::request_typed`]. @@ -159,16 +143,6 @@ impl Error for TypedRequestError { } } -#[derive(Clone)] -struct SharedCoreManagers { - // Temporary bootstrap escape hatch for embedders that still need direct - // core handles during the in-process app-server migration. Once TUI/exec - // stop depending on direct manager access, remove this wrapper and keep - // manager ownership entirely inside the app-server runtime. - auth_manager: Arc, - thread_manager: Arc, -} - #[derive(Clone)] pub struct InProcessClientStartArgs { /// Resolved argv0 dispatch paths used by command execution internals. @@ -202,30 +176,6 @@ pub struct InProcessClientStartArgs { } impl InProcessClientStartArgs { - fn shared_core_managers(&self) -> SharedCoreManagers { - let auth_manager = AuthManager::shared( - self.config.codex_home.clone(), - self.enable_codex_api_key_env, - self.config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - self.config.as_ref(), - auth_manager.clone(), - self.session_source.clone(), - CollaborationModesConfig { - default_mode_request_user_input: self - .config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); - - SharedCoreManagers { - auth_manager, - thread_manager, - } - } - /// Builds initialize params from caller-provided metadata. pub fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { @@ -247,7 +197,7 @@ impl InProcessClientStartArgs { } } - fn into_runtime_start_args(self, shared_core: &SharedCoreManagers) -> InProcessStartArgs { + fn into_runtime_start_args(self) -> InProcessStartArgs { let initialize = self.initialize_params(); InProcessStartArgs { arg0_paths: self.arg0_paths, @@ -255,8 +205,6 @@ impl InProcessClientStartArgs { cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, cloud_requirements: self.cloud_requirements, - auth_manager: Some(shared_core.auth_manager.clone()), - thread_manager: Some(shared_core.thread_manager.clone()), feedback: self.feedback, config_warnings: self.config_warnings, session_source: self.session_source, @@ -310,8 +258,6 @@ pub struct InProcessAppServerClient { command_tx: mpsc::Sender, event_rx: mpsc::Receiver, worker_handle: tokio::task::JoinHandle<()>, - auth_manager: Arc, - thread_manager: Arc, } #[derive(Clone)] @@ -338,9 +284,8 @@ impl InProcessAppServerClient { /// with overload error instead of being silently dropped. pub async fn start(args: InProcessClientStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); - let shared_core = args.shared_core_managers(); let mut handle = - codex_app_server::in_process::start(args.into_runtime_start_args(&shared_core)).await?; + codex_app_server::in_process::start(args.into_runtime_start_args()).await?; let request_sender = handle.sender(); let (command_tx, mut command_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -401,6 +346,25 @@ impl InProcessAppServerClient { let Some(event) = event else { break; }; + if let InProcessServerEvent::ServerRequest( + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } + ) = &event + { + let send_result = request_sender.fail_server_request( + request_id.clone(), + JSONRPCErrorError { + code: -32000, + message: "chatgpt auth token refresh is not supported for in-process app-server clients".to_string(), + data: None, + }, + ); + if let Err(err) = send_result { + warn!( + "failed to reject unsupported chatgpt auth token refresh request: {err}" + ); + } + continue; + } if skipped_events > 0 { if event_requires_delivery(&event) { @@ -491,21 +455,9 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, - auth_manager: shared_core.auth_manager, - thread_manager: shared_core.thread_manager, }) } - /// Temporary bootstrap escape hatch for embedders migrating toward RPC-only usage. - pub fn auth_manager(&self) -> Arc { - self.auth_manager.clone() - } - - /// Temporary bootstrap escape hatch for embedders migrating toward RPC-only usage. - pub fn thread_manager(&self) -> Arc { - self.thread_manager.clone() - } - pub fn request_handle(&self) -> InProcessAppServerRequestHandle { InProcessAppServerRequestHandle { command_tx: self.command_tx.clone(), @@ -664,8 +616,6 @@ impl InProcessAppServerClient { command_tx, event_rx, worker_handle, - auth_manager: _, - thread_manager: _, } = self; let mut worker_handle = worker_handle; // Drop the caller-facing receiver before asking the worker to shut @@ -857,8 +807,6 @@ mod tests { use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; - use codex_core::AuthManager; - use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; use futures::SinkExt; use futures::StreamExt; @@ -1052,7 +1000,7 @@ mod tests { } #[tokio::test] - async fn shared_thread_manager_tracks_threads_started_via_app_server() { + async fn threads_started_via_app_server_are_visible_through_typed_requests() { let client = start_test_client(SessionSource::Cli).await; let response: ThreadStartResponse = client @@ -1065,17 +1013,19 @@ mod tests { }) .await .expect("thread/start should succeed"); - let created_thread_id = codex_protocol::ThreadId::from_string(&response.thread.id) - .expect("thread id should parse"); - timeout( - Duration::from_secs(2), - client.thread_manager().get_thread(created_thread_id), - ) - .await - .expect("timed out waiting for retained thread manager to observe started thread") - .expect("started thread should be visible through the shared thread manager"); - let thread_ids = client.thread_manager().list_thread_ids().await; - assert!(thread_ids.contains(&created_thread_id)); + let read = client + .request_typed::( + ClientRequest::ThreadRead { + request_id: RequestId::Integer(4), + params: codex_app_server_protocol::ThreadReadParams { + thread_id: response.thread.id.clone(), + include_turns: false, + }, + }, + ) + .await + .expect("thread/read should return the newly started thread"); + assert_eq!(read.thread.id, response.thread.id); client.shutdown().await.expect("shutdown should complete"); } @@ -1472,22 +1422,6 @@ mod tests { let (command_tx, _command_rx) = mpsc::channel(1); let (event_tx, event_rx) = mpsc::channel(1); let worker_handle = tokio::spawn(async {}); - let config = build_test_config().await; - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - false, - config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - &config, - auth_manager.clone(), - SessionSource::Exec, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); event_tx .send(InProcessServerEvent::Lagged { skipped: 3 }) .await @@ -1498,8 +1432,6 @@ mod tests { command_tx, event_rx, worker_handle, - auth_manager, - thread_manager, }; let event = timeout(Duration::from_secs(2), client.next_event()) @@ -1530,37 +1462,38 @@ mod tests { ) ) )); - assert!(event_requires_delivery( - &InProcessServerEvent::LegacyNotification( - codex_app_server_protocol::JSONRPCNotification { - method: "codex/event/turn_aborted".to_string(), - params: None, - } - ) - )); assert!(!event_requires_delivery(&InProcessServerEvent::Lagged { skipped: 1 })); } #[tokio::test] - async fn accessors_expose_retained_shared_managers() { - let client = start_test_client(SessionSource::Cli).await; + async fn runtime_start_args_leave_manager_bootstrap_to_app_server() { + let config = Arc::new(build_test_config().await); - assert!( - Arc::ptr_eq(&client.auth_manager(), &client.auth_manager()), - "auth_manager accessor should clone the retained shared manager" - ); - assert!( - Arc::ptr_eq(&client.thread_manager(), &client.thread_manager()), - "thread_manager accessor should clone the retained shared manager" - ); + let runtime_args = InProcessClientStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: config.clone(), + cli_overrides: Vec::new(), + loader_overrides: LoaderOverrides::default(), + cloud_requirements: CloudRequirementsLoader::default(), + feedback: CodexFeedback::new(), + config_warnings: Vec::new(), + session_source: SessionSource::Exec, + enable_codex_api_key_env: false, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + } + .into_runtime_start_args(); - client.shutdown().await.expect("shutdown should complete"); + assert_eq!(runtime_args.config, config); } #[tokio::test] - async fn shutdown_completes_promptly_with_retained_shared_managers() { + async fn shutdown_completes_promptly_without_retained_managers() { let client = start_test_client(SessionSource::Cli).await; timeout(Duration::from_secs(1), client.shutdown()) diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index b747595204..b9179716fe 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -272,18 +272,19 @@ impl RemoteAppServerClient { } } Ok(JSONRPCMessage::Notification(notification)) => { - let event = app_server_event_from_notification(notification); - if let Err(err) = deliver_event( - &event_tx, - &mut skipped_events, - event, - &mut stream, - ) - .await - { - warn!(%err, "failed to deliver remote app-server event"); - break; - } + if let Some(event) = + app_server_event_from_notification(notification) + && let Err(err) = deliver_event( + &event_tx, + &mut skipped_events, + event, + &mut stream, + ) + .await + { + warn!(%err, "failed to deliver remote app-server event"); + break; + } } Ok(JSONRPCMessage::Request(request)) => { let request_id = request.id.clone(); @@ -673,7 +674,9 @@ async fn initialize_remote_connection( ))); } JSONRPCMessage::Notification(notification) => { - pending_events.push(app_server_event_from_notification(notification)); + if let Some(event) = app_server_event_from_notification(notification) { + pending_events.push(event); + } } JSONRPCMessage::Request(request) => { let request_id = request.id.clone(); @@ -756,10 +759,10 @@ async fn initialize_remote_connection( Ok(pending_events) } -fn app_server_event_from_notification(notification: JSONRPCNotification) -> AppServerEvent { - match ServerNotification::try_from(notification.clone()) { - Ok(notification) => AppServerEvent::ServerNotification(notification), - Err(_) => AppServerEvent::LegacyNotification(notification), +fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option { + match ServerNotification::try_from(notification) { + Ok(notification) => Some(AppServerEvent::ServerNotification(notification)), + Err(_) => None, } } @@ -852,13 +855,6 @@ async fn reject_if_server_request_dropped( fn event_requires_delivery(event: &AppServerEvent) -> bool { match event { AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(_)) => true, - AppServerEvent::LegacyNotification(notification) => matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ), AppServerEvent::Disconnected { .. } => true, AppServerEvent::Lagged { .. } | AppServerEvent::ServerNotification(_) diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 8566779a0a..a9d90831ef 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -14,8 +14,9 @@ workspace = true [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } -codex-protocol = { workspace = true } codex-experimental-api-macros = { workspace = true } +codex-git-utils = { workspace = true } +codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index ae8e6fed34..9227a97d55 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -524,6 +524,21 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "type": "object" + }, "ExperimentalFeatureListParams": { "properties": { "cursor": { @@ -781,6 +796,36 @@ ], "type": "object" }, + "FsUnwatchParams": { + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "type": "object" + }, + "FsWatchParams": { + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "type": "object" + }, "FsWriteFileParams": { "description": "Write a file on the host filesystem.", "properties": { @@ -2343,13 +2388,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "type": "object" }, @@ -3986,6 +4045,54 @@ "title": "Fs/copyRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, { "properties": { "id": { @@ -4202,6 +4309,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 7d192f0b01..37f7ff8c5f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AccountLoginCompletedNotification": { "properties": { "error": { @@ -998,6 +1002,27 @@ ], "type": "object" }, + "FsChangedNotification": { + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "type": "object" + }, "FuzzyFileSearchMatchType": { "enum": [ "file", @@ -1180,6 +1205,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -4393,6 +4419,26 @@ "title": "App/list/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, { "properties": { "method": { 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 6f71247511..d4269f5239 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 @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { "read": { @@ -883,6 +887,54 @@ "title": "Fs/copyRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, { "properties": { "id": { @@ -1099,6 +1151,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -2257,6 +2333,14 @@ "InitializeResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute path to the server's $CODEX_HOME directory." + }, "platformFamily": { "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", "type": "string" @@ -2270,6 +2354,7 @@ } }, "required": [ + "codexHome", "platformFamily", "platformOs", "userAgent" @@ -4053,6 +4138,26 @@ "title": "App/list/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, { "properties": { "method": { @@ -7168,6 +7273,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -7444,6 +7583,29 @@ ], "type": "string" }, + "FsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" + }, "FsCopyParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Copy a file or directory tree on the host filesystem.", @@ -7698,6 +7860,70 @@ "title": "FsRemoveResponse", "type": "object" }, + "FsUnwatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" + }, + "FsUnwatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" + }, + "FsWatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Created watch handle returned by `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchResponse", + "type": "object" + }, "FsWriteFileParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Write a file on the host filesystem.", @@ -7994,6 +8220,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -8556,6 +8783,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -9514,6 +9756,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/v2/PluginMarketplaceEntry" @@ -11352,6 +11601,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -11377,6 +11629,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], @@ -11433,13 +11686,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" 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 a9c3b96635..acf36d6c3c 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 @@ -1417,6 +1417,54 @@ "title": "Fs/copyRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, { "properties": { "id": { @@ -1633,6 +1681,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -3761,6 +3833,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -4037,6 +4143,29 @@ ], "type": "string" }, + "FsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" + }, "FsCopyParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Copy a file or directory tree on the host filesystem.", @@ -4291,6 +4420,70 @@ "title": "FsRemoveResponse", "type": "object" }, + "FsUnwatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" + }, + "FsUnwatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" + }, + "FsWatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Created watch handle returned by `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchResponse", + "type": "object" + }, "FsWriteFileParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Write a file on the host filesystem.", @@ -4698,6 +4891,7 @@ }, "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" @@ -5304,6 +5498,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -6262,6 +6471,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/PluginMarketplaceEntry" @@ -8517,6 +8733,26 @@ "title": "App/list/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, { "properties": { "method": { @@ -9112,6 +9348,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -9137,6 +9376,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], @@ -9193,13 +9433,27 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json index ada38a6582..1de65f82f4 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json @@ -1,6 +1,20 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to the server's $CODEX_HOME directory." + }, "platformFamily": { "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", "type": "string" @@ -14,6 +28,7 @@ } }, "required": [ + "codexHome", "platformFamily", "platformOs", "userAgent" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json new file mode 100644 index 0000000000..9d6bcec932 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json new file mode 100644 index 0000000000..9cdbf0691f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json new file mode 100644 index 0000000000..ab26588ecb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json new file mode 100644 index 0000000000..4b988d97aa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json new file mode 100644 index 0000000000..daa80ad656 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json b/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json new file mode 100644 index 0000000000..cf80c7b101 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json b/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json new file mode 100644 index 0000000000..b516636a09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Created watch handle returned by `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 84fea949c8..881c343601 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 7b55420da2..18fdb5008d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -3,6 +3,7 @@ "definitions": { "HookEventName": { "enum": [ + "preToolUse", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 580ee37a18..a9a5960638 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -16,6 +16,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "PluginAuthPolicy": { "enum": [ "ON_INSTALL", @@ -246,6 +261,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/PluginMarketplaceEntry" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 5fecf50376..1917935a2e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -318,6 +318,9 @@ "description": { "type": "string" }, + "enabled": { + "type": "boolean" + }, "interface": { "anyOf": [ { @@ -343,6 +346,7 @@ }, "required": [ "description", + "enabled", "name", "path" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json index 3fa74811d5..696226a50c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsConfigWriteParams.json @@ -1,16 +1,36 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": [ + "string", + "null" + ] + }, "path": { - "type": "string" + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "title": "SkillsConfigWriteParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 5e03a26ca2..e33a986359 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -16,6 +16,7 @@ import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; +import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; @@ -26,6 +27,8 @@ import type { FsGetMetadataParams } from "./v2/FsGetMetadataParams"; import type { FsReadDirectoryParams } from "./v2/FsReadDirectoryParams"; import type { FsReadFileParams } from "./v2/FsReadFileParams"; import type { FsRemoveParams } from "./v2/FsRemoveParams"; +import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; +import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; @@ -61,4 +64,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts index 47978fc8d1..c1d0a822f2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts @@ -1,8 +1,13 @@ // 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 { AbsolutePathBuf } from "./AbsolutePathBuf"; export type InitializeResponse = { userAgent: string, +/** + * Absolute path to the server's $CODEX_HOME directory. + */ +codexHome: AbsolutePathBuf, /** * Platform family for the running app-server target, for example * `"unix"` or `"windows"`. diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index d9e2df7797..85ebe847f4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -15,6 +15,7 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification"; import type { ErrorNotification } from "./v2/ErrorNotification"; import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; +import type { FsChangedNotification } from "./v2/FsChangedNotification"; import type { HookCompletedNotification } from "./v2/HookCompletedNotification"; import type { HookStartedNotification } from "./v2/HookStartedNotification"; import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; @@ -56,4 +57,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts new file mode 100644 index 0000000000..cd9ced0b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExperimentalFeatureEnablementSetParams = { +/** + * Process-wide runtime feature enablement keyed by canonical feature name. + * + * Only named features are updated. Omitted features are left unchanged. + * Send an empty map for a no-op. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts new file mode 100644 index 0000000000..903576d9bc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.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. + +export type ExperimentalFeatureEnablementSetResponse = { +/** + * Feature enablement entries updated by this request. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts new file mode 100644 index 0000000000..2e9bd0d6ec --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts @@ -0,0 +1,17 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Filesystem watch notification emitted for `fs/watch` subscribers. + */ +export type FsChangedNotification = { +/** + * Watch identifier returned by `fs/watch`. + */ +watchId: string, +/** + * File or directory paths associated with this event. + */ +changedPaths: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts new file mode 100644 index 0000000000..b21befdb5c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts @@ -0,0 +1,12 @@ +// 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. + +/** + * Stop filesystem watch notifications for a prior `fs/watch`. + */ +export type FsUnwatchParams = { +/** + * Watch identifier returned by `fs/watch`. + */ +watchId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts new file mode 100644 index 0000000000..02507d2c00 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Successful response for `fs/unwatch`. + */ +export type FsUnwatchResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts new file mode 100644 index 0000000000..d6d956b288 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts @@ -0,0 +1,13 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Start filesystem watch notifications for an absolute path. + */ +export type FsWatchParams = { +/** + * Absolute file or directory path to watch. + */ +path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts new file mode 100644 index 0000000000..1927272821 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts @@ -0,0 +1,17 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Created watch handle returned by `fs/watch`. + */ +export type FsWatchResponse = { +/** + * Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + */ +watchId: string, +/** + * Canonicalized path associated with the watch. + */ +path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index a531b78dcf..b75ee3930a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.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 HookEventName = "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts new file mode 100644 index 0000000000..3e60e2149e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type MarketplaceLoadErrorInfo = { marketplacePath: AbsolutePathBuf, message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts index 4ca9b8a714..7ae5f8e505 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -1,6 +1,7 @@ // 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 { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; -export type PluginListResponse = { marketplaces: Array, remoteSyncError: string | null, featuredPluginIds: Array, }; +export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, remoteSyncError: string | null, featuredPluginIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts index 818e0b05d4..ea37393536 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillSummary.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { SkillInterface } from "./SkillInterface"; -export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, }; +export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts index 5a4bcf9bc0..273d593cfd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsConfigWriteParams.ts @@ -1,5 +1,14 @@ // 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type SkillsConfigWriteParams = { path: string, enabled: boolean, }; +export type SkillsConfigWriteParams = { +/** + * Path-based selector. + */ +path?: AbsolutePathBuf | null, +/** + * Name-based selector. + */ +name?: string | null, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c649aec06a..d0b1b88198 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -81,6 +81,8 @@ export type { DynamicToolSpec } from "./DynamicToolSpec"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; +export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams"; +export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; @@ -97,6 +99,7 @@ export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaN export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams"; export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse"; export type { FileUpdateChange } from "./FileUpdateChange"; +export type { FsChangedNotification } from "./FsChangedNotification"; export type { FsCopyParams } from "./FsCopyParams"; export type { FsCopyResponse } from "./FsCopyResponse"; export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams"; @@ -110,6 +113,10 @@ export type { FsReadFileParams } from "./FsReadFileParams"; export type { FsReadFileResponse } from "./FsReadFileResponse"; export type { FsRemoveParams } from "./FsRemoveParams"; export type { FsRemoveResponse } from "./FsRemoveResponse"; +export type { FsUnwatchParams } from "./FsUnwatchParams"; +export type { FsUnwatchResponse } from "./FsUnwatchResponse"; +export type { FsWatchParams } from "./FsWatchParams"; +export type { FsWatchResponse } from "./FsWatchResponse"; export type { FsWriteFileParams } from "./FsWriteFileParams"; export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; @@ -141,6 +148,7 @@ export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; export type { MarketplaceInterface } from "./MarketplaceInterface"; +export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; export type { McpAuthStatus } from "./McpAuthStatus"; export type { McpElicitationArrayType } from "./McpElicitationArrayType"; export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 3c5fa6dc2e..b1a8f474f3 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -4,6 +4,7 @@ mod jsonrpc_lite; mod protocol; mod schema_fixtures; +pub use codex_git_utils::GitSha; pub use experimental_api::*; pub use export::GenerateTsOptions; pub use export::generate_internal_json_schema; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 56897566d9..5a9215f2ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -14,16 +14,6 @@ use serde::Serialize; use strum_macros::Display; use ts_rs::TS; -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] -#[ts(type = "string")] -pub struct GitSha(pub String); - -impl GitSha { - pub fn new(sha: &str) -> Self { - Self(sha.to_string()) - } -} - /// Authentication mode for OpenAI-backed providers. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] @@ -336,6 +326,14 @@ client_request_definitions! { params: v2::FsCopyParams, response: v2::FsCopyResponse, }, + FsWatch => "fs/watch" { + params: v2::FsWatchParams, + response: v2::FsWatchResponse, + }, + FsUnwatch => "fs/unwatch" { + params: v2::FsUnwatchParams, + response: v2::FsUnwatchResponse, + }, SkillsConfigWrite => "skills/config/write" { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, @@ -394,6 +392,10 @@ client_request_definitions! { params: v2::ExperimentalFeatureListParams, response: v2::ExperimentalFeatureListResponse, }, + ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { + params: v2::ExperimentalFeatureEnablementSetParams, + response: v2::ExperimentalFeatureEnablementSetResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -909,6 +911,7 @@ server_notification_definitions! { AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification), + FsChanged => "fs/changed" (v2::FsChangedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification), ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification), @@ -1488,6 +1491,27 @@ mod tests { Ok(()) } + #[test] + fn serialize_fs_watch() -> Result<()> { + let request = ClientRequest::FsWatch { + request_id: RequestId::Integer(10), + params: v2::FsWatchParams { + path: absolute_path("tmp/repo/.git"), + }, + }; + assert_eq!( + json!({ + "method": "fs/watch", + "id": 10, + "params": { + "path": absolute_path_string("tmp/repo/.git") + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_list_experimental_features() -> Result<()> { let request = ClientRequest::ExperimentalFeatureList { 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 11dfe29769..48fa56d687 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -74,6 +74,8 @@ pub struct ThreadHistoryBuilder { turns: Vec, current_turn: Option, next_item_index: i64, + current_rollout_index: usize, + next_rollout_index: usize, } impl Default for ThreadHistoryBuilder { @@ -88,6 +90,8 @@ impl ThreadHistoryBuilder { turns: Vec::new(), current_turn: None, next_item_index: 1, + current_rollout_index: 0, + next_rollout_index: 0, } } @@ -111,6 +115,19 @@ impl ThreadHistoryBuilder { self.current_turn.is_some() } + pub fn active_turn_id_if_explicit(&self) -> Option { + self.current_turn + .as_ref() + .filter(|turn| turn.opened_explicitly) + .map(|turn| turn.id.clone()) + } + + pub fn active_turn_start_index(&self) -> Option { + self.current_turn + .as_ref() + .map(|turn| turn.rollout_start_index) + } + /// Shared reducer for persisted rollout replay and in-memory current-turn /// tracking used by running thread resume/rejoin. /// @@ -182,6 +199,8 @@ impl ThreadHistoryBuilder { } pub fn handle_rollout_item(&mut self, item: &RolloutItem) { + self.current_rollout_index = self.next_rollout_index; + self.next_rollout_index += 1; match item { RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::Compacted(payload) => self.handle_compacted(payload), @@ -974,6 +993,7 @@ impl ThreadHistoryBuilder { status: TurnStatus::Completed, opened_explicitly: false, saw_compaction: false, + rollout_start_index: self.current_rollout_index, } } @@ -1137,6 +1157,8 @@ struct PendingTurn { /// True when this turn includes a persisted `RolloutItem::Compacted`, which /// should keep the turn from being dropped even without normal items. saw_compaction: bool, + /// Index of the rollout item that opened this turn during replay. + rollout_start_index: usize, } impl PendingTurn { diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 81f3cc58a8..6aa2e9fa30 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::PathBuf; +use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; @@ -21,7 +22,6 @@ use serde::Serialize; use ts_rs::TS; use crate::protocol::common::AuthMode; -use crate::protocol::common::GitSha; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -56,6 +56,8 @@ pub struct InitializeCapabilities { #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub user_agent: String, + /// Absolute path to the server's $CODEX_HOME directory. + pub codex_home: AbsolutePathBuf, /// Platform family for the running app-server target, for example /// `"unix"` or `"windows"`. pub platform_family: String, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a1d5e55621..67c4e95856 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -377,7 +377,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - SessionStart, UserPromptSubmit, Stop + PreToolUse, SessionStart, UserPromptSubmit, Stop } ); @@ -1926,6 +1926,25 @@ pub struct ExperimentalFeatureListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: std::collections::BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: std::collections::BTreeMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2301,6 +2320,52 @@ pub struct FsCopyParams { #[ts(export_to = "v2/")] pub struct FsCopyResponse {} +/// Start filesystem watch notifications for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchParams { + /// Absolute file or directory path to watch. + pub path: AbsolutePathBuf, +} + +/// Created watch handle returned by `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchResponse { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, + /// Canonicalized path associated with the watch. + pub path: AbsolutePathBuf, +} + +/// Stop filesystem watch notifications for a prior `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchParams { + /// Watch identifier returned by `fs/watch`. + pub watch_id: String, +} + +/// Successful response for `fs/unwatch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchResponse {} + +/// Filesystem watch notification emitted for `fs/watch` subscribers. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsChangedNotification { + /// Watch identifier returned by `fs/watch`. + pub watch_id: String, + /// File or directory paths associated with this event. + pub changed_paths: Vec, +} + /// PTY size in character cells for `command/exec` PTY sessions. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -3146,11 +3211,21 @@ pub struct PluginListParams { #[ts(export_to = "v2/")] pub struct PluginListResponse { pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, pub remote_sync_error: Option, #[serde(default)] pub featured_plugin_ids: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceLoadErrorInfo { + pub marketplace_path: AbsolutePathBuf, + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -3340,6 +3415,7 @@ pub struct SkillSummary { pub short_description: Option, pub interface: Option, pub path: PathBuf, + pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -3378,7 +3454,12 @@ pub enum PluginSource { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct SkillsConfigWriteParams { - pub path: PathBuf, + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, pub enabled: bool, } @@ -6481,6 +6562,33 @@ mod tests { assert_eq!(decoded, response); } + #[test] + fn fs_changed_notification_round_trips() { + let notification = FsChangedNotification { + watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), + changed_paths: vec![ + absolute_path("tmp/repo/.git/HEAD"), + absolute_path("tmp/repo/.git/FETCH_HEAD"), + ], + }; + + let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); + assert_eq!( + value, + json!({ + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": [ + absolute_path_string("tmp/repo/.git/HEAD"), + absolute_path_string("tmp/repo/.git/FETCH_HEAD"), + ], + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/changed notification"); + assert_eq!(decoded, notification); + } + #[test] fn command_exec_params_default_optional_streaming_flags() { let params = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c6be85984d..1eb09e2605 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -34,6 +34,7 @@ codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } +codex-git-utils = { workspace = true } codex-otel = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } @@ -46,6 +47,7 @@ codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } codex-rmcp-client = { workspace = true } +codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4d139b7f7e..1e186c0d5e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -75,7 +75,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat ## Initialization -Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services plus `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error. +Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services, `codexHome` for the server's Codex home directory, and `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error. `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. @@ -125,7 +125,7 @@ Example with notification opt-out: - `thread/start` β€” create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. - `thread/resume` β€” reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` β€” fork an existing thread into a new thread id by copying the stored history; accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. +- `thread/fork` β€” fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` β€” page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` β€” list the thread ids currently loaded in memory. - `thread/read` β€” read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -159,15 +159,19 @@ Example with notification opt-out: - `fs/readDirectory` β€” list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path. - `fs/remove` β€” remove an absolute file or directory tree; `recursive` and `force` default to `true`. - `fs/copy` β€” copy between absolute paths; directory copies require `recursive: true`. +- `fs/watch` β€” subscribe this connection to filesystem change notifications for an absolute file or directory path; returns a `watchId` and canonicalized `path`. +- `fs/unwatch` β€” stop sending notifications for a prior `fs/watch`; returns `{}`. +- `fs/changed` β€” notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` β€” list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` β€” list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/enablement/set` β€” patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` β€” list available collaboration mode presets (experimental, no pagination). 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`). -- `plugin/list` β€” list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). -- `plugin/read` β€” read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). +- `plugin/list` β€” list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). +- `plugin/read` β€” read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` β€” notification emitted when watched local skill files change. - `app/list` β€” list available apps. -- `skills/config/write` β€” write user-level skill config by path. +- `skills/config/write` β€” write user-level skill config by name or absolute path. - `plugin/install` β€” install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` β€” uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `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. @@ -240,7 +244,7 @@ Example: { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` -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. Pass `ephemeral: true` when the fork should stay in-memory only: +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. 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: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } @@ -795,6 +799,28 @@ All filesystem paths in this section must be absolute. - `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`. - `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped. +### Example: Filesystem watch + +`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations. + +```json +{ "method": "fs/watch", "id": 44, "params": { + "path": "/Users/me/project/.git/HEAD" +} } +{ "id": 44, "result": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "path": "/Users/me/project/.git/HEAD" +} } +{ "method": "fs/changed", "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": ["/Users/me/project/.git/HEAD"] +} } +{ "method": "fs/unwatch", "id": 45, "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1" +} } +{ "id": 45, "result": {} } +``` + ## Events Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications. @@ -1145,14 +1171,29 @@ The server also emits `skills/changed` notifications when watched local skill fi } ``` -To enable or disable a skill by path: +To enable or disable a skill by absolute path: ```json { "method": "skills/config/write", "id": 26, "params": { - "path": "/Users/me/.codex/skills/skill-creator/SKILL.md", + "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md", + "name": null, + "enabled": false + } +} +``` + +To enable or disable a skill by name: + +```json +{ + "method": "skills/config/write", + "id": 27, + "params": { + "path": null, + "name": "github:yeet", "enabled": false } } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 34640a50cf..15484d3d5f 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -110,7 +110,6 @@ use codex_core::ThreadManager; use codex_core::find_thread_name_by_id; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; -use codex_core::sandboxing::intersect_permission_profiles; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; @@ -136,6 +135,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; +use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use std::collections::HashMap; use std::convert::TryFrom; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 8917f392dd..6e42f18e4c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,6 +1,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::apply_runtime_feature_enablement; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; @@ -12,7 +13,6 @@ use crate::models::supported_models; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; -use crate::outgoing_message::OutgoingNotification; use crate::outgoing_message::RequestContext; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::thread_status::ThreadWatchManager; @@ -183,6 +183,7 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; +use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -213,7 +214,6 @@ use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_name_by_id; use codex_core::find_thread_names_by_ids; use codex_core::find_thread_path_by_id_str; -use codex_core::git_info::git_diff_to_remote; use codex_core::mcp::auth::discover_supported_scopes; use codex_core::mcp::auth::resolve_oauth_scopes; use codex_core::mcp::collect_mcp_snapshot; @@ -243,6 +243,7 @@ use codex_features::FEATURES; use codex_features::Feature; use codex_features::Stage; use codex_feedback::CodexFeedback; +use codex_git_utils::git_diff_to_remote; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::auth::login_with_chatgpt_auth_tokens; @@ -283,6 +284,7 @@ use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -371,7 +373,8 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, arg0_paths: Arg0DispatchPaths, config: Arc, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -409,13 +412,21 @@ enum EnsureConversationListenerResult { ConnectionClosed, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RefreshTokenRequestOutcome { + NotAttemptedOrSucceeded, + FailedTransiently, + FailedPermanently, +} + pub(crate) struct CodexMessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) thread_manager: Arc, pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, - pub(crate) cli_overrides: Vec<(String, TomlValue)>, + pub(crate) cli_overrides: Arc>>, + pub(crate) runtime_feature_enablement: Arc>>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, @@ -479,6 +490,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, feedback, log_db, @@ -490,6 +502,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), @@ -510,7 +523,7 @@ impl CodexMessageProcessor { ) -> Result { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() - .cli_overrides(self.cli_overrides.clone()) + .cli_overrides(self.current_cli_overrides()) .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() @@ -520,6 +533,7 @@ impl CodexMessageProcessor { message: format!("failed to reload config: {err}"), data: None, })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); Ok(config) @@ -532,6 +546,20 @@ impl CodexMessageProcessor { .unwrap_or_default() } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -877,7 +905,8 @@ impl CodexMessageProcessor { } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } - | ClientRequest::ConfigBatchWrite { .. } => { + | ClientRequest::ConfigBatchWrite { .. } + | ClientRequest::ExperimentalFeatureEnablementSet { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } ClientRequest::FsReadFile { .. } @@ -886,7 +915,9 @@ impl CodexMessageProcessor { | ClientRequest::FsGetMetadata { .. } | ClientRequest::FsReadDirectory { .. } | ClientRequest::FsRemove { .. } - | ClientRequest::FsCopy { .. } => { + | ClientRequest::FsCopy { .. } + | ClientRequest::FsWatch { .. } + | ClientRequest::FsUnwatch { .. } => { warn!("Filesystem request reached CodexMessageProcessor unexpectedly"); } ClientRequest::ConfigRequirementsRead { .. } => { @@ -1076,7 +1107,7 @@ impl CodexMessageProcessor { let cloud_requirements = self.cloud_requirements.clone(); let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); - let cli_overrides = self.cli_overrides.clone(); + let cli_overrides = self.current_cli_overrides(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1264,11 +1295,9 @@ impl CodexMessageProcessor { self.config.chatgpt_base_url.clone(), self.config.codex_home.clone(), ); - sync_default_client_residency_requirement( - &self.cli_overrides, - self.cloud_requirements.as_ref(), - ) - .await; + let cli_overrides = self.current_cli_overrides(); + sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref()) + .await; self.outgoing .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) @@ -1338,13 +1367,19 @@ impl CodexMessageProcessor { } } - async fn refresh_token_if_requested(&self, do_refresh: bool) { + async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { if self.auth_manager.is_external_auth_active() { - return; + return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token while getting account: {err}"); + let failed_reason = err.failed_reason(); + if failed_reason.is_none() { + tracing::warn!("failed to refresh token while getting account: {err}"); + return RefreshTokenRequestOutcome::FailedTransiently; + } + return RefreshTokenRequestOutcome::FailedPermanently; } + RefreshTokenRequestOutcome::NotAttemptedOrSucceeded } async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) { @@ -1367,18 +1402,25 @@ impl CodexMessageProcessor { } else { match self.auth_manager.auth().await { Some(auth) => { + let permanent_refresh_failure = + self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } - }; + let (reported_auth_method, token_opt) = + if include_token && permanent_refresh_failure { + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, @@ -1603,7 +1645,7 @@ impl CodexMessageProcessor { return; } - let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone()); + let cwd = cwd.unwrap_or_else(|| self.config.cwd.to_path_buf()); let mut env = create_env( &self.config.permissions.shell_environment_policy, /*thread_id*/ None, @@ -1870,8 +1912,8 @@ impl CodexMessageProcessor { personality, ); typesafe_overrides.ephemeral = ephemeral; - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), @@ -1881,10 +1923,12 @@ impl CodexMessageProcessor { codex_home: self.config.codex_home.clone(), }; let request_trace = request_context.request_trace(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let thread_start_task = async move { Self::thread_start_task( listener_task_context, cli_overrides, + runtime_feature_enablement, cloud_requirements, request_id, config, @@ -1911,6 +1955,13 @@ impl CodexMessageProcessor { } } + pub(crate) async fn cancel_active_login(&self) { + let mut guard = self.active_login.lock().await; + if let Some(active_login) = guard.take() { + drop(active_login); + } + } + pub(crate) async fn clear_all_thread_listeners(&self) { self.thread_state_manager.clear_all_listeners().await; } @@ -1950,6 +2001,7 @@ impl CodexMessageProcessor { async fn thread_start_task( listener_task_context: ListenerTaskContext, cli_overrides: Vec<(String, TomlValue)>, + runtime_feature_enablement: BTreeMap, cloud_requirements: CloudRequirementsLoader, request_id: ConnectionRequestId, config_overrides: Option>, @@ -1966,6 +2018,7 @@ impl CodexMessageProcessor { typesafe_overrides, &cloud_requirements, &listener_task_context.codex_home, + &runtime_feature_enablement, ) .await { @@ -3468,13 +3521,16 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -4010,13 +4066,16 @@ impl CodexMessageProcessor { typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -4039,7 +4098,7 @@ impl CodexMessageProcessor { } = match self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path.clone(), persist_extended_history, @@ -5405,7 +5464,7 @@ impl CodexMessageProcessor { per_cwd_extra_user_roots, } = params; let cwds = if cwds.is_empty() { - vec![self.config.cwd.clone()] + vec![self.config.cwd.to_path_buf()] } else { cwds }; @@ -5523,11 +5582,18 @@ impl CodexMessageProcessor { let config_for_marketplace_listing = config.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let data = match tokio::task::spawn_blocking(move || { - let marketplaces = plugins_manager_for_marketplace_listing + let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager_for_marketplace_listing .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; - Ok::, MarketplaceError>( - marketplaces + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces .into_iter() .map(|marketplace| PluginMarketplaceEntry { name: marketplace.name, @@ -5551,11 +5617,19 @@ impl CodexMessageProcessor { .collect(), }) .collect(), - ) + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) }) .await { - Ok(Ok(data)) => data, + Ok(Ok(outcome)) => outcome, Ok(Err(err)) => { self.send_marketplace_error(request_id, err, "list marketplace plugins") .await; @@ -5597,6 +5671,7 @@ impl CodexMessageProcessor { request_id, PluginListResponse { marketplaces: data, + marketplace_load_errors, remote_sync_error, featured_plugin_ids, }, @@ -5672,7 +5747,7 @@ impl CodexMessageProcessor { interface: outcome.plugin.interface.map(plugin_interface_to_info), }, description: outcome.plugin.description, - skills: plugin_skills_to_info(&visible_skills), + skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths), apps: app_summaries, mcp_servers: outcome.plugin.mcp_server_names, }; @@ -5687,8 +5762,30 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: SkillsConfigWriteParams, ) { - let SkillsConfigWriteParams { path, enabled } = params; - let edits = vec![ConfigEdit::SetSkillConfig { path, enabled }]; + let SkillsConfigWriteParams { + path, + name, + enabled, + } = params; + let edit = match (path, name) { + (Some(path), None) => ConfigEdit::SetSkillConfig { + path: path.into_path_buf(), + enabled, + }, + (None, Some(name)) if !name.trim().is_empty() => { + ConfigEdit::SetSkillConfigByName { name, enabled } + } + _ => { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "skills/config/write requires exactly one of path or name".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let edits = vec![edit]; let result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(edits) .apply() @@ -5696,6 +5793,7 @@ impl CodexMessageProcessor { match result { Ok(()) => { + self.thread_manager.plugins_manager().clear_cache(); self.thread_manager.skills_manager().clear_cache(); self.outgoing .send_response( @@ -6485,7 +6583,7 @@ impl CodexMessageProcessor { } = self .thread_manager .fork_thread( - usize::MAX, + ForkSnapshot::Interrupted, config, rollout_path, /*persist_extended_history*/ false, @@ -6807,43 +6905,9 @@ impl CodexMessageProcessor { } }; - // For now, we send a notification for every event, - // Legacy `codex/event/*` notifications are still - // produced here because the in-process app-server lane - // (`codex exec` and other in-process consumers) still - // depends on them. External transports now drop - // `OutgoingMessage::Notification` in `transport.rs`, - // so stdio/websocket clients only observe the typed - // `ServerNotification` translations emitted below. - // - // TODO: remove this raw legacy-notification emission - // entirely once the remaining in-process consumers are - // migrated off `codex/event/*`. - let event_formatted = match &event.msg { - EventMsg::TurnStarted(_) => "task_started", - EventMsg::TurnComplete(_) => "task_complete", - _ => &event.msg.to_string(), - }; - let request_event_name = format!("codex/event/{event_formatted}"); - tracing::trace!( - conversation_id = %conversation_id, - "app-server event: {request_event_name}" - ); - let mut params = match serde_json::to_value(event.clone()) { - Ok(serde_json::Value::Object(map)) => map, - Ok(_) => { - error!("event did not serialize to an object"); - continue; - } - Err(err) => { - error!("failed to serialize event: {err}"); - continue; - } - }; - params.insert( - "conversationId".to_string(), - conversation_id.to_string().into(), - ); + // Track the event before emitting any typed + // translations so thread-local state such as raw event + // opt-in stays synchronized with the conversation. let raw_events_enabled = { let mut thread_state = thread_state.lock().await; thread_state.track_current_turn_event(&event.msg); @@ -6856,18 +6920,6 @@ impl CodexMessageProcessor { continue; } - if !subscribed_connection_ids.is_empty() { - outgoing_for_task - .send_notification_to_connections( - &subscribed_connection_ids, - OutgoingNotification { - method: request_event_name, - params: Some(params.into()), - }, - ) - .await; - } - let thread_outgoing = ThreadScopedOutgoingMessageSender::new( outgoing_for_task.clone(), subscribed_connection_ids, @@ -7202,12 +7254,13 @@ impl CodexMessageProcessor { WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, }; let config = Arc::clone(&self.config); - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); let command_cwd = params .cwd .map(PathBuf::from) - .unwrap_or_else(|| config.cwd.clone()); + .unwrap_or_else(|| config.cwd.to_path_buf()); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; @@ -7222,6 +7275,7 @@ impl CodexMessageProcessor { Some(command_cwd.clone()), &cloud_requirements, &config.codex_home, + &runtime_feature_enablement, ) .await; let setup_result = match derived_config { @@ -7229,7 +7283,7 @@ impl CodexMessageProcessor { let setup_request = WindowsSandboxSetupRequest { mode, policy: config.permissions.sandbox_policy.get().clone(), - policy_cwd: config.cwd.clone(), + policy_cwd: config.cwd.to_path_buf(), command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.clone(), @@ -7669,7 +7723,10 @@ fn skills_to_info( .collect() } -fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec { +fn plugin_skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_skill_paths: &std::collections::HashSet, +) -> Vec { skills .iter() .map(|skill| SkillSummary { @@ -7687,6 +7744,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7859,13 +7918,15 @@ async fn derive_config_from_params( ) .collect::>(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .cloud_requirements(cloud_requirements.clone()) .build() - .await + .await?; + apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); + Ok(config) } async fn derive_config_for_cwd( @@ -7875,6 +7936,7 @@ async fn derive_config_for_cwd( cwd: Option, cloud_requirements: &CloudRequirementsLoader, codex_home: &Path, + runtime_feature_enablement: &BTreeMap, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7887,14 +7949,16 @@ async fn derive_config_for_cwd( ) .collect::>(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .fallback_cwd(cwd) .cloud_requirements(cloud_requirements.clone()) .build() - .await + .await?; + apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); + Ok(config) } async fn read_history_cwd_from_state_db( @@ -8207,7 +8271,7 @@ fn extract_conversation_summary( fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { ConversationGitInfo { - sha: git_info.commit_hash.clone(), + sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()), branch: git_info.branch.clone(), origin_url: git_info.repository_url.clone(), } @@ -8890,6 +8954,7 @@ mod tests { request_id: sent_request_id, .. }), + .. } = request_message else { panic!("expected tool request to be sent to the subscribed connection"); diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs index 0c13f5ed4c..fb9973e8c5 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs @@ -9,7 +9,7 @@ use codex_core::mcp::auth::McpOAuthLoginSupport; use codex_core::mcp::auth::oauth_login_support; use codex_core::mcp::auth::resolve_oauth_scopes; use codex_core::mcp::auth::should_retry_without_scopes; -use codex_rmcp_client::perform_oauth_login; +use codex_rmcp_client::perform_oauth_login_silent; use tracing::warn; use super::CodexMessageProcessor; @@ -45,7 +45,7 @@ impl CodexMessageProcessor { let notification_name = name.clone(); tokio::spawn(async move { - let first_attempt = perform_oauth_login( + let first_attempt = perform_oauth_login_silent( &name, &oauth_config.url, store_mode, @@ -60,7 +60,7 @@ impl CodexMessageProcessor { let final_result = match first_attempt { Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => { - perform_oauth_login( + perform_oauth_login_silent( &name, &oauth_config.url, store_mode, diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index e1a9cb3def..a1a042de6a 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -23,8 +23,8 @@ use codex_core::config::StartedNetworkProxy; use codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; use codex_core::exec::ExecExpiration; use codex_core::exec::IO_DRAIN_TIMEOUT_MS; -use codex_core::exec::SandboxType; use codex_core::sandboxing::ExecRequest; +use codex_sandboxing::SandboxType; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::ProcessHandle; use codex_utils_pty::SpawnedProcess; @@ -42,6 +42,7 @@ use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; +const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024; #[derive(Clone)] pub(crate) struct CommandExecManager { @@ -577,13 +578,19 @@ fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHa let mut buffer: Vec = Vec::new(); let mut observed_num_bytes = 0usize; loop { - let chunk = tokio::select! { + let mut chunk = tokio::select! { chunk = output_rx.recv() => match chunk { Some(chunk) => chunk, None => break, }, _ = stdio_timeout_rx.wait_for(|&v| v) => break, }; + // Individual chunks are at most 8KiB, so overshooting a bit is acceptable. + while chunk.len() < OUTPUT_CHUNK_SIZE_HINT + && let Ok(next_chunk) = output_rx.try_recv() + { + chunk.extend_from_slice(&next_chunk); + } let capped_chunk = match output_bytes_cap { Some(output_bytes_cap) => { let capped_chunk_len = output_bytes_cap @@ -597,8 +604,8 @@ fn spawn_process_output(params: SpawnProcessOutputParams) -> tokio::task::JoinHa let cap_reached = Some(observed_num_bytes) == output_bytes_cap; if let (true, Some(process_id)) = (stream_output, process_id.as_ref()) { outgoing - .send_server_notification_to_connections( - &[connection_id], + .send_server_notification_to_connection_and_wait( + connection_id, ServerNotification::CommandExecOutputDelta( CommandExecOutputDeltaNotification { process_id: process_id.clone(), @@ -727,23 +734,21 @@ mod tests { access: ReadOnlyAccess::FullAccess, network_access: false, }; - ExecRequest { - command: vec!["cmd".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::WindowsRestrictedToken, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - justification: None, - arg0: None, - } + ExecRequest::new( + vec!["cmd".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::DefaultTimeout, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::WindowsRestrictedToken, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ) } #[tokio::test] @@ -809,6 +814,7 @@ mod tests { let OutgoingEnvelope::ToConnection { connection_id, message, + .. } = envelope else { panic!("expected connection-scoped outgoing message"); @@ -840,23 +846,21 @@ mod tests { outgoing: Arc::new(OutgoingMessageSender::new(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), - exec_request: ExecRequest { - command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::Cancellation(CancellationToken::new()), - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::None, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - justification: None, - arg0: None, - }, + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::Cancellation(CancellationToken::new()), + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ), started_network_proxy: None, tty: false, stream_stdin: false, @@ -891,6 +895,7 @@ mod tests { let OutgoingEnvelope::ToConnection { connection_id, message, + .. } = envelope else { panic!("expected connection-scoped outgoing message"); diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index bc82e9152e..4aa2982a81 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteErrorCode; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; +use codex_core::config::Config; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; use codex_core::config_loader::CloudRequirementsLoader; @@ -24,15 +27,27 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem use codex_core::plugins::PluginId; use codex_core::plugins::collect_plugin_enabled_candidates; use codex_core::plugins::installed_plugin_telemetry_metadata; +use codex_features::canonical_feature_for_key; +use codex_features::feature_for_key; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::Op; use serde_json::json; +use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "plugins", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; + #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { async fn reload_user_config(&self); @@ -56,7 +71,8 @@ impl UserConfigReloader for ThreadManager { #[derive(Clone)] pub(crate) struct ConfigApi { codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -66,7 +82,8 @@ pub(crate) struct ConfigApi { impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -75,6 +92,7 @@ impl ConfigApi { Self { codex_home, cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, user_config_reloader, @@ -83,24 +101,87 @@ impl ConfigApi { } fn config_service(&self) -> ConfigService { - let cloud_requirements = self - .cloud_requirements - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); ConfigService::new( self.codex_home.clone(), - self.cli_overrides.clone(), + self.current_cli_overrides(), self.loader_overrides.clone(), - cloud_requirements, + self.current_cloud_requirements(), ) } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_cloud_requirements(&self) -> CloudRequirementsLoader { + self.cloud_requirements + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.current_cli_overrides()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(fallback_cwd) + .cloud_requirements(self.current_cloud_requirements()) + .build() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to resolve feature override precedence: {err}"), + data: None, + })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + Ok(config) + } + pub(crate) async fn read( &self, params: ConfigReadParams, ) -> Result { - self.config_service().read(params).await.map_err(map_error) + let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); + let mut response = self + .config_service() + .read(params) + .await + .map_err(map_error)?; + let config = self.load_latest_config(fallback_cwd).await?; + for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + let Some(feature) = feature_for_key(feature_key) else { + continue; + }; + let features = response + .config + .additional + .entry("features".to_string()) + .or_insert_with(|| json!({})); + if !features.is_object() { + *features = json!({}); + } + if let Some(features) = features.as_object_mut() { + features.insert( + (*feature_key).to_string(), + json!(config.features.enabled(feature)), + ); + } + } + Ok(response) } pub(crate) async fn config_requirements_read( @@ -154,6 +235,68 @@ impl ConfigApi { Ok(response) } + pub(crate) async fn set_experimental_feature_enablement( + &self, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { + if canonical_feature_for_key(key).is_some() { + if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + continue; + } + + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ), + data: None, + }); + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature enablement `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature enablement `{key}`") + }; + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }); + } + + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + { + let mut runtime_feature_enablement = + self.runtime_feature_enablement + .write() + .map_err(|_| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "failed to update feature enablement".to_string(), + data: None, + })?; + runtime_feature_enablement.extend( + enablement + .iter() + .map(|(name, enabled)| (name.clone(), *enabled)), + ); + } + + self.load_latest_config(/*fallback_cwd*/ None).await?; + self.user_config_reloader.reload_user_config().await; + + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) + } + fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap) { for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { @@ -170,6 +313,49 @@ impl ConfigApi { } } +pub(crate) fn protected_feature_keys( + config_layer_stack: &codex_core::config_loader::ConfigLayerStack, +) -> BTreeSet { + let mut protected_features = config_layer_stack + .effective_config() + .get("features") + .and_then(toml::Value::as_table) + .map(|features| features.keys().cloned().collect::>()) + .unwrap_or_default(); + + if let Some(feature_requirements) = config_layer_stack + .requirements_toml() + .feature_requirements + .as_ref() + { + protected_features.extend(feature_requirements.entries.keys().cloned()); + } + + protected_features +} + +pub(crate) fn apply_runtime_feature_enablement( + config: &mut Config, + runtime_feature_enablement: &BTreeMap, +) { + let protected_features = protected_feature_keys(&config.config_layer_stack); + for (name, enabled) in runtime_feature_enablement { + if protected_features.contains(name) { + continue; + } + let Some(feature) = feature_for_key(name) else { + continue; + }; + if let Err(err) = config.features.set_enabled(feature, *enabled) { + warn!( + feature = name, + error = %err, + "failed to apply runtime feature enablement" + ); + } + } +} + fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { @@ -265,6 +451,7 @@ mod tests { use super::*; use codex_core::AnalyticsEventsClient; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; + use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; use serde_json::json; @@ -392,6 +579,66 @@ mod tests { ); } + #[tokio::test] + async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() { + let codex_home = TempDir::new().expect("create temp dir"); + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + ) + .expect("write config"); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cli_overrides(vec![( + "features.apps".to_string(), + TomlValue::Boolean(true), + )]) + .build() + .await + .expect("load config"); + + apply_runtime_feature_enablement( + &mut config, + &BTreeMap::from([("apps".to_string(), false)]), + ); + + assert!(config.features.enabled(Feature::Apps)); + } + + #[tokio::test] + async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() { + let codex_home = TempDir::new().expect("create temp dir"); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![( + "features.apps".to_string(), + TomlValue::Boolean(true), + )]) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some( + codex_core::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("apps".to_string(), false)]), + }, + ), + ..Default::default() + })) + })) + .build() + .await + .expect("load config"); + + apply_runtime_feature_enablement( + &mut config, + &BTreeMap::from([("apps".to_string(), true)]), + ); + + assert!(!config.features.enabled(Feature::Apps)); + } + #[tokio::test] async fn batch_write_reloads_user_config_when_requested() { let codex_home = TempDir::new().expect("create temp dir"); @@ -406,7 +653,8 @@ mod tests { ); let config_api = ConfigApi::new( codex_home.path().to_path_buf(), - Vec::new(), + Arc::new(RwLock::new(Vec::new())), + Arc::new(RwLock::new(BTreeMap::new())), LoaderOverrides::default(), Arc::new(RwLock::new(CloudRequirementsLoader::default())), reloader.clone(), diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 1f8a32362f..57b355f818 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -159,7 +159,7 @@ impl FsApi { } } -fn invalid_request(message: impl Into) -> JSONRPCErrorError { +pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: message.into(), @@ -167,7 +167,7 @@ fn invalid_request(message: impl Into) -> JSONRPCErrorError { } } -fn map_fs_error(err: io::Error) -> JSONRPCErrorError { +pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError { if err.kind() == io::ErrorKind::InvalidInput { invalid_request(err.to_string()) } else { diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs new file mode 100644 index 0000000000..309bee4a64 --- /dev/null +++ b/codex-rs/app-server/src/fs_watch.rs @@ -0,0 +1,379 @@ +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::FsChangedNotification; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsUnwatchResponse; +use codex_app_server_protocol::FsWatchParams; +use codex_app_server_protocol::FsWatchResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ServerNotification; +use codex_core::file_watcher::FileWatcher; +use codex_core::file_watcher::FileWatcherEvent; +use codex_core::file_watcher::FileWatcherSubscriber; +use codex_core::file_watcher::Receiver; +use codex_core::file_watcher::WatchPath; +use codex_core::file_watcher::WatchRegistration; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::collections::HashSet; +use std::hash::Hash; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex as AsyncMutex; +#[cfg(test)] +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Instant; +use tracing::warn; +use uuid::Uuid; + +const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200); + +struct DebouncedReceiver { + rx: Receiver, + interval: Duration, + changed_paths: HashSet, + next_allowance: Option, +} + +impl DebouncedReceiver { + fn new(rx: Receiver, interval: Duration) -> Self { + Self { + rx, + interval, + changed_paths: HashSet::new(), + next_allowance: None, + } + } + + async fn recv(&mut self) -> Option { + while self.changed_paths.is_empty() { + self.changed_paths.extend(self.rx.recv().await?.paths); + } + let next_allowance = *self + .next_allowance + .get_or_insert_with(|| Instant::now() + self.interval); + + loop { + tokio::select! { + event = self.rx.recv() => self.changed_paths.extend(event?.paths), + _ = tokio::time::sleep_until(next_allowance) => break, + } + } + + Some(FileWatcherEvent { + paths: self.changed_paths.drain().collect(), + }) + } +} + +#[derive(Clone)] +pub(crate) struct FsWatchManager { + outgoing: Arc, + file_watcher: Arc, + state: Arc>, +} + +#[derive(Default)] +struct FsWatchState { + entries: HashMap, +} + +struct WatchEntry { + terminate_tx: oneshot::Sender>, + _subscriber: FileWatcherSubscriber, + _registration: WatchRegistration, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WatchKey { + connection_id: ConnectionId, + watch_id: String, +} + +impl FsWatchManager { + pub(crate) fn new(outgoing: Arc) -> Self { + let file_watcher = match FileWatcher::new() { + Ok(file_watcher) => Arc::new(file_watcher), + Err(err) => { + warn!("filesystem watch manager falling back to noop core watcher: {err}"); + Arc::new(FileWatcher::noop()) + } + }; + Self::new_with_file_watcher(outgoing, file_watcher) + } + + fn new_with_file_watcher( + outgoing: Arc, + file_watcher: Arc, + ) -> Self { + Self { + outgoing, + file_watcher, + state: Arc::new(AsyncMutex::new(FsWatchState::default())), + } + } + + pub(crate) async fn watch( + &self, + connection_id: ConnectionId, + params: FsWatchParams, + ) -> Result { + let watch_id = Uuid::now_v7().to_string(); + let outgoing = self.outgoing.clone(); + let (subscriber, rx) = self.file_watcher.add_subscriber(); + let watch_root = params.path.to_path_buf().clone(); + let registration = subscriber.register_paths(vec![WatchPath { + path: params.path.to_path_buf(), + recursive: false, + }]); + let (terminate_tx, terminate_rx) = oneshot::channel(); + + self.state.lock().await.entries.insert( + WatchKey { + connection_id, + watch_id: watch_id.clone(), + }, + WatchEntry { + terminate_tx, + _subscriber: subscriber, + _registration: registration, + }, + ); + + let task_watch_id = watch_id.clone(); + tokio::spawn(async move { + let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); + tokio::pin!(terminate_rx); + loop { + let event = tokio::select! { + biased; + _ = &mut terminate_rx => break, + event = rx.recv() => match event { + Some(event) => event, + None => break, + }, + }; + let mut changed_paths = event + .paths + .into_iter() + .filter_map(|path| { + match AbsolutePathBuf::resolve_path_against_base(&path, &watch_root) { + Ok(path) => Some(path), + Err(err) => { + warn!( + "failed to normalize watch event path ({}) for {}: {err}", + path.display(), + watch_root.display() + ); + None + } + } + }) + .collect::>(); + changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); + if !changed_paths.is_empty() { + outgoing + .send_server_notification_to_connection_and_wait( + connection_id, + ServerNotification::FsChanged(FsChangedNotification { + watch_id: task_watch_id.clone(), + changed_paths, + }), + ) + .await; + } + } + }); + + Ok(FsWatchResponse { + watch_id, + path: params.path, + }) + } + + pub(crate) async fn unwatch( + &self, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) -> Result { + let watch_key = WatchKey { + connection_id, + watch_id: params.watch_id, + }; + let entry = self.state.lock().await.entries.remove(&watch_key); + if let Some(entry) = entry { + // Wait for the oneshot to be destroyed by the task to ensure that no notifications + // are send after the unwatch response. + let (done_tx, done_rx) = oneshot::channel(); + let _ = entry.terminate_tx.send(done_tx); + let _ = done_rx.await; + } + Ok(FsUnwatchResponse {}) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let mut state = self.state.lock().await; + state + .entries + .extract_if(|key, _| key.connection_id == connection_id) + .count(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use uuid::Version; + + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { + assert!( + path.is_absolute(), + "path must be absolute: {}", + path.display() + ); + AbsolutePathBuf::try_from(path).expect("path should be absolute") + } + + fn manager_with_noop_watcher() -> FsWatchManager { + const OUTGOING_BUFFER: usize = 1; + let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); + FsWatchManager::new_with_file_watcher( + Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(FileWatcher::noop()), + ) + } + + #[tokio::test] + async fn watch_returns_a_v7_id_and_tracks_the_owner_scoped_entry() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + + let manager = manager_with_noop_watcher(); + let path = absolute_path(head_path); + let response = manager + .watch(ConnectionId(1), FsWatchParams { path: path.clone() }) + .await + .expect("watch should succeed"); + + assert_eq!(response.path, path); + let watch_id = Uuid::parse_str(&response.watch_id).expect("watch id should be a UUID"); + assert_eq!(watch_id.get_version(), Some(Version::SortRand)); + + let state = manager.state.lock().await; + assert_eq!( + state.entries.keys().cloned().collect::>(), + HashSet::from([WatchKey { + connection_id: ConnectionId(1), + watch_id: response.watch_id, + }]) + ); + } + + #[tokio::test] + async fn unwatch_is_scoped_to_the_connection_that_created_the_watch() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + + let manager = manager_with_noop_watcher(); + let response = manager + .watch( + ConnectionId(1), + FsWatchParams { + path: absolute_path(head_path), + }, + ) + .await + .expect("watch should succeed"); + let watch_key = WatchKey { + connection_id: ConnectionId(1), + watch_id: response.watch_id.clone(), + }; + + manager + .unwatch( + ConnectionId(2), + FsUnwatchParams { + watch_id: response.watch_id.clone(), + }, + ) + .await + .expect("foreign unwatch should be a no-op"); + assert!(manager.state.lock().await.entries.contains_key(&watch_key)); + + manager + .unwatch( + ConnectionId(1), + FsUnwatchParams { + watch_id: response.watch_id, + }, + ) + .await + .expect("owner unwatch should succeed"); + assert!(!manager.state.lock().await.entries.contains_key(&watch_key)); + } + + #[tokio::test] + async fn connection_closed_removes_only_that_connections_watches() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + let fetch_head_path = temp_dir.path().join("FETCH_HEAD"); + let packed_refs_path = temp_dir.path().join("packed-refs"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD"); + std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs"); + + let manager = manager_with_noop_watcher(); + let response_1 = manager + .watch( + ConnectionId(1), + FsWatchParams { + path: absolute_path(head_path), + }, + ) + .await + .expect("first watch should succeed"); + let response_2 = manager + .watch( + ConnectionId(1), + FsWatchParams { + path: absolute_path(fetch_head_path), + }, + ) + .await + .expect("second watch should succeed"); + let response_3 = manager + .watch( + ConnectionId(2), + FsWatchParams { + path: absolute_path(packed_refs_path), + }, + ) + .await + .expect("third watch should succeed"); + + manager.connection_closed(ConnectionId(1)).await; + + assert_eq!( + manager + .state + .lock() + .await + .entries + .keys() + .cloned() + .collect::>(), + HashSet::from([WatchKey { + connection_id: ConnectionId(2), + watch_id: response_3.watch_id, + }]) + ); + assert_ne!(response_1.watch_id, response_2.watch_id); + } +} diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4288d15393..79f32c9083 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -60,6 +60,7 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::OutboundConnectionState; use crate::transport::route_outgoing_envelope; @@ -68,14 +69,11 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_arg0::Arg0DispatchPaths; -use codex_core::AuthManager; -use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; @@ -98,16 +96,6 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b matches!(notification, ServerNotification::TurnCompleted(_)) } -fn legacy_notification_requires_delivery(notification: &JSONRPCNotification) -> bool { - matches!( - notification - .method - .strip_prefix("codex/event/") - .unwrap_or(¬ification.method), - "task_complete" | "turn_aborted" | "shutdown_complete" - ) -} - /// Input needed to start an in-process app-server runtime. /// /// These fields mirror the pieces of ambient process state that stdio and @@ -124,10 +112,6 @@ pub struct InProcessStartArgs { pub loader_overrides: LoaderOverrides, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, - /// Optional prebuilt auth manager reused by an embedding caller. - pub auth_manager: Option>, - /// Optional prebuilt thread manager reused by an embedding caller. - pub thread_manager: Option>, /// Feedback sink used by app-server/core telemetry and logs. pub feedback: CodexFeedback, /// Startup warnings emitted after initialize succeeds. @@ -144,11 +128,6 @@ pub struct InProcessStartArgs { /// Event emitted from the app-server to the in-process client. /// -/// The stream carries three event families because CLI surfaces are mid-migration -/// from the legacy `codex_protocol::Event` model to the typed app-server -/// notification model. Once all surfaces consume only [`ServerNotification`], -/// [`LegacyNotification`](Self::LegacyNotification) can be removed. -/// /// [`Lagged`](Self::Lagged) is a transport health marker, not an application /// event β€” it signals that the consumer fell behind and some events were dropped. #[derive(Debug, Clone)] @@ -157,8 +136,6 @@ pub enum InProcessServerEvent { ServerRequest(ServerRequest), /// App-server notification directed to the embedded client. ServerNotification(ServerNotification), - /// Legacy JSON-RPC notification from core event bridge. - LegacyNotification(JSONRPCNotification), /// Indicates one or more events were dropped due to backpressure. Lagged { skipped: usize }, } @@ -377,7 +354,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(channel_capacity); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx)); - let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); + let (writer_tx, mut writer_rx) = mpsc::channel::(channel_capacity); let outbound_initialized = Arc::new(AtomicBool::new(false)); let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false)); let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new())); @@ -390,7 +367,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { Arc::clone(&outbound_initialized), Arc::clone(&outbound_experimental_api_enabled), Arc::clone(&outbound_opted_out_notification_methods), - /*allow_legacy_notifications*/ true, /*disconnect_sender*/ None, ), ); @@ -410,8 +386,6 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, - auth_manager: args.auth_manager, - thread_manager: args.thread_manager, feedback: args.feedback, log_db: None, config_warnings: args.config_warnings, @@ -484,6 +458,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } processor.clear_runtime_references(); + processor.cancel_active_login().await; processor.connection_closed(IN_PROCESS_CONNECTION_ID).await; processor.clear_all_thread_listeners().await; processor.drain_background_tasks().await; @@ -574,10 +549,11 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } } } - outgoing_message = writer_rx.recv() => { - let Some(outgoing_message) = outgoing_message else { + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { break; }; + let outgoing_message = queued_message.message; match outgoing_message { OutgoingMessage::Response(response) => { if let Some(response_tx) = pending_request_responses.remove(&response.id) { @@ -655,32 +631,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } } } - OutgoingMessage::Notification(notification) => { - let notification = JSONRPCNotification { - method: notification.method, - params: notification.params, - }; - if legacy_notification_requires_delivery(¬ification) { - if event_tx - .send(InProcessServerEvent::LegacyNotification(notification)) - .await - .is_err() - { - break; - } - } else if let Err(send_error) = - event_tx.try_send(InProcessServerEvent::LegacyNotification(notification)) - { - match send_error { - mpsc::error::TrySendError::Full(_) => { - warn!("dropping in-process legacy notification (queue full)"); - } - mpsc::error::TrySendError::Closed(_) => { - break; - } - } - } - } + } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); } } } @@ -759,8 +712,6 @@ mod tests { cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), - auth_manager: None, - thread_manager: None, feedback: CodexFeedback::new(), config_warnings: Vec::new(), session_source, @@ -858,7 +809,7 @@ mod tests { } #[test] - fn guaranteed_delivery_helpers_cover_terminal_notifications() { + fn guaranteed_delivery_helpers_cover_terminal_server_notifications() { assert!(server_notification_requires_delivery( &ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread-1".to_string(), @@ -870,30 +821,5 @@ mod tests { }, }) )); - - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/task_complete".to_string(), - params: None, - } - )); - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/turn_aborted".to_string(), - params: None, - } - )); - assert!(legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/shutdown_complete".to_string(), - params: None, - } - )); - assert!(!legacy_notification_requires_delivery( - &JSONRPCNotification { - method: "codex/event/item_started".to_string(), - params: None, - } - )); } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a4994d34b8..dc2512aaec 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -22,6 +22,7 @@ use crate::message_processor::MessageProcessorArgs; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; @@ -66,6 +67,7 @@ mod error_code; mod external_agent_config_api; mod filters; mod fs_api; +mod fs_watch; mod fuzzy_file_search; pub mod in_process; mod message_processor; @@ -103,9 +105,7 @@ enum OutboundControlEvent { /// Register a new writer for an opened connection. Opened { connection_id: ConnectionId, - writer: mpsc::Sender, - // Allow codex/event/* notifications to be emitted. - allow_legacy_notifications: bool, + writer: mpsc::Sender, disconnect_sender: Option, initialized: Arc, experimental_api_enabled: Arc, @@ -562,7 +562,6 @@ pub async fn run_main_with_transport( OutboundControlEvent::Opened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, initialized, experimental_api_enabled, @@ -575,7 +574,6 @@ pub async fn run_main_with_transport( initialized, experimental_api_enabled, opted_out_notification_methods, - allow_legacy_notifications, disconnect_sender, ), ); @@ -618,8 +616,6 @@ pub async fn run_main_with_transport( cli_overrides, loader_overrides, cloud_requirements: cloud_requirements.clone(), - auth_manager: None, - thread_manager: None, feedback: feedback.clone(), log_db, config_warnings, @@ -675,7 +671,6 @@ pub async fn run_main_with_transport( TransportEvent::ConnectionOpened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, } => { let outbound_initialized = Arc::new(AtomicBool::new(false)); @@ -687,7 +682,6 @@ pub async fn run_main_with_transport( .send(OutboundControlEvent::Opened { connection_id, writer, - allow_legacy_notifications, disconnect_sender, initialized: Arc::clone(&outbound_initialized), experimental_api_enabled: Arc::clone( diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 287fe79755..d070738fba 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use std::future::Future; use std::sync::Arc; @@ -8,9 +9,11 @@ use std::sync::atomic::Ordering; use crate::codex_message_processor::CodexMessageProcessor; use crate::codex_message_processor::CodexMessageProcessorArgs; use crate::config_api::ConfigApi; +use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::fs_api::FsApi; +use crate::fs_watch::FsWatchManager; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; @@ -28,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::ExperimentalApi; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::FsCopyParams; @@ -36,6 +40,8 @@ use codex_app_server_protocol::FsGetMetadataParams; use codex_app_server_protocol::FsReadDirectoryParams; use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCError; @@ -152,6 +158,7 @@ pub(crate) struct MessageProcessor { external_agent_config_api: ExternalAgentConfigApi, fs_api: FsApi, auth_manager: Arc, + fs_watch_manager: FsWatchManager, config: Arc, config_warnings: Arc>, } @@ -172,8 +179,6 @@ pub(crate) struct MessageProcessorArgs { pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, - pub(crate) auth_manager: Option>, - pub(crate) thread_manager: Option>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, pub(crate) config_warnings: Vec, @@ -192,36 +197,27 @@ impl MessageProcessor { cli_overrides, loader_overrides, cloud_requirements, - auth_manager, - thread_manager, feedback, log_db, config_warnings, session_source, enable_codex_api_key_env, } = args; - let (auth_manager, thread_manager) = match (auth_manager, thread_manager) { - (Some(auth_manager), Some(thread_manager)) => (auth_manager, thread_manager), - (None, None) => { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode, - ); - let thread_manager = Arc::new(ThreadManager::new( - config.as_ref(), - auth_manager.clone(), - session_source, - CollaborationModesConfig { - default_mode_request_user_input: config - .features - .enabled(Feature::DefaultModeRequestUserInput), - }, - )); - (auth_manager, thread_manager) - } - _ => panic!("MessageProcessorArgs must provide both auth_manager and thread_manager"), - }; + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode, + ); + let thread_manager = Arc::new(ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + CollaborationModesConfig { + default_mode_request_user_input: config + .features + .enabled(Feature::DefaultModeRequestUserInput), + }, + )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), @@ -232,6 +228,8 @@ impl MessageProcessor { .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); + let cli_overrides = Arc::new(RwLock::new(cli_overrides)); + let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new())); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager: auth_manager.clone(), @@ -240,6 +238,7 @@ impl MessageProcessor { arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), + runtime_feature_enablement: runtime_feature_enablement.clone(), cloud_requirements: cloud_requirements.clone(), feedback, log_db, @@ -252,6 +251,7 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, thread_manager, @@ -259,6 +259,7 @@ impl MessageProcessor { ); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone()); let fs_api = FsApi::default(); + let fs_watch_manager = FsWatchManager::new(outgoing.clone()); Self { outgoing, @@ -267,6 +268,7 @@ impl MessageProcessor { external_agent_config_api, fs_api, auth_manager, + fs_watch_manager, config, config_warnings: Arc::new(config_warnings), } @@ -462,6 +464,10 @@ impl MessageProcessor { self.codex_message_processor.drain_background_tasks().await; } + pub(crate) async fn cancel_active_login(&self) { + self.codex_message_processor.cancel_active_login().await; + } + pub(crate) async fn clear_all_thread_listeners(&self) { self.codex_message_processor .clear_all_thread_listeners() @@ -474,6 +480,7 @@ impl MessageProcessor { pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) { self.outgoing.connection_closed(connection_id).await; + self.fs_watch_manager.connection_closed(connection_id).await; self.codex_message_processor .connection_closed(connection_id) .await; @@ -590,8 +597,21 @@ impl MessageProcessor { } let user_agent = get_codex_user_agent(); + let codex_home = match self.config.codex_home.clone().try_into() { + Ok(codex_home) => codex_home, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("Invalid CODEX_HOME: {err}"), + data: None, + }; + self.outgoing.send_error(connection_request_id, error).await; + return; + } + }; let response = InitializeResponse { user_agent, + codex_home, platform_family: std::env::consts::FAMILY.to_string(), platform_os: std::env::consts::OS.to_string(), }; @@ -686,6 +706,16 @@ impl MessageProcessor { ) .await; } + ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { + self.handle_experimental_feature_enablement_set( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } ClientRequest::ConfigRequirementsRead { request_id, params: _, @@ -766,6 +796,28 @@ impl MessageProcessor { ) .await; } + ClientRequest::FsWatch { request_id, params } => { + self.handle_fs_watch( + ConnectionRequestId { + connection_id, + request_id, + }, + connection_id, + params, + ) + .await; + } + ClientRequest::FsUnwatch { request_id, params } => { + self.handle_fs_unwatch( + ConnectionRequestId { + connection_id, + request_id, + }, + connection_id, + params, + ) + .await; + } other => { // Box the delegated future so this wrapper's async state machine does not // inline the full `CodexMessageProcessor::process_request` future, which @@ -812,7 +864,30 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - match self.config_api.batch_write(params).await { + self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) + .await; + } + + async fn handle_experimental_feature_enablement_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureEnablementSetParams, + ) { + self.handle_config_mutation_result( + request_id, + self.config_api + .set_experimental_feature_enablement(params) + .await, + ) + .await; + } + + async fn handle_config_mutation_result( + &self, + request_id: ConnectionRequestId, + result: std::result::Result, + ) { + match result { Ok(response) => { self.codex_message_processor.clear_plugin_related_caches(); self.codex_message_processor @@ -917,6 +992,30 @@ impl MessageProcessor { Err(error) => self.outgoing.send_error(request_id, error).await, } } + + async fn handle_fs_watch( + &self, + request_id: ConnectionRequestId, + connection_id: ConnectionId, + params: FsWatchParams, + ) { + match self.fs_watch_manager.watch(connection_id, params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn handle_fs_unwatch( + &self, + request_id: ConnectionRequestId, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) { + match self.fs_watch_manager.unwatch(connection_id, params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } } #[cfg(test)] 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 58499745bd..aa66f79f29 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -239,8 +239,6 @@ fn build_test_processor( cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), - auth_manager: None, - thread_manager: None, feedback: CodexFeedback::new(), log_db: None, config_warnings: Vec::new(), @@ -392,6 +390,7 @@ async fn read_response( let crate::outgoing_message::OutgoingEnvelope::ToConnection { connection_id, message, + .. } = envelope else { continue; @@ -422,6 +421,7 @@ async fn read_thread_started_notification( crate::outgoing_message::OutgoingEnvelope::ToConnection { connection_id, message, + .. } => { if connection_id != TEST_CONNECTION_ID { continue; diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 67761525b3..946a819d79 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -81,17 +81,33 @@ impl RequestContext { } } -#[derive(Debug, Clone)] +#[derive(Debug)] pub(crate) enum OutgoingEnvelope { ToConnection { connection_id: ConnectionId, message: OutgoingMessage, + write_complete_tx: Option>, }, Broadcast { message: OutgoingMessage, }, } +#[derive(Debug)] +pub(crate) struct QueuedOutgoingMessage { + pub(crate) message: OutgoingMessage, + pub(crate) write_complete_tx: Option>, +} + +impl QueuedOutgoingMessage { + pub(crate) fn new(message: OutgoingMessage) -> Self { + Self { + message, + write_complete_tx: None, + } + } +} + /// Sends messages to the client and manages request callbacks. pub(crate) struct OutgoingMessageSender { next_server_request_id: AtomicI64, @@ -299,6 +315,7 @@ impl OutgoingMessageSender { .send(OutgoingEnvelope::ToConnection { connection_id: *connection_id, message: outgoing_message.clone(), + write_complete_tx: None, }) .await { @@ -333,6 +350,7 @@ impl OutgoingMessageSender { .send(OutgoingEnvelope::ToConnection { connection_id, message: OutgoingMessage::Request(request), + write_complete_tx: None, }) .await { @@ -519,6 +537,7 @@ impl OutgoingMessageSender { .send(OutgoingEnvelope::ToConnection { connection_id: *connection_id, message: outgoing_message.clone(), + write_complete_tx: None, }) .await { @@ -527,36 +546,26 @@ impl OutgoingMessageSender { } } - pub(crate) async fn send_notification_to_connections( + pub(crate) async fn send_server_notification_to_connection_and_wait( &self, - connection_ids: &[ConnectionId], - notification: OutgoingNotification, + connection_id: ConnectionId, + notification: ServerNotification, ) { - let outgoing_message = OutgoingMessage::Notification(notification); - if connection_ids.is_empty() { - if let Err(err) = self - .sender - .send(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) - .await - { - warn!("failed to send notification to client: {err:?}"); - } - return; - } - for connection_id in connection_ids { - if let Err(err) = self - .sender - .send(OutgoingEnvelope::ToConnection { - connection_id: *connection_id, - message: outgoing_message.clone(), - }) - .await - { - warn!("failed to send notification to client: {err:?}"); - } + tracing::trace!("app-server event: {notification}"); + let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let (write_complete_tx, write_complete_rx) = oneshot::channel(); + if let Err(err) = self + .sender + .send(OutgoingEnvelope::ToConnection { + connection_id, + message: outgoing_message, + write_complete_tx: Some(write_complete_tx), + }) + .await + { + warn!("failed to send server notification to client: {err:?}"); } + let _ = write_complete_rx.await; } pub(crate) async fn send_error( @@ -598,6 +607,7 @@ impl OutgoingMessageSender { let send_fut = self.sender.send(OutgoingEnvelope::ToConnection { connection_id, message, + write_complete_tx: None, }); let send_result = if let Some(request_context) = request_context { send_fut.instrument(request_context.span()).await @@ -616,7 +626,6 @@ impl OutgoingMessageSender { #[serde(untagged)] pub(crate) enum OutgoingMessage { Request(ServerRequest), - Notification(OutgoingNotification), /// AppServerNotification is specific to the case where this is run as an /// "app server" as opposed to an MCP server. AppServerNotification(ServerNotification), @@ -624,13 +633,6 @@ pub(crate) enum OutgoingMessage { Error(OutgoingError), } -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingNotification { - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} - #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingResponse { pub id: RequestId, @@ -858,6 +860,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message, + .. } => { assert_eq!(connection_id, ConnectionId(42)); let OutgoingMessage::Response(response) = message else { @@ -920,6 +923,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message, + .. } => { assert_eq!(connection_id, ConnectionId(9)); let OutgoingMessage::Error(outgoing_error) = message else { @@ -932,6 +936,50 @@ mod tests { } } + #[tokio::test] + async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { + let (tx, mut rx) = mpsc::channel::(4); + let outgoing = OutgoingMessageSender::new(tx); + let send_task = tokio::spawn(async move { + outgoing + .send_server_notification_to_connection_and_wait( + ConnectionId(42), + ServerNotification::ModelRerouted(ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5.3-codex".to_string(), + to_model: "gpt-5.2".to_string(), + reason: ModelRerouteReason::HighRiskCyberActivity, + }), + ) + .await + }); + + let envelope = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("should receive envelope before timeout") + .expect("channel should contain one message"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + write_complete_tx, + } = envelope + else { + panic!("expected targeted server notification envelope"); + }; + assert_eq!(connection_id, ConnectionId(42)); + assert!(matches!(message, OutgoingMessage::AppServerNotification(_))); + write_complete_tx + .expect("write completion sender should be attached") + .send(()) + .expect("receiver should still be waiting"); + + timeout(Duration::from_secs(1), send_task) + .await + .expect("send task should finish after write completion is signaled") + .expect("send task should not panic"); + } + #[tokio::test] async fn connection_closed_clears_registered_request_contexts() { let (tx, _rx) = mpsc::channel::(4); diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 3e24d831ae..21fbc9965b 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -4,6 +4,7 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::QueuedOutgoingMessage; use axum::Router; use axum::body::Body; use axum::extract::ConnectInfo; @@ -187,8 +188,7 @@ impl FromStr for AppServerTransport { pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, - writer: mpsc::Sender, - allow_legacy_notifications: bool, + writer: mpsc::Sender, disconnect_sender: Option, }, ConnectionClosed { @@ -226,25 +226,22 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) experimental_api_enabled: Arc, pub(crate) opted_out_notification_methods: Arc>>, - pub(crate) allow_legacy_notifications: bool, - pub(crate) writer: mpsc::Sender, + pub(crate) writer: mpsc::Sender, disconnect_sender: Option, } impl OutboundConnectionState { pub(crate) fn new( - writer: mpsc::Sender, + writer: mpsc::Sender, initialized: Arc, experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, - allow_legacy_notifications: bool, disconnect_sender: Option, ) -> Self { Self { initialized, experimental_api_enabled, opted_out_notification_methods, - allow_legacy_notifications, writer, disconnect_sender, } @@ -266,13 +263,12 @@ pub(crate) async fn start_stdio_connection( stdio_handles: &mut Vec>, ) -> IoResult<()> { let connection_id = ConnectionId(0); - let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (writer_tx, mut writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, - allow_legacy_notifications: false, disconnect_sender: None, }) .await @@ -314,8 +310,8 @@ pub(crate) async fn start_stdio_connection( stdio_handles.push(tokio::spawn(async move { let mut stdout = io::stdout(); - while let Some(outgoing_message) = writer_rx.recv().await { - let Some(mut json) = serialize_outgoing_message(outgoing_message) else { + while let Some(queued_message) = writer_rx.recv().await { + let Some(mut json) = serialize_outgoing_message(queued_message.message) else { continue; }; json.push('\n'); @@ -323,6 +319,9 @@ pub(crate) async fn start_stdio_connection( error!("Failed to write to stdout: {err}"); break; } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } } info!("stdout writer exited (channel closed)"); })); @@ -369,14 +368,13 @@ async fn run_websocket_connection( websocket_stream: WebSocket, transport_event_tx: mpsc::Sender, ) { - let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); + let (writer_tx, writer_rx) = mpsc::channel::(CHANNEL_CAPACITY); let writer_tx_for_reader = writer_tx.clone(); let disconnect_token = CancellationToken::new(); if transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, writer: writer_tx, - allow_legacy_notifications: false, disconnect_sender: Some(disconnect_token.clone()), }) .await @@ -421,7 +419,7 @@ async fn run_websocket_connection( async fn run_websocket_outbound_loop( mut websocket_writer: futures::stream::SplitSink, - mut writer_rx: mpsc::Receiver, + mut writer_rx: mpsc::Receiver, mut writer_control_rx: mpsc::Receiver, disconnect_token: CancellationToken, ) { @@ -438,16 +436,19 @@ async fn run_websocket_outbound_loop( break; } } - outgoing_message = writer_rx.recv() => { - let Some(outgoing_message) = outgoing_message else { + queued_message = writer_rx.recv() => { + let Some(queued_message) = queued_message else { break; }; - let Some(json) = serialize_outgoing_message(outgoing_message) else { + let Some(json) = serialize_outgoing_message(queued_message.message) else { continue; }; if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() { break; } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } } } } @@ -456,7 +457,7 @@ async fn run_websocket_outbound_loop( async fn run_websocket_inbound_loop( mut websocket_reader: futures::stream::SplitStream, transport_event_tx: mpsc::Sender, - writer_tx_for_reader: mpsc::Sender, + writer_tx_for_reader: mpsc::Sender, writer_control_tx: mpsc::Sender, connection_id: ConnectionId, disconnect_token: CancellationToken, @@ -507,7 +508,7 @@ async fn run_websocket_inbound_loop( async fn forward_incoming_message( transport_event_tx: &mpsc::Sender, - writer: &mpsc::Sender, + writer: &mpsc::Sender, connection_id: ConnectionId, payload: &str, ) -> bool { @@ -524,7 +525,7 @@ async fn forward_incoming_message( async fn enqueue_incoming_message( transport_event_tx: &mpsc::Sender, - writer: &mpsc::Sender, + writer: &mpsc::Sender, connection_id: ConnectionId, message: JSONRPCMessage, ) -> bool { @@ -547,7 +548,7 @@ async fn enqueue_incoming_message( data: None, }, }); - match writer.try_send(overload_error) { + match writer.try_send(QueuedOutgoingMessage::new(overload_error)) { Ok(()) => true, Err(mpsc::error::TrySendError::Closed(_)) => false, Err(mpsc::error::TrySendError::Full(_overload_error)) => { @@ -584,16 +585,6 @@ fn should_skip_notification_for_connection( connection_state: &OutboundConnectionState, message: &OutgoingMessage, ) -> bool { - if !connection_state.allow_legacy_notifications - && matches!(message, OutgoingMessage::Notification(_)) - { - // Raw legacy `codex/event/*` notifications are still emitted upstream - // for in-process compatibility, but they are no longer part of the - // external app-server contract. Keep dropping them here until the - // producer path can be deleted entirely. - return true; - } - let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read() else { warn!("failed to read outbound opted-out notifications"); @@ -604,9 +595,6 @@ fn should_skip_notification_for_connection( let method = notification.to_string(); opted_out_notification_methods.contains(method.as_str()) } - OutgoingMessage::Notification(notification) => { - opted_out_notification_methods.contains(notification.method.as_str()) - } _ => false, } } @@ -626,6 +614,7 @@ async fn send_message_to_connection( connections: &mut HashMap, connection_id: ConnectionId, message: OutgoingMessage, + write_complete_tx: Option>, ) -> bool { let Some(connection_state) = connections.get(&connection_id) else { warn!("dropping message for disconnected connection: {connection_id:?}"); @@ -637,8 +626,12 @@ async fn send_message_to_connection( } let writer = connection_state.writer.clone(); + let queued_message = QueuedOutgoingMessage { + message, + write_complete_tx, + }; if connection_state.can_disconnect() { - match writer.try_send(message) { + match writer.try_send(queued_message) { Ok(()) => false, Err(mpsc::error::TrySendError::Full(_)) => { warn!( @@ -650,7 +643,7 @@ async fn send_message_to_connection( disconnect_connection(connections, connection_id) } } - } else if writer.send(message).await.is_err() { + } else if writer.send(queued_message).await.is_err() { disconnect_connection(connections, connection_id) } else { false @@ -689,8 +682,11 @@ pub(crate) async fn route_outgoing_envelope( OutgoingEnvelope::ToConnection { connection_id, message, + write_complete_tx, } => { - let _ = send_message_to_connection(connections, connection_id, message).await; + let _ = + send_message_to_connection(connections, connection_id, message, write_complete_tx) + .await; } OutgoingEnvelope::Broadcast { message } => { let target_connections: Vec = connections @@ -707,8 +703,13 @@ pub(crate) async fn route_outgoing_envelope( .collect(); for connection_id in target_connections { - let _ = - send_message_to_connection(connections, connection_id, message.clone()).await; + let _ = send_message_to_connection( + connections, + connection_id, + message.clone(), + /*write_complete_tx*/ None, + ) + .await; } } } @@ -719,6 +720,8 @@ mod tests { use super::*; use crate::error_code::OVERLOADED_ERROR_CODE; use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; + use codex_app_server_protocol::ConfigWarningNotification; + use codex_app_server_protocol::ServerNotification; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -817,7 +820,8 @@ mod tests { .recv() .await .expect("request should receive overload error"); - let overload_json = serde_json::to_value(overload).expect("serialize overload error"); + let overload_json = + serde_json::to_value(overload.message).expect("serialize overload error"); assert_eq!( overload_json, json!({ @@ -921,11 +925,15 @@ mod tests { .expect("transport queue should accept first message"); writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }, + )), )) .await .expect("writer queue should accept first message"); @@ -949,8 +957,18 @@ mod tests { .recv() .await .expect("writer queue should still contain original message"); - let queued_json = serde_json::to_value(queued_outgoing).expect("serialize queued message"); - assert_eq!(queued_json, json!({ "method": "queued" })); + let queued_json = + serde_json::to_value(queued_outgoing.message).expect("serialize queued message"); + assert_eq!( + queued_json, + json!({ + "method": "configWarning", + "params": { + "summary": "queued", + "details": null, + }, + }) + ); } #[tokio::test] @@ -958,9 +976,8 @@ mod tests { let connection_id = ConnectionId(7); let (writer_tx, mut writer_rx) = mpsc::channel(1); let initialized = Arc::new(AtomicBool::new(true)); - let opted_out_notification_methods = Arc::new(RwLock::new(HashSet::from([ - "codex/event/task_started".to_string(), - ]))); + let opted_out_notification_methods = + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))); let mut connections = HashMap::new(); connections.insert( @@ -970,7 +987,6 @@ mod tests { initialized, Arc::new(AtomicBool::new(true)), opted_out_notification_methods, - false, None, ), ); @@ -979,12 +995,15 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, }, - ), + )), + write_complete_tx: None, }, ) .await; @@ -996,7 +1015,7 @@ mod tests { } #[tokio::test] - async fn to_connection_legacy_notifications_are_dropped_for_external_clients() { + async fn to_connection_notifications_are_dropped_for_opted_out_clients() { let connection_id = ConnectionId(10); let (writer_tx, mut writer_rx) = mpsc::channel(1); @@ -1007,8 +1026,7 @@ mod tests { writer_tx, Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), - Arc::new(RwLock::new(HashSet::new())), - false, + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))), None, ), ); @@ -1017,24 +1035,27 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, }, - ), + )), + write_complete_tx: None, }, ) .await; assert!( writer_rx.try_recv().is_err(), - "legacy notifications should not reach external clients" + "opted-out notifications should not reach clients" ); } #[tokio::test] - async fn to_connection_legacy_notifications_are_preserved_for_in_process_clients() { + async fn to_connection_notifications_are_preserved_for_non_opted_out_clients() { let connection_id = ConnectionId(11); let (writer_tx, mut writer_rx) = mpsc::channel(1); @@ -1046,7 +1067,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - true, None, ), ); @@ -1055,12 +1075,15 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "codex/event/task_started".to_string(), - params: None, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, }, - ), + )), + write_complete_tx: None, }, ) .await; @@ -1068,13 +1091,12 @@ mod tests { let message = writer_rx .recv() .await - .expect("legacy notification should reach in-process clients"); + .expect("notification should reach non-opted-out clients"); assert!(matches!( - message, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "codex/event/task_started" + message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "task_started" )); } @@ -1091,7 +1113,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(false)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1132,6 +1153,7 @@ mod tests { available_decisions: None, }, }), + write_complete_tx: None, }, ) .await; @@ -1140,7 +1162,7 @@ mod tests { .recv() .await .expect("request should be delivered to the connection"); - let json = serde_json::to_value(message).expect("request should serialize"); + let json = serde_json::to_value(message.message).expect("request should serialize"); assert_eq!(json["params"].get("additionalPermissions"), None); assert_eq!(json["params"].get("skillMetadata"), None); } @@ -1158,7 +1180,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1199,6 +1220,7 @@ mod tests { available_decisions: None, }, }), + write_complete_tx: None, }, ) .await; @@ -1207,7 +1229,7 @@ mod tests { .recv() .await .expect("request should be delivered to the connection"); - let json = serde_json::to_value(message).expect("request should serialize"); + let json = serde_json::to_value(message.message).expect("request should serialize"); let allowed_path = absolute_path("/tmp/allowed").to_string_lossy().into_owned(); assert_eq!( json["params"]["additionalPermissions"], @@ -1246,7 +1268,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, Some(fast_disconnect_token.clone()), ), ); @@ -1257,25 +1278,30 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, Some(slow_disconnect_token.clone()), ), ); - let queued_message = - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/already-buffered".to_string(), - params: None, - }); + let queued_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "already-buffered".to_string(), + details: None, + path: None, + range: None, + }), + ); slow_writer_tx - .try_send(queued_message) + .try_send(QueuedOutgoingMessage::new(queued_message)) .expect("channel should have room"); - let broadcast_message = - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method: "codex/event/test".to_string(), - params: None, - }); + let broadcast_message = OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "test".to_string(), + details: None, + path: None, + range: None, + }), + ); timeout( Duration::from_millis(100), route_outgoing_envelope( @@ -1286,24 +1312,28 @@ mod tests { ), ) .await - .expect("broadcast should return even when legacy notifications are dropped"); - assert!(connections.contains_key(&slow_connection_id)); - assert!(!slow_disconnect_token.is_cancelled()); + .expect("broadcast should return even when one connection is slow"); + assert!(!connections.contains_key(&slow_connection_id)); + assert!(slow_disconnect_token.is_cancelled()); assert!(!fast_disconnect_token.is_cancelled()); - assert!( - fast_writer_rx.try_recv().is_err(), - "broadcast legacy notification should be dropped for fast connections" - ); + let fast_message = fast_writer_rx + .try_recv() + .expect("fast connection should receive the broadcast notification"); + assert!(matches!( + fast_message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "test" + )); let slow_message = slow_writer_rx .try_recv() .expect("slow connection should retain its original buffered message"); assert!(matches!( - slow_message, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "codex/event/already-buffered" + slow_message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "already-buffered" )); } @@ -1312,11 +1342,15 @@ mod tests { let connection_id = ConnectionId(3); let (writer_tx, mut writer_rx) = mpsc::channel(1); writer_tx - .send(OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "queued".to_string(), - params: None, - }, + .send(QueuedOutgoingMessage::new( + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "queued".to_string(), + details: None, + path: None, + range: None, + }, + )), )) .await .expect("channel should accept the first queued message"); @@ -1329,7 +1363,6 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(AtomicBool::new(true)), Arc::new(RwLock::new(HashSet::new())), - false, None, ), ); @@ -1339,12 +1372,15 @@ mod tests { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::Notification( - crate::outgoing_message::OutgoingNotification { - method: "second".to_string(), - params: None, - }, + message: OutgoingMessage::AppServerNotification( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "second".to_string(), + details: None, + path: None, + range: None, + }), ), + write_complete_tx: None, }, ) .await @@ -1356,20 +1392,23 @@ mod tests { .expect("first queued message should exist"); timeout(Duration::from_millis(100), route_task) .await - .expect("routing should finish immediately when legacy notifications are dropped") + .expect("routing should finish after the first queued message is drained") .expect("routing task should succeed"); assert!(matches!( - first, - OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification { - method, - params: None, - }) if method == "queued" + first.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "queued" )); + let second = writer_rx + .try_recv() + .expect("second notification should be delivered once the queue has room"); assert!(matches!( - writer_rx.try_recv(), - Err(tokio::sync::mpsc::error::TryRecvError::Empty) - | Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) + second.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "second" )); } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 1a132ccee1..fe21cdafff 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -31,6 +31,8 @@ use codex_app_server_protocol::FsGetMetadataParams; use codex_app_server_protocol::FsReadDirectoryParams; use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; @@ -463,6 +465,16 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. + pub async fn send_experimental_feature_enablement_set_request( + &mut self, + params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/enablement/set", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); @@ -790,6 +802,19 @@ impl McpProcess { self.send_request("fs/copy", params).await } + pub async fn send_fs_watch_request(&mut self, params: FsWatchParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/watch", params).await + } + + pub async fn send_fs_unwatch_request( + &mut self, + params: FsUnwatchParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/unwatch", params).await + } + /// Send an `account/logout` JSON-RPC request. pub async fn send_logout_account_request(&mut self) -> anyhow::Result { self.send_request("account/logout", /*params*/ None).await diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 68d0bcd95d..ff50cd744a 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -1,6 +1,10 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use chrono::Duration; +use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; @@ -8,10 +12,17 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -207,6 +218,288 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::ApiKey), + auth_token: Some("sk-test-key".to_string()), + requires_openai_auth: Some(true), + } + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + let second_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let second_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request_id)), + ) + .await??; + let second_status: GetAuthStatusResponse = to_response(second_resp)?; + assert_eq!(second_status, status); + + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - Duration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("stale-access-token") + .refresh_token("stale-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now() - Duration::days(9))), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(2) + .mount(&server) + .await; + + let refresh_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let failed_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(true), + }) + .await?; + + let failed_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(failed_request_id)), + ) + .await??; + let failed_status: GetAuthStatusResponse = to_response(failed_resp)?; + assert_eq!( + failed_status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("recovered-access-token") + .refresh_token("recovered-refresh-token") + .account_id("acct_123") + .email("user@example.com") + .plan_type("pro") + .last_refresh(Some(Utc::now())), + AuthCredentialsStoreMode::File, + )?; + + let recovered_request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let recovered_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(recovered_request_id)), + ) + .await??; + let recovered_status: GetAuthStatusResponse = to_response(recovered_resp)?; + assert_eq!( + recovered_status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::Chatgpt), + auth_token: Some("recovered-access-token".to_string()), + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/bash b/codex-rs/app-server/tests/suite/bash deleted file mode 100755 index 828d10f266..0000000000 --- a/codex-rs/app-server/tests/suite/bash +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env dotslash - -// This is an instance of the fork of Bash that we bundle with -// https://www.npmjs.com/package/@openai/codex-shell-tool-mcp. -// Fetching the prebuilt version via DotSlash makes it easier to write -// integration tests for shell execution flows. -// -// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for -// multiple platforms, but we could save a bit of space by making arch-specific -// artifacts available in the GitHub releases and referencing those here. -{ - "name": "codex-bash", - "platforms": { - // macOS 13 builds (and therefore x86_64) were dropped in - // https://github.com/openai/codex/pull/7295, so we only provide an - // Apple Silicon build for now. - "macos-aarch64": { - "size": 37003612, - "hash": "blake3", - "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", - "format": "tar.gz", - "path": "package/vendor/aarch64-apple-darwin/bash/macos-15/bash", - "providers": [ - { - "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" - }, - { - "type": "github-release", - "repo": "openai/codex", - "tag": "rust-v0.65.0", - "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" - } - ] - }, - // Note the `musl` parts of the Linux paths are misleading: the Bash - // binaries are actually linked against `glibc`, but the - // `codex-execve-wrapper` that invokes them is linked against `musl`. - "linux-x86_64": { - "size": 37003612, - "hash": "blake3", - "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", - "format": "tar.gz", - "path": "package/vendor/x86_64-unknown-linux-musl/bash/ubuntu-24.04/bash", - "providers": [ - { - "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" - }, - { - "type": "github-release", - "repo": "openai/codex", - "tag": "rust-v0.65.0", - "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" - } - ] - }, - "linux-aarch64": { - "size": 37003612, - "hash": "blake3", - "digest": "d9cd5928c993b65c340507931c61c02bd6e9179933f8bf26a548482bb5fa53bb", - "format": "tar.gz", - "path": "package/vendor/aarch64-unknown-linux-musl/bash/ubuntu-24.04/bash", - "providers": [ - { - "url": "https://github.com/openai/codex/releases/download/rust-v0.65.0/codex-shell-tool-mcp-npm-0.65.0.tgz" - }, - { - "type": "github-release", - "repo": "openai/codex", - "tag": "rust-v0.65.0", - "name": "codex-shell-tool-mcp-npm-0.65.0.tgz" - } - ] - }, - } -} 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 f0216f6bae..c5c76bf8e9 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 @@ -35,7 +35,7 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::ORIGIN; -pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); pub(super) type WsClient = WebSocketStream>; 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 7ff5f6fe39..3063830d5a 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 @@ -3,16 +3,24 @@ use std::time::Duration; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::config::ConfigBuilder; use codex_features::FEATURES; use codex_features::Stage; use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; @@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let actual = to_response::(response)?; + let actual = read_response::(&mut mcp, request_id).await?; let expected_data = FEATURES .iter() .map(|spec| { @@ -82,3 +84,240 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu assert_eq!(actual, expected); Ok(()) } + +#[tokio::test] +async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() +-> Result<()> { + let codex_home = TempDir::new()?; + let project_cwd = codex_home.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + for cwd in [None, Some(project_cwd.display().to_string())] { + let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), + ) + .await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("plugins")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_search")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_suggest")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_call_mcp_elicitation")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::new(), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("personality".to_string(), true)]), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("unsupported feature enablement `personality`"), + "{}", + error.message + ); + assert!( + error + .message + .contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"), + "{}", + error.message + ); + + Ok(()) +} + +async fn set_experimental_feature_enablement( + mcp: &mut McpProcess, + enablement: BTreeMap, +) -> Result { + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result { + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_response(mcp: &mut McpProcess, request_id: i64) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index bc8ae20ec1..e113904809 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -4,11 +4,15 @@ use app_test_support::McpProcess; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::FsChangedNotification; use codex_app_server_protocol::FsCopyParams; use codex_app_server_protocol::FsGetMetadataResponse; use codex_app_server_protocol::FsReadDirectoryEntry; use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -17,6 +21,8 @@ use std::path::PathBuf; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::timeout; +use uuid::Uuid; +use uuid::Version; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -611,3 +617,195 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications() +-> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let fetch_head = git_dir.join("FETCH_HEAD"); + std::fs::create_dir_all(&git_dir)?; + std::fs::write(&fetch_head, "old\n")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + path: absolute_path(git_dir.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(git_dir.clone())); + let watch_id = Uuid::parse_str(&watch_response.watch_id)?; + assert_eq!(watch_id.get_version(), Some(Version::SortRand)); + + std::fs::write(&fetch_head, "updated\n")?; + + // Kernel file watching is not reliable in every sandboxed test environment. + // Keep validating notification shape when the backend does emit, but do not + // fail the whole suite if no OS event arrives. + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!(changed.watch_id, watch_response.watch_id.clone()); + assert_eq!( + changed.changed_paths, + vec![absolute_path(fetch_head.clone())] + ); + } + while timeout( + Duration::from_millis(200), + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await + .is_ok() + {} + + let unwatch_request_id = mcp + .send_fs_unwatch_request(FsUnwatchParams { + watch_id: watch_response.watch_id, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)), + ) + .await??; + + std::fs::write(git_dir.join("packed-refs"), "refs\n")?; + let maybe_notification = timeout( + Duration::from_millis(1500), + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await; + assert!( + maybe_notification.is_err(), + "fs/unwatch should stop future change notifications" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let head_path = git_dir.join("HEAD"); + std::fs::create_dir_all(&git_dir)?; + std::fs::write(&head_path, "ref: refs/heads/main\n")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + path: absolute_path(head_path.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(head_path.clone())); + + replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?; + + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!( + changed, + FsChangedNotification { + watch_id: watch_response.watch_id, + changed_paths: vec![absolute_path(head_path.clone())], + } + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_allows_missing_file_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let fetch_head = git_dir.join("FETCH_HEAD"); + std::fs::create_dir_all(&git_dir)?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + path: absolute_path(fetch_head.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(fetch_head.clone())); + + replace_file_atomically(&fetch_head, "origin/main\n")?; + + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!( + changed, + FsChangedNotification { + watch_id: watch_response.watch_id, + changed_paths: vec![absolute_path(fetch_head.clone())], + } + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_rejects_relative_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let watch_id = mcp + .send_raw_request("fs/watch", Some(json!({ "path": "relative-path" }))) + .await?; + expect_error_message( + &mut mcp, + watch_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + Ok(()) +} + +fn fs_changed_notification(notification: JSONRPCNotification) -> Result { + let params = notification + .params + .context("fs/changed notification should include params")?; + Ok(serde_json::from_value::(params)?) +} + +async fn maybe_fs_changed_notification( + mcp: &mut McpProcess, +) -> Result> { + match timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await + { + Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)), + Err(_) => Ok(None), + } +} + +fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> { + let temp_path = path.with_extension("lock"); + std::fs::write(&temp_path, contents)?; + std::fs::rename(temp_path, path)?; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 9b5f0cacc8..165160468f 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cargo_bin::cargo_bin; use core_test_support::fs_wait; use pretty_assertions::assert_eq; @@ -30,6 +31,7 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { let responses = Vec::new(); let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; + let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -48,11 +50,13 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { }; let InitializeResponse { user_agent, + codex_home: response_codex_home, platform_family, platform_os, } = to_response::(response)?; assert!(user_agent.starts_with("codex_vscode/")); + assert_eq!(response_codex_home, expected_codex_home); assert_eq!(platform_family, std::env::consts::FAMILY); assert_eq!(platform_os, std::env::consts::OS); Ok(()) @@ -63,6 +67,7 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; + let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; let mut mcp = McpProcess::new_with_env( codex_home.path(), @@ -88,11 +93,13 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { }; let InitializeResponse { user_agent, + codex_home: response_codex_home, platform_family, platform_os, } = to_response::(response)?; assert!(user_agent.starts_with("codex_originator_via_env_var/")); + assert_eq!(response_codex_home, expected_codex_home); assert_eq!(platform_family, std::env::consts::FAMILY); assert_eq!(platform_os, std::env::consts::OS); Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 89b1090b84..ce3d4f5286 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -11,6 +11,9 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; @@ -41,16 +44,15 @@ plugins = true } #[tokio::test] -async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> { +async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; write_plugins_enabled_config(codex_home.path())?; - std::fs::write( - repo_root.path().join(".agents/plugins/marketplace.json"), - "{not json", - )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + std::fs::write(marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); let mut mcp = McpProcess::new_with_env( @@ -78,15 +80,24 @@ async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> { let response: PluginListResponse = to_response(response)?; assert!( - response.marketplaces.iter().all(|marketplace| { - marketplace.path - != AbsolutePathBuf::try_from( - repo_root.path().join(".agents/plugins/marketplace.json"), - ) - .expect("absolute marketplace path") - }), + response + .marketplaces + .iter() + .all(|marketplace| { marketplace.path != marketplace_path }), "invalid marketplace should be skipped" ); + assert_eq!(response.marketplace_load_errors.len(), 1); + assert_eq!( + response.marketplace_load_errors[0].marketplace_path, + marketplace_path + ); + assert!( + response.marketplace_load_errors[0] + .message + .contains("invalid marketplace file"), + "unexpected error: {:?}", + response.marketplace_load_errors + ); Ok(()) } @@ -116,6 +127,124 @@ async fn plugin_list_rejects_relative_cwds() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()> +{ + let codex_home = TempDir::new()?; + let valid_repo_root = TempDir::new()?; + let invalid_repo_root = TempDir::new()?; + std::fs::create_dir_all(valid_repo_root.path().join(".git"))?; + std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all( + valid_repo_root + .path() + .join("plugins/valid-plugin/.codex-plugin"), + )?; + std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?; + std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config(codex_home.path())?; + + let valid_marketplace_path = AbsolutePathBuf::try_from( + valid_repo_root + .path() + .join(".agents/plugins/marketplace.json"), + )?; + let invalid_marketplace_path = AbsolutePathBuf::try_from( + invalid_repo_root + .path() + .join(".agents/plugins/marketplace.json"), + )?; + let valid_plugin_path = + AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?; + + std::fs::write( + valid_marketplace_path.as_path(), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugins/valid-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + valid_repo_root + .path() + .join("plugins/valid-plugin/.codex-plugin/plugin.json"), + r#"{"name":"valid-plugin"}"#, + )?; + std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![ + AbsolutePathBuf::try_from(valid_repo_root.path())?, + AbsolutePathBuf::try_from(invalid_repo_root.path())?, + ]), + force_remote_sync: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response.marketplaces, + vec![PluginMarketplaceEntry { + name: "valid-marketplace".to_string(), + path: valid_marketplace_path, + interface: None, + plugins: vec![PluginSummary { + id: "valid-plugin@valid-marketplace".to_string(), + name: "valid-plugin".to_string(), + source: PluginSource::Local { + path: valid_plugin_path, + }, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + interface: None, + }], + }] + ); + assert_eq!(response.marketplace_load_errors.len(), 1); + assert_eq!( + response.marketplace_load_errors[0].marketplace_path, + invalid_marketplace_path + ); + assert!( + response.marketplace_load_errors[0] + .message + .contains("invalid marketplace file"), + "unexpected error: {:?}", + response.marketplace_load_errors + ); + assert_eq!(response.remote_sync_error, None); + assert!(response.featured_plugin_ids.is_empty()); + Ok(()) +} + #[tokio::test] async fn plugin_list_accepts_omitted_cwds() -> Result<()> { let codex_home = TempDir::new()?; 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 5dc7b5624f..977ceb2691 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -162,6 +162,10 @@ description: Visible only for ChatGPT r#"[features] plugins = true +[[skills.config]] +name = "demo-plugin:thread-summarizer" +enabled = false + [plugins."demo-plugin@codex-curated"] enabled = true "#, @@ -244,6 +248,7 @@ enabled = true response.plugin.skills[0].description, "Summarize email threads" ); + assert!(!response.plugin.skills[0].enabled); assert_eq!(response.plugin.apps.len(), 1); assert_eq!(response.plugin.apps[0].id, "gmail"); assert_eq!(response.plugin.apps[0].name, "gmail"); 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 1b40fadc67..a81c87bd4e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -123,7 +123,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { "expected forked thread to include one turn" ); let turn = &thread.turns[0]; - assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.status, TurnStatus::Interrupted); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0] { ThreadItem::UserMessage { content, .. } => { 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 75bffe622c..2b5d1fe8a0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::RolloutItem; @@ -959,7 +960,7 @@ async fn thread_list_includes_git_info() -> Result<()> { create_minimal_config(codex_home.path())?; let git_info = CoreGitInfo { - commit_hash: Some("abc123".to_string()), + commit_hash: Some(GitSha::new("abc123")), branch: Some("main".to_string()), repository_url: Some("https://example.com/repo.git".to_string()), }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 6024fe47f5..680e70f2d8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::state_db::reconcile_rollout; +use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::protocol::GitInfo as RolloutGitInfo; use codex_state::StateRuntime; @@ -378,7 +379,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { "Thread preview", Some("mock_provider"), Some(RolloutGitInfo { - commit_hash: Some("abc123".to_string()), + commit_hash: Some(GitSha::new("abc123")), branch: Some("feature/sidebar-pr".to_string()), repository_url: Some("git@example.com:openai/codex.git".to_string()), }), 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 37ca5a3903..1a73444bda 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -585,9 +585,10 @@ request_max_retries = 0 stream_max_retries = 0 [mcp_servers.required_broken] -command = "codex-definitely-not-a-real-binary" +{required_broken_transport} required = true -"# +"#, + required_broken_transport = broken_mcp_transport_toml() ), ) } @@ -615,8 +616,21 @@ request_max_retries = 0 stream_max_retries = 0 [mcp_servers.optional_broken] -command = "codex-definitely-not-a-real-binary" -"# +{optional_broken_transport} +"#, + optional_broken_transport = broken_mcp_transport_toml() ), ) } + +#[cfg(target_os = "windows")] +fn broken_mcp_transport_toml() -> &'static str { + r#"command = "cmd" +args = ["/C", "exit 1"]"# +} + +#[cfg(not(target_os = "windows"))] +fn broken_mcp_transport_toml() -> &'static str { + r#"command = "/bin/sh" +args = ["-c", "exit 1"]"# +} diff --git a/codex-rs/app-server/tests/suite/zsh b/codex-rs/app-server/tests/suite/zsh index 76cd78ce69..f796fa7201 100755 --- a/codex-rs/app-server/tests/suite/zsh +++ b/codex-rs/app-server/tests/suite/zsh @@ -1,13 +1,14 @@ #!/usr/bin/env dotslash -// This is the patched zsh fork built by -// `.github/workflows/shell-tool-mcp.yml` for the shell-tool-mcp package. +// This is the patched zsh fork corresponding to +// `codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch`. // Fetching the prebuilt version via DotSlash makes it easier to write // integration tests that exercise the zsh fork behavior in app-server tests. // -// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for -// multiple platforms, but we could save a bit of space by making arch-specific -// artifacts available in the GitHub releases and referencing those here. +// This checked-in fixture is still pinned to the latest released bundle that +// contains this binary. New releases publish standalone `codex-zsh-*.tar.gz` +// assets plus a generated `codex-zsh` DotSlash release asset, so this file can +// be retargeted when a newer fork build needs to be exercised in tests. { "name": "codex-zsh", "platforms": { diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index d8dbaaa30d..e210783a35 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -309,14 +309,16 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { - format!("{path_element}{PATH_SEPARATOR}{existing_path}") - } - Err(_) => { - format!("{path_element}") + let updated_path_env_var = match std::env::var_os("PATH") { + Some(existing_path) => { + let mut path_env_var = + std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len()); + path_env_var.push(path); + path_env_var.push(PATH_SEPARATOR); + path_env_var.push(existing_path); + path_env_var } + None => path.as_os_str().to_owned(), }; unsafe { diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 17a6f97ad3..cd14a67009 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -17,7 +17,7 @@ codex-utils-cargo-bin = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } -codex-git = { workspace = true } +codex-git-utils = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 6497777a9f..1a9553955d 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use clap::Parser; use codex_core::config::Config; +use codex_git_utils::ApplyGitRequest; +use codex_git_utils::apply_git_patch; use codex_utils_cli::CliConfigOverrides; use crate::chatgpt_token::init_chatgpt_token_from_auth; @@ -57,13 +59,13 @@ pub async fn apply_diff_from_task( async fn apply_diff(diff: &str, cwd: Option) -> anyhow::Result<()> { let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir())); - let req = codex_git::ApplyGitRequest { + let req = ApplyGitRequest { cwd, diff: diff.to_string(), revert: false, preflight: false, }; - let res = codex_git::apply_git_patch(&req)?; + let res = apply_git_patch(&req)?; if res.exit_code != 0 { anyhow::bail!( "Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c2fd1300c6..7e703efe10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } +codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-terminal-detection = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c65b6dcad5..d519bd5f67 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -11,14 +11,14 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::exec_env::create_env; -use codex_core::landlock::create_linux_sandbox_command_args_for_policies; -#[cfg(target_os = "macos")] -use codex_core::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; #[cfg(target_os = "macos")] use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; use codex_utils_cli::CliConfigOverrides; use tokio::process::Child; use tokio::process::Command as TokioCommand; @@ -260,7 +260,7 @@ async fn run_command_under_sandbox( PathBuf::from("/usr/bin/sandbox-exec"), args, /*arg0*/ None, - cwd, + cwd.to_path_buf(), network_policy, env, |env_map| { @@ -293,7 +293,7 @@ async fn run_command_under_sandbox( codex_linux_sandbox_exe, args, Some("codex-linux-sandbox"), - cwd, + cwd.to_path_buf(), network_policy, env, |env_map| { diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml index 15a206079a..6774d46fdc 100644 --- a/codex-rs/cloud-tasks-client/Cargo.toml +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -25,4 +25,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2.0.17" codex-backend-client = { path = "../backend-client", optional = true } -codex-git = { workspace = true } +codex-git-utils = { workspace = true } diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 29211c2e10..4ea0980227 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -16,6 +16,8 @@ use chrono::Utc; use codex_backend_client as backend; use codex_backend_client::CodeTaskDetailsResponseExt; +use codex_git_utils::ApplyGitRequest; +use codex_git_utils::apply_git_patch; #[derive(Clone)] pub struct HttpClient { @@ -459,13 +461,13 @@ mod api { }); } - let req = codex_git::ApplyGitRequest { + let req = ApplyGitRequest { cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), diff: diff.clone(), revert: false, preflight, }; - let r = codex_git::apply_git_patch(&req) + let r = apply_git_patch(&req) .map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?; let status = if r.exit_code == 0 { diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index b28b356f2a..9519050bae 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -27,4 +27,4 @@ pub use mock::MockClient; #[cfg(feature = "online")] pub use http::HttpClient; -// Reusable apply engine now lives in the shared crate `codex-git`. +// Reusable apply engine now lives in the shared crate `codex-git-utils`. diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index a80c455de4..4ba9968fb0 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -22,6 +22,7 @@ codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [ ] } codex-client = { workspace = true } codex-core = { path = "../core" } +codex-git-utils = { workspace = true } codex-login = { path = "../login" } codex-tui = { path = "../tui" } codex-utils-cli = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index c6abc80c10..7766c1eb8b 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -10,6 +10,8 @@ pub use cli::Cli; use anyhow::anyhow; use chrono::Utc; use codex_cloud_tasks_client::TaskStatus; +use codex_git_utils::current_branch_name; +use codex_git_utils::default_branch_name; use owo_colors::OwoColorize; use owo_colors::Stream; use std::cmp::Ordering; @@ -119,11 +121,11 @@ struct RealGitInfo; #[async_trait::async_trait] impl GitInfoProvider for RealGitInfo { async fn default_branch_name(&self, path: &std::path::Path) -> Option { - codex_core::git_info::default_branch_name(path).await + default_branch_name(path).await } async fn current_branch_name(&self, path: &std::path::Path) -> Option { - codex_core::git_info::current_branch_name(path).await + current_branch_name(path).await } } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 6386c8d424..a3868e4358 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -40,20 +40,23 @@ codex-login = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } codex-execpolicy = { workspace = true } -codex-file-search = { workspace = true } -codex-git = { workspace = true } +codex-git-utils = { workspace = true } codex-hooks = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifacts = { workspace = true } codex-protocol = { workspace = true } +codex-rollout = { workspace = true } codex-rmcp-client = { workspace = true } +codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } +codex-utils-output-truncation = { workspace = true } +codex-utils-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } @@ -93,12 +96,6 @@ similar = { workspace = true } tempfile = { workspace = true } test-log = { workspace = true } thiserror = { workspace = true } -time = { workspace = true, features = [ - "formatting", - "parsing", - "local-offset", - "macros", -] } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c27ec5460e..c154ef463d 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem policy is sandbox-equivalent to the legacy model after `cwd` resolution. - Split filesystem policies that need direct `FileSystemSandboxPolicy` enforcement, such as read-only or denied carveouts under a broader writable root, automatically route through bubblewrap. The legacy Landlock path is used @@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as `C:\ProgramData`. When it is `false`, those extra system roots are omitted. The unelevated restricted-token backend still supports the legacy full-read -Windows model only. Restricted read-only policies continue to fail closed there -instead of running with weaker read enforcement. +Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also +supports a narrow split-filesystem subset: full-read split policies whose +writable roots still match the legacy `WorkspaceWrite` root set, but add extra +read-only carveouts under those writable roots. New `[permissions]` / split filesystem policies remain supported on Windows only when they round-trip through the legacy `SandboxPolicy` model without -changing semantics. Richer split-only carveouts still fail closed instead of -running with weaker enforcement. +changing semantics. Policies that would require direct read restriction, +explicit unreadable carveouts, reopened writable descendants under read-only +carveouts, different writable root sets, or split carveout support in the +elevated setup/runner backend still fail closed instead of running with weaker +enforcement. ### All Platforms diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 009ef57369..3e2d7ff689 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -485,6 +485,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, @@ -1524,13 +1527,21 @@ "enabled": { "type": "boolean" }, + "name": { + "description": "Name-based selector.", + "type": "string" + }, "path": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Path-based selector." } }, "required": [ - "enabled", - "path" + "enabled" ], "type": "object" }, @@ -2091,6 +2102,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 5bc5a0711c..a3b9897688 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1,14 +1,14 @@ use crate::agent::AgentStatus; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::agent::registry::AgentMetadata; use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; +use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::event_mapping::parse_turn_item; use crate::find_archived_thread_path_by_id_str; use crate::find_thread_path_by_id_str; use crate::rollout::RolloutRecorder; @@ -20,9 +20,13 @@ use crate::thread_manager::ThreadManagerState; use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -30,6 +34,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_state::DirectionalThreadSpawnEdgeStatus; +use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; @@ -52,6 +57,13 @@ pub(crate) struct LiveAgent { pub(crate) status: AgentStatus, } +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub(crate) struct ListedAgent { + pub(crate) agent_name: String, + pub(crate) agent_status: AgentStatus, + pub(crate) last_task_message: Option, +} + fn default_agent_nickname_list() -> Vec<&'static str> { AGENT_NAMES .lines() @@ -267,6 +279,7 @@ impl AgentControl { new_thread.thread_id, notification_source, child_reference, + agent_metadata.agent_path.clone(), ); Ok(LiveAgent { @@ -438,6 +451,7 @@ impl AgentControl { resumed_thread.thread_id, Some(notification_source.clone()), child_reference, + agent_metadata.agent_path.clone(), ); self.persist_thread_spawn_edge_for_source( resumed_thread.thread.as_ref(), @@ -455,21 +469,28 @@ impl AgentControl { agent_id: ThreadId, items: Vec, ) -> CodexResult { + let last_task_message = render_input_preview(&items); let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state - .send_op( - agent_id, - Op::UserInput { - items, - final_output_json_schema: None, - }, - ) - .await, - ) - .await + let result = self + .handle_thread_request_result( + agent_id, + &state, + state + .send_op( + agent_id, + Op::UserInput { + items, + final_output_json_schema: None, + }, + ) + .await, + ) + .await; + if result.is_ok() { + self.state + .update_last_task_message(agent_id, last_task_message); + } + result } /// Append a prebuilt message to an existing agent thread outside the normal user-input path. @@ -488,21 +509,27 @@ impl AgentControl { .await } - pub(crate) async fn deliver_inter_agent_instruction( + pub(crate) async fn send_inter_agent_communication( &self, agent_id: ThreadId, - instruction: InterAgentInstruction, - delivery: InterAgentDelivery, + communication: InterAgentCommunication, ) -> CodexResult { + let last_task_message = communication.content.clone(); let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state - .deliver_inter_agent_instruction(agent_id, instruction, delivery) - .await, - ) - .await + let result = self + .handle_thread_request_result( + agent_id, + &state, + state + .send_op(agent_id, Op::InterAgentCommunication { communication }) + .await, + ) + .await; + if result.is_ok() { + self.state + .update_last_task_message(agent_id, last_task_message); + } + result } /// Interrupt the current task for an existing agent thread. @@ -680,6 +707,70 @@ impl AgentControl { .join("\n") } + pub(crate) async fn list_agents( + &self, + current_session_source: &SessionSource, + path_prefix: Option<&str>, + ) -> CodexResult> { + let state = self.upgrade()?; + let resolved_prefix = path_prefix + .map(|prefix| { + current_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .resolve(prefix) + .map_err(CodexErr::UnsupportedOperation) + }) + .transpose()?; + + let mut live_agents = self.state.live_agents(); + live_agents.sort_by(|left, right| { + left.agent_path + .as_deref() + .unwrap_or_default() + .cmp(right.agent_path.as_deref().unwrap_or_default()) + .then_with(|| { + left.agent_id + .map(|id| id.to_string()) + .unwrap_or_default() + .cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default()) + }) + }); + + let mut agents = Vec::with_capacity(live_agents.len()); + for metadata in live_agents { + let Some(thread_id) = metadata.agent_id else { + continue; + }; + if resolved_prefix + .as_ref() + .is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix)) + { + continue; + } + + let Ok(thread) = state.get_thread(thread_id).await else { + continue; + }; + let agent_name = metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| thread_id.to_string()); + let last_task_message = match metadata.last_task_message.clone() { + Some(last_task_message) => Some(last_task_message), + None => last_task_message_for_thread(thread.as_ref()).await, + }; + agents.push(ListedAgent { + agent_name, + agent_status: thread.agent_status().await, + last_task_message, + }); + } + + Ok(agents) + } + /// Starts a detached watcher for sub-agents spawned from another thread. /// /// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and @@ -689,6 +780,7 @@ impl AgentControl { child_thread_id: ThreadId, session_source: Option, child_reference: String, + child_agent_path: Option, ) { let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, .. @@ -719,14 +811,41 @@ impl AgentControl { let Ok(state) = control.upgrade() else { return; }; + let child_thread = state.get_thread(child_thread_id).await.ok(); + let message = format_subagent_notification_message(child_reference.as_str(), &status); + if child_agent_path.is_some() + && child_thread + .as_ref() + .map(|thread| thread.enabled(Feature::MultiAgentV2)) + .unwrap_or(true) + { + let Some(child_agent_path) = child_agent_path.clone() else { + return; + }; + let Some(parent_agent_path) = child_agent_path + .as_str() + .rsplit_once('/') + .and_then(|(parent, _)| AgentPath::try_from(parent).ok()) + else { + return; + }; + let communication = InterAgentCommunication::new( + child_agent_path, + parent_agent_path, + Vec::new(), + message, + /*trigger_turn*/ false, + ); + let _ = control + .send_inter_agent_communication(parent_thread_id, communication) + .await; + return; + } let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; parent_thread - .inject_user_message_without_turn(format_subagent_notification_message( - child_reference.as_str(), - &status, - )) + .inject_user_message_without_turn(message) .await; }); } @@ -766,6 +885,7 @@ impl AgentControl { agent_path, agent_nickname, agent_role, + last_task_message: None, }; Ok((session_source, agent_metadata)) } @@ -929,6 +1049,95 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option, prefix: &AgentPath) -> bool { + if prefix.is_root() { + return true; + } + + agent_path.is_some_and(|agent_path| { + agent_path == prefix + || agent_path + .as_str() + .strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option { + let pending_input = thread.codex.session.pending_input_snapshot().await; + if let Some(message) = pending_input + .iter() + .rev() + .find_map(last_task_message_from_input_item) + { + return Some(message); + } + + let queued_input = thread + .codex + .session + .queued_response_items_for_next_turn_snapshot() + .await; + if let Some(message) = queued_input + .iter() + .rev() + .find_map(last_task_message_from_input_item) + { + return Some(message); + } + + let history = thread.codex.session.clone_history().await; + history + .raw_items() + .iter() + .rev() + .find_map(last_task_message_from_item) +} + +fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option { + let response_item: ResponseItem = item.clone().into(); + last_task_message_from_item(&response_item) +} + +fn last_task_message_from_item(item: &ResponseItem) -> Option { + if !is_user_turn_boundary(item) { + return None; + } + + match item { + ResponseItem::Message { role, .. } if role == "user" => { + let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else { + return None; + }; + Some(render_input_preview(&message.content)) + } + ResponseItem::Message { content, .. } => match content.as_slice() { + [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { + serde_json::from_str::(text) + .ok() + .map(|communication| communication.content) + } + _ => None, + }, + _ => None, + } +} + +fn render_input_preview(items: &[UserInput]) -> String { + items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_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})"), + _ => "[input]".to_string(), + }) + .collect::>() + .join("\n") +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 677bf5a2f3..10e9821aea 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -11,11 +11,13 @@ use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG; use assert_matches::assert_matches; use chrono::Utc; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TurnAbortReason; @@ -125,6 +127,29 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool { }) } +fn history_contains_assistant_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + async fn wait_for_subagent_notification(parent_thread: &Arc) -> bool { let wait = async { loop { @@ -382,6 +407,67 @@ async fn send_input_submits_user_message() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "hello from tests".to_string(), + false, + ); + + let submission_id = harness + .control + .send_inter_agent_communication(thread_id, communication.clone()) + .await + .expect("send_inter_agent_communication should succeed"); + assert!(!submission_id.is_empty()); + + let expected = ( + thread_id, + Op::InterAgentCommunication { + communication: communication.clone(), + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); + + timeout(Duration::from_secs(5), async { + loop { + if thread + .codex + .session + .has_queued_response_items_for_next_turn() + .await + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("inter-agent communication should stay pending"); + + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &history_items, + &communication + )); +} + #[tokio::test] async fn append_message_records_assistant_message() { let harness = AgentControlHarness::new().await; @@ -937,6 +1023,204 @@ async fn spawn_child_completion_notifies_parent_history() { assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); } +#[tokio::test] +async fn multi_agent_v2_completion_ignores_dead_direct_parent() { + let harness = AgentControlHarness::new().await; + let (root_thread_id, root_thread) = harness.start_thread().await; + let mut config = harness.config.clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let worker_thread_id = harness + .control + .spawn_agent( + config.clone(), + text_input("hello worker"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root_thread_id, + depth: 1, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("worker spawn should succeed"); + let tester_path = worker_path.join("tester").expect("tester path"); + let tester_thread_id = harness + .control + .spawn_agent( + config, + text_input("hello tester"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("tester spawn should succeed"); + harness + .control + .shutdown_live_agent(worker_thread_id) + .await + .expect("worker shutdown should succeed"); + + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + sleep(Duration::from_millis(100)).await; + + assert!( + !harness + .manager + .captured_ops() + .into_iter() + .any(|(thread_id, op)| { + thread_id == worker_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == tester_path + && communication.recipient == worker_path + && communication.content == "done" + ) + }) + ); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + "done".to_string(), + true, + ) + )); + assert!(!has_subagent_notification(&root_history_items)); +} + +#[tokio::test] +async fn multi_agent_v2_completion_queues_message_for_direct_parent() { + let harness = AgentControlHarness::new().await; + let (_root_thread_id, root_thread) = harness.start_thread().await; + let (worker_thread_id, _worker_thread) = harness.start_thread().await; + let mut tester_config = harness.config.clone(); + let _ = tester_config.features.enable(Feature::MultiAgentV2); + let tester_thread_id = harness + .manager + .start_thread(tester_config) + .await + .expect("tester thread should start") + .thread_id; + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let tester_path = worker_path.join("tester").expect("tester path"); + harness.control.maybe_start_completion_watcher( + tester_thread_id, + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + tester_path.to_string(), + Some(tester_path.clone()), + ); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + let expected_message = crate::session_prefix::format_subagent_notification_message( + tester_path.as_str(), + &AgentStatus::Completed(Some("done".to_string())), + ); + let expected = ( + worker_thread_id, + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + tester_path.clone(), + worker_path.clone(), + Vec::new(), + expected_message.clone(), + false, + ), + }, + ); + + timeout(Duration::from_secs(5), async { + loop { + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + if captured == Some(expected.clone()) { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("completion watcher should queue a direct-parent message"); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + expected_message, + false, + ) + )); +} + #[tokio::test] async fn completion_watcher_notifies_parent_when_child_is_missing() { let harness = AgentControlHarness::new().await; @@ -953,6 +1237,7 @@ async fn completion_watcher_notifies_parent_when_child_is_missing() { agent_role: Some("explorer".to_string()), })), child_thread_id.to_string(), + None, ); assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); diff --git a/codex-rs/core/src/agent/inter_agent_instruction.rs b/codex-rs/core/src/agent/inter_agent_instruction.rs deleted file mode 100644 index 0eff40beb4..0000000000 --- a/codex-rs/core/src/agent/inter_agent_instruction.rs +++ /dev/null @@ -1,74 +0,0 @@ -use codex_protocol::AgentPath; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum InterAgentDelivery { - CurrentTurn, - NextTurn, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct InterAgentInstruction { - author: AgentPath, - recipient: AgentPath, - other_recipients: Vec, - content: String, -} - -impl InterAgentInstruction { - pub(crate) fn new( - author: AgentPath, - recipient: AgentPath, - other_recipients: Vec, - content: String, - ) -> Self { - Self { - author, - recipient, - other_recipients, - content, - } - } - - pub(crate) fn to_response_item(&self) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: self.as_text(), - }], - end_turn: None, - phase: None, - } - } - - pub(crate) fn is_message_content(content: &[ContentItem]) -> bool { - content.iter().any(|content_item| match content_item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - Self::is_instruction_text(text) - } - _ => false, - }) - } - - fn as_text(&self) -> String { - let other_recipients = self - .other_recipients - .iter() - .map(std::string::ToString::to_string) - .collect::>() - .join(", "); - format!( - "author: {}\nrecipient: {}\nother_recipients: [{other_recipients}]\nContent: {}", - self.author, self.recipient, self.content - ) - } - - fn is_instruction_text(text: &str) -> bool { - text.starts_with("author: ") - && text.contains("\nrecipient: ") - && text.contains("\nother_recipients: [") - && text.contains("]\nContent: ") - } -} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 3f14e5c590..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,6 +1,5 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -pub(crate) mod inter_agent_instruction; mod registry; pub(crate) mod role; pub(crate) mod status; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index af545e8c97..f78c8d08bc 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -38,6 +38,7 @@ pub(crate) struct AgentMetadata { pub(crate) agent_path: Option, pub(crate) agent_nickname: Option, pub(crate) agent_role: Option, + pub(crate) last_task_message: Option, } fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { @@ -151,6 +152,34 @@ impl AgentRegistry { .cloned() } + pub(crate) fn live_agents(&self) -> Vec { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .filter(|metadata| { + metadata.agent_id.is_some() + && !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) + .cloned() + .collect() + } + + pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = Some(last_task_message); + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 06c6eae1e6..b7d7b55ab1 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -252,7 +252,7 @@ mod reload { fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides { ConfigOverrides { - cwd: Some(config.cwd.clone()), + cwd: Some(config.cwd.to_path_buf()), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index bfb90bc4da..922f16de58 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -1,9 +1,9 @@ use crate::AuthManager; use crate::config::Config; use crate::default_client::create_client; -use crate::git_info::collect_git_info; -use crate::git_info::get_git_repo_root; use crate::plugins::PluginTelemetryMetadata; +use codex_git_utils::collect_git_info; +use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::SkillScope; use serde::Serialize; use sha1::Digest; diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 7cc07c0747..3657a23255 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -1,10 +1,72 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; -pub(crate) fn render_apps_section() -> String { +pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { + if !connectors + .iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + { + return None; + } + let body = format!( "## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps." ); - format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}") + Some(format!( + "{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible, + is_enabled, + plugin_display_names: Vec::new(), + } + } + + #[test] + fn omits_apps_section_without_accessible_and_enabled_apps() { + assert_eq!(render_apps_section(&[]), None); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ false + )]), + None + ); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ false, /*is_enabled*/ true + )]), + None + ); + } + + #[test] + fn renders_apps_section_with_an_accessible_and_enabled_app() { + let rendered = render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ true, + )]) + .expect("expected apps section"); + + assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG)); + assert!(rendered.contains("## Apps (Connectors)")); + assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG)); + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b040883c25..1e874b4d66 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -46,7 +47,6 @@ use crate::stream_events_utils::handle_output_item_done; use crate::stream_events_utils::last_assistant_message_from_item; use crate::stream_events_utils::raw_assistant_output_text_from_item; use crate::stream_events_utils::record_completed_response_item; -use crate::truncate::TruncationPolicy; use crate::turn_metadata::TurnMetadataState; use crate::util::error_or_panic; use async_channel::Receiver; @@ -117,6 +117,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_terminal_detection::user_agent; +use codex_utils_output_truncation::TruncationPolicy; use codex_utils_stream_parser::AssistantTextChunk; use codex_utils_stream_parser::AssistantTextStreamParser; use codex_utils_stream_parser::ProposedPlanSegment; @@ -235,9 +236,6 @@ pub(crate) struct PreviousTurnSettings { use crate::exec_policy::ExecPolicyUpdateError; use crate::feedback_tags; -use crate::file_watcher::FileWatcher; -use crate::file_watcher::FileWatcherEvent; -use crate::git_info::get_git_repo_root; use crate::guardian::GuardianReviewSessionManager; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::inspect_pending_input; @@ -322,6 +320,8 @@ use crate::skills::injection::ToolMentionKind; use crate::skills::injection::app_id_from_path; use crate::skills::injection::tool_kind_for_path; use crate::skills::resolve_skill_dependencies_for_turn; +use crate::skills_watcher::SkillsWatcher; +use crate::skills_watcher::SkillsWatcherEvent; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -350,6 +350,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_async_utils::OrCancelExt; +use codex_git_utils::get_git_repo_root; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::metrics::names::THREAD_STARTED_METRIC; @@ -403,7 +404,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, - pub(crate) file_watcher: Arc, + pub(crate) skills_watcher: Arc, pub(crate) conversation_history: InitialHistory, pub(crate) session_source: SessionSource, pub(crate) agent_control: AgentControl, @@ -456,7 +457,7 @@ impl Codex { skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, conversation_history, session_source, agent_control, @@ -644,7 +645,7 @@ impl Codex { skills_manager, plugins_manager, mcp_manager.clone(), - file_watcher, + skills_watcher, agent_control, ) .await @@ -835,10 +836,10 @@ pub(crate) struct TurnContext { pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) environment: Arc, - /// The session's current working directory. All relative paths provided by - /// the model as well as sandbox policies are resolved against this path + /// The session's absolute working directory. All relative paths provided + /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. - pub(crate) cwd: PathBuf, + pub(crate) cwd: AbsolutePathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, @@ -979,7 +980,7 @@ impl TurnContext { pub(crate) fn resolve_path(&self, path: Option) -> PathBuf { path.as_ref() .map(PathBuf::from) - .map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p)) + .map_or_else(|| self.cwd.to_path_buf(), |p| self.cwd.as_path().join(p)) } pub(crate) fn compact_prompt(&self) -> &str { @@ -992,7 +993,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), @@ -1007,7 +1008,7 @@ impl TurnContext { user_instructions: self.user_instructions.clone(), developer_instructions: self.developer_instructions.clone(), final_output_json_schema: self.final_output_json_schema.clone(), - truncation_policy: Some(self.truncation_policy.into()), + truncation_policy: Some(self.truncation_policy), } } @@ -1068,14 +1069,11 @@ pub(crate) struct SessionConfiguration { network_sandbox_policy: NetworkSandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, - /// Working directory that should be treated as the *root* of the + /// Absolute working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the - /// execution sandbox are resolved against this directory **instead** - /// of the process-wide current working directory. CLI front-ends are - /// expected to expand this to an absolute path before sending the - /// `ConfigureSession` operation so that the business-logic layer can - /// operate deterministically. - cwd: PathBuf, + /// execution sandbox are resolved against this directory **instead** of + /// the process-wide current working directory. + cwd: AbsolutePathBuf, /// Directory containing all Codex state for this session. codex_home: PathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -1107,7 +1105,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, sandbox_policy: self.sandbox_policy.get().clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.to_path_buf(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, @@ -1150,11 +1148,23 @@ impl SessionConfiguration { if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let mut cwd_changed = false; - if let Some(cwd) = updates.cwd.clone() { - next_configuration.cwd = cwd; - cwd_changed = true; - } + + let absolute_cwd = updates + .cwd + .as_ref() + .map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir( + cwd.as_path(), + )) + .unwrap_or_else(|e| { + warn!("failed to normalize update cwd: {cwd:?}: {e}"); + self.cwd.clone() + }) + }) + .unwrap_or_else(|| self.cwd.clone()); + + let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); + next_configuration.cwd = absolute_cwd; if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) { // Preserve richer split policies across cwd-only updates; only // rederive when the session is already using the legacy bridge. @@ -1297,13 +1307,13 @@ impl Session { self.out_of_band_elicitation_paused.send_replace(paused); } - fn start_file_watcher_listener(self: &Arc) { - let mut rx = self.services.file_watcher.subscribe(); + fn start_skills_watcher_listener(self: &Arc) { + let mut rx = self.services.skills_watcher.subscribe(); let weak_sess = Arc::downgrade(self); tokio::spawn(async move { loop { match rx.recv().await { - Ok(FileWatcherEvent::SkillsChanged { .. }) => { + Ok(SkillsWatcherEvent::SkillsChanged { .. }) => { let Some(sess) = weak_sess.upgrade() else { break; }; @@ -1351,8 +1361,6 @@ impl Session { let auth_manager_for_context = auth_manager; let provider_for_context = provider; let session_telemetry_for_context = session_telemetry; - let per_turn_config = Arc::new(per_turn_config); - let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &models_manager.try_list_models().unwrap_or_default(), @@ -1372,10 +1380,12 @@ impl Session { .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); + + let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( conversation_id.to_string(), sub_id.clone(), - cwd.clone(), + cwd.to_path_buf(), session_configuration.sandbox_policy.get(), session_configuration.windows_sandbox_level, )); @@ -1439,7 +1449,7 @@ impl Session { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, - file_watcher: Arc, + skills_watcher: Arc, agent_control: AgentControl, ) -> anyhow::Result> { debug!( @@ -1447,13 +1457,6 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); - if !session_configuration.cwd.is_absolute() { - return Err(anyhow::anyhow!( - "cwd is not absolute: {:?}", - session_configuration.cwd - )); - } - let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -1570,7 +1573,7 @@ impl Session { })?; let rollout_path = rollout_recorder .as_ref() - .map(|rec| rec.rollout_path.clone()); + .map(|rec| rec.rollout_path().to_path_buf()); let mut post_session_configured_events = Vec::::new(); @@ -1723,7 +1726,7 @@ impl Session { ShellSnapshot::start_snapshotting( config.codex_home.clone(), conversation_id, - session_configuration.cwd.clone(), + session_configuration.cwd.to_path_buf(), &mut default_shell, session_telemetry.clone(), ) @@ -1858,7 +1861,7 @@ impl Session { skills_manager, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), - file_watcher, + skills_watcher, agent_control, network_proxy, network_approval: Arc::clone(&network_approval), @@ -1922,7 +1925,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, sandbox_policy: session_configuration.sandbox_policy.get().clone(), - cwd: session_configuration.cwd.clone(), + cwd: session_configuration.cwd.to_path_buf(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, history_entry_count, @@ -1937,13 +1940,13 @@ impl Session { } // Start the watcher after SessionConfigured so it cannot emit earlier events. - sess.start_file_watcher_listener(); + sess.start_skills_watcher_listener(); // Construct sandbox_state before MCP startup so it can be sent to each // MCP server immediately after it becomes ready (avoiding blocking). let sandbox_state = SandboxState { sandbox_policy: session_configuration.sandbox_policy.get().clone(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: session_configuration.cwd.clone(), + sandbox_cwd: session_configuration.cwd.to_path_buf(), use_legacy_landlock: config.features.use_legacy_landlock(), }; let mut required_mcp_servers: Vec = mcp_servers @@ -2215,15 +2218,6 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - // Append the current session's initial context after the reconstructed history. - let initial_context = self.build_initial_context(&turn_context).await; - self.record_conversation_items(&turn_context, &initial_context) - .await; - { - let mut state = self.state.lock().await; - state.set_reference_context_item(Some(turn_context.to_turn_context_item())); - } - // Forked threads should remain file-backed immediately after startup. self.ensure_rollout_materialized().await; @@ -2416,7 +2410,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: per_turn_config.cwd.clone(), + sandbox_cwd: per_turn_config.cwd.to_path_buf(), use_legacy_landlock: per_turn_config.features.use_legacy_landlock(), }; if let Err(e) = self @@ -3537,7 +3531,16 @@ impl Session { } } if turn_context.apps_enabled() { - developer_sections.push(render_apps_section()); + let mcp_connection_manager = self.services.mcp_connection_manager.read().await; + let accessible_and_enabled_connectors = + connectors::list_accessible_and_enabled_connectors_from_manager( + &mcp_connection_manager, + &turn_context.config, + ) + .await; + if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) { + developer_sections.push(apps_section); + } } let implicit_skills = turn_context .turn_skills @@ -3956,6 +3959,17 @@ impl Session { } } + pub(crate) async fn pending_input_snapshot(&self) -> Vec { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ts.pending_input_snapshot() + } + None => Vec::with_capacity(0), + } + } + /// Queue response items to be injected into the next active turn created for this session. pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { if items.is_empty() { @@ -3970,6 +3984,12 @@ impl Session { std::mem::take(&mut *self.idle_pending_input.lock().await) } + pub(crate) async fn queued_response_items_for_next_turn_snapshot( + &self, + ) -> Vec { + self.idle_pending_input.lock().await.clone() + } + pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { !self.idle_pending_input.lock().await.is_empty() } @@ -4121,7 +4141,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), - sandbox_cwd: turn_context.cwd.clone(), + sandbox_cwd: turn_context.cwd.to_path_buf(), use_legacy_landlock: turn_context.features.use_legacy_landlock(), }; { @@ -4306,6 +4326,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } + Op::InterAgentCommunication { communication } => { + handlers::inter_agent_communication(&sess, sub.id.clone(), communication).await; + false + } Op::ExecApproval { id: approval_id, turn_id, @@ -4485,6 +4509,7 @@ mod handlers { use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; @@ -4545,6 +4570,7 @@ mod handlers { Op::UserTurn { cwd, approval_policy, + approvals_reviewer, sandbox_policy, model, effort, @@ -4570,7 +4596,7 @@ mod handlers { SessionSettingsUpdate { cwd: Some(cwd), approval_policy: Some(approval_policy), - approvals_reviewer: None, + approvals_reviewer, sandbox_policy: Some(sandbox_policy), windows_sandbox_level: None, collaboration_mode, @@ -4627,6 +4653,29 @@ mod handlers { } } + /// Records an inter-agent assistant envelope and, when requested, wakes the recipient by + /// starting a regular turn if the session is currently idle. + pub async fn inter_agent_communication( + sess: &Arc, + sub_id: String, + communication: InterAgentCommunication, + ) { + let pending_item = communication.to_response_input_item(); + if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await { + return; + } + + sess.queue_response_items_for_next_turn(vec![pending_item]) + .await; + if communication.trigger_turn { + let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; + sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) + .await; + } + } + pub async fn run_user_shell_command(sess: &Arc, sub_id: String, command: String) { if let Some((turn_context, cancellation_token)) = sess.active_turn_context_and_cancellation_token().await @@ -4873,7 +4922,7 @@ mod handlers { ) { let cwds = if cwds.is_empty() { let state = sess.state.lock().await; - vec![state.session_configuration.cwd.clone()] + vec![state.session_configuration.cwd.to_path_buf()] } else { cwds }; @@ -5317,7 +5366,7 @@ async fn spawn_review_thread( let turn_metadata_state = Arc::new(TurnMetadataState::new( sess.conversation_id.to_string(), review_turn_id.clone(), - parent_turn_context.cwd.clone(), + parent_turn_context.cwd.to_path_buf(), parent_turn_context.sandbox_policy.get(), parent_turn_context.windows_sandbox_level, )); @@ -5806,7 +5855,7 @@ pub(crate) async fn run_turn( let stop_request = codex_hooks::StopRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: stop_hook_permission_mode, @@ -5856,7 +5905,7 @@ pub(crate) async fn run_turn( .hooks() .dispatch(HookPayload { session_id: sess.conversation_id, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterAgent { diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 09b4f45613..e468bfceb5 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -3,9 +3,11 @@ use super::*; use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; use crate::protocol::ResumedHistory; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -34,11 +36,18 @@ fn assistant_message(text: &str) -> ResponseItem { } fn inter_agent_assistant_message(text: &str) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").unwrap(), + Vec::new(), + text.to_string(), + true, + ); ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: text.to_string(), + text: serde_json::to_string(&communication).unwrap(), }], end_turn: None, phase: None, @@ -53,7 +62,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -68,7 +77,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -92,7 +101,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -107,7 +116,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let turn_id = previous_context_item .turn_id @@ -455,9 +464,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { turn_id: Some(assistant_turn_id.clone()), ..first_context_item.clone() }; - let assistant_instruction = inter_agent_assistant_message( - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", - ); + let assistant_instruction = inter_agent_assistant_message("continue"); let assistant_reply = assistant_message("worker reply"); let rollout_items = vec![ @@ -844,7 +851,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -859,7 +866,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id @@ -916,7 +923,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -931,7 +938,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), })) .expect("serialize expected reference context item") ); @@ -945,7 +952,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -960,7 +967,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id @@ -1051,7 +1058,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1066,7 +1073,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let rollout_items = vec![ @@ -1153,7 +1160,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1168,7 +1175,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id @@ -1297,7 +1304,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1312,7 +1319,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..d87dd070d5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -22,7 +22,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use std::time::Duration; use tokio::sync::Mutex; @@ -80,7 +79,7 @@ pub(crate) async fn run_codex_thread_interactive( skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), - file_watcher: Arc::clone(&parent_session.services.file_watcher), + skills_watcher: Arc::clone(&parent_session.services.skills_watcher), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source), agent_control: parent_session.services.agent_control.clone(), @@ -518,7 +517,7 @@ async fn handle_patch_approval( let change_count = changes.len(); let maybe_files = changes .keys() - .map(|path| AbsolutePathBuf::from_absolute_path(parent_ctx.cwd.join(path)).ok()) + .map(|path| parent_ctx.cwd.join(path).ok()) .collect::>>(); if let Some(files) = maybe_files { let review_cancel = cancel_token.child_token(); @@ -554,7 +553,7 @@ async fn handle_patch_approval( Arc::clone(parent_ctx), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), - cwd: parent_ctx.cwd.clone(), + cwd: parent_ctx.cwd.to_path_buf(), files, change_count, patch, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 24470051f2..34399a000c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -38,6 +38,7 @@ use crate::protocol::NetworkApprovalProtocol; use crate::protocol::RateLimitSnapshot; use crate::protocol::RateLimitWindow; use crate::protocol::ResumedHistory; +use crate::protocol::RolloutItem; use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; @@ -79,6 +80,7 @@ use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::Submission; use codex_protocol::protocol::W3cTraceContext; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -1114,18 +1116,12 @@ async fn recompute_token_usage_updates_model_context_window() { #[tokio::test] async fn record_initial_history_reconstructs_forked_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Forked(rollout_items)) .await; - let reconstruction_turn = session.new_default_turn().await; - expected.extend( - session - .build_initial_context(reconstruction_turn.as_ref()) - .await, - ); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -1166,6 +1162,35 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }) .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + // The parent rollout writer drains asynchronously after turn completion. + // Wait until the persisted JSONL includes the source user turn before forking from it. + let mut source_history_persisted = false; + for _ in 0..100 { + let history = RolloutRecorder::get_rollout_history(&rollout_path).await; + source_history_persisted = history.ok().is_some_and(|history| { + history.get_rollout_items().into_iter().any(|item| { + matches!( + item, + RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) + if role == "user" + && content.iter().any(|content_item| { + matches!( + content_item, + ContentItem::InputText { text } if text == "fork seed" + ) + }) + ) + }) + }); + if source_history_persisted { + break; + } + sleep(StdDuration::from_millis(10)).await; + } + assert!( + source_history_persisted, + "source rollout should contain the completed pre-fork user turn before forking" + ); let mut fork_config = initial.config.clone(); fork_config.permissions.approval_policy = @@ -1214,7 +1239,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< let request = first_forked_request.single_request(); let snapshot = context_snapshot::format_labeled_requests_snapshot( - "First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.", + "First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.", &[("First Forked Turn Request", &request)], &ContextSnapshotOptions::default() .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }) @@ -1242,7 +1267,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1257,7 +1282,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let turn_id = previous_context_item .turn_id @@ -1279,7 +1304,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { text_elements: Vec::new(), }, )), - RolloutItem::TurnContext(previous_context_item), + RolloutItem::TurnContext(previous_context_item.clone()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { turn_id, @@ -1292,6 +1317,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { .record_initial_history(InitialHistory::Forked(rollout_items)) .await; + let history = session.clone_history().await; assert_eq!( session.previous_turn_settings().await, Some(PreviousTurnSettings { @@ -1299,6 +1325,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { realtime_active: Some(turn_context.realtime_active), }) ); + assert_eq!(history.raw_items(), &[]); + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize fork reference context item"), + serde_json::to_value(Some(previous_context_item)) + .expect("serialize expected reference context item") + ); } #[tokio::test] @@ -2250,10 +2283,9 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd; + session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -2375,10 +2407,9 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd; + session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -2412,6 +2443,36 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ ); } +#[tokio::test] +async fn session_update_settings_keeps_runtime_cwds_absolute() { + let (session, turn_context) = make_session_and_context().await; + let updated_cwd = turn_context + .cwd + .join("project") + .expect("resolve project dir"); + std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); + + session + .update_settings(SessionSettingsUpdate { + cwd: Some(PathBuf::from("project")), + ..Default::default() + }) + .await + .expect("cwd update should succeed"); + + let session_cwd = { + let state = session.state.lock().await; + state.session_configuration.cwd.clone() + }; + let config = session.get_config().await; + let next_turn = session.new_default_turn().await; + + assert_eq!(session_cwd, updated_cwd); + assert_eq!(config.cwd, turn_context.cwd); + assert_eq!(next_turn.cwd, updated_cwd); + assert_eq!(next_turn.config.cwd, updated_cwd); +} + #[tokio::test] async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -2494,7 +2555,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { skills_manager, plugins_manager, mcp_manager, - Arc::new(FileWatcher::noop()), + Arc::new(SkillsWatcher::noop()), AgentControl::default(), ) .await; @@ -2593,7 +2654,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .expect("create environment"), ); - let file_watcher = Arc::new(FileWatcher::noop()); + let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new( McpConnectionManager::new_mcp_connection_manager_for_tests( @@ -2627,7 +2688,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), @@ -3013,6 +3074,41 @@ fn op_kind_distinguishes_turn_ops() { ); } +#[tokio::test] +async fn user_turn_updates_approvals_reviewer() { + let (session, turn_context, _rx) = make_session_and_context_with_rx().await; + let config = session.get_config().await; + + handlers::user_input_or_turn( + &session, + "sub-1".to_string(), + Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + cwd: config.cwd.to_path_buf(), + approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent), + sandbox_policy: config.permissions.sandbox_policy.get().clone(), + model: turn_context.model_info.slug.clone(), + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + service_tier: None, + final_output_json_schema: None, + collaboration_mode: None, + personality: config.personality, + }, + ) + .await; + + let state = session.state.lock().await; + assert_eq!( + state.session_configuration.approvals_reviewer, + crate::config::types::ApprovalsReviewer::GuardianSubagent + ); +} + #[tokio::test] async fn spawn_task_turn_span_inherits_dispatch_trace_context() { struct TraceCaptureTask { @@ -3393,7 +3489,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( .expect("create environment"), ); - let file_watcher = Arc::new(FileWatcher::noop()); + let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new( McpConnectionManager::new_mcp_connection_manager_for_tests( @@ -3427,7 +3523,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), @@ -4993,7 +5089,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { "echo hi".to_string(), ] }, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), expiration: timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index af0fccc9ac..b576ff3eba 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -23,7 +23,8 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathExt; +use core_test_support::TempDirExt; use core_test_support::codex_linux_sandbox_exe_or_skip; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -123,7 +124,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid "echo hi".to_string(), ] }, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), expiration: expiration_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), @@ -388,12 +389,11 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { .expect("write policy file"); let mut config = build_test_config(codex_home.path()).await; - config.cwd = project_dir.path().to_path_buf(); + config.cwd = project_dir.abs(); config.config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_dir.path()) - .expect("absolute project path"), + dot_codex_folder: project_dir.path().abs(), }, toml::Value::Table(Default::default()), )], @@ -435,7 +435,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { true, )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let file_watcher = Arc::new(FileWatcher::noop()); + let skills_watcher = Arc::new(SkillsWatcher::noop()); let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, @@ -444,7 +444,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, conversation_history: InitialHistory::New, session_source: SessionSource::SubAgent(SubAgentSource::Other( GUARDIAN_REVIEWER_NAME.to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index ff36de6c15..9fa7069118 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,4 @@ use crate::agent::AgentStatus; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::SteerInputError; use crate::config::ConstraintResult; @@ -158,6 +156,7 @@ impl CodexThread { /// 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)?; @@ -180,36 +179,6 @@ impl CodexThread { Ok(submission_id) } - pub(crate) async fn deliver_inter_agent_instruction( - &self, - instruction: InterAgentInstruction, - delivery: InterAgentDelivery, - ) -> CodexResult { - let message = instruction.to_response_item(); - match delivery { - InterAgentDelivery::CurrentTurn => self.append_message(message).await, - InterAgentDelivery::NextTurn => self.queue_message_for_next_turn(message).await, - } - } - - /// Queue a prebuilt message so the next turn records it before any submitted user input. - pub(crate) async fn queue_message_for_next_turn( - &self, - message: ResponseItem, - ) -> CodexResult { - let submission_id = uuid::Uuid::new_v4().to_string(); - let pending_item = pending_message_input_item(&message)?; - self.codex - .session - .queue_response_items_for_next_turn(vec![pending_item]) - .await; - self.codex - .session - .ensure_task_for_queued_response_items() - .await; - Ok(submission_id) - } - pub fn rollout_path(&self) -> Option { self.rollout_path.clone() } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7686c5b65c..82fd5c15e3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,9 +15,6 @@ use crate::protocol::CompactedItem; use crate::protocol::EventMsg; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; -use crate::truncate::TruncationPolicy; -use crate::truncate::approx_token_count; -use crate::truncate::truncate_text; use crate::util::backoff; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; @@ -25,6 +22,9 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_text; use futures::prelude::*; use tracing::error; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7786b30873..f67c532341 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -27,6 +27,9 @@ use serde::Deserialize; use tempfile::tempdir; use super::*; +use core_test_support::PathBufExt; +use core_test_support::PathExt; +use core_test_support::TempDirExt; use core_test_support::test_absolute_path; use pretty_assertions::assert_eq; @@ -77,6 +80,23 @@ fn http_mcp(url: &str) -> McpServerConfig { } } +#[test] +fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { + let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; + let codex_home = tempdir()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(PathBuf::from("nested")), + ..Default::default() + }, + codex_home.abs().into_path_buf(), + )?; + + assert_eq!(config.cwd, expected_cwd); + Ok(()) +} + #[test] fn test_toml_parsing() { let history_with_persistence = r#" @@ -460,7 +480,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re codex_home.path().to_path_buf(), )?; - let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap(); + let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy, FileSystemSandboxPolicy::restricted(vec![ @@ -496,9 +516,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re writable_roots: vec![memories_root], read_only_access: ReadOnlyAccess::Restricted { include_platform_defaults: true, - readable_roots: vec![ - AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"), - ], + readable_roots: vec![cwd.path().join("docs").abs(),], }, network_access: false, exclude_tmpdir_env_var: true, @@ -1277,7 +1295,7 @@ fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { temp_dir.path().to_path_buf(), )?; - let expected_backend = AbsolutePathBuf::try_from(backend).unwrap(); + let expected_backend = backend.abs(); if cfg!(target_os = "windows") { match config.permissions.sandbox_policy.get() { SandboxPolicy::ReadOnly { .. } => {} @@ -1327,7 +1345,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(&memories_root)?], + writable_roots: vec![memories_root.abs()], ..Default::default() }), ..Default::default() @@ -1350,7 +1368,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { "expected memories root directory to exist at {}", memories_root.display() ); - let expected_memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + let expected_memories_root = memories_root.abs(); match config.permissions.sandbox_policy.get() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( @@ -1768,7 +1786,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { macos_managed_config_requirements_base64: None, }; - let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( codex_home.path(), Some(cwd), @@ -1897,7 +1915,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { macos_managed_config_requirements_base64: None, }; - let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( codex_home.path(), Some(cwd), @@ -2933,7 +2951,11 @@ struct PrecedenceTestFixture { } impl PrecedenceTestFixture { - fn cwd(&self) -> PathBuf { + fn cwd(&self) -> AbsolutePathBuf { + self.cwd.abs() + } + + fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } @@ -2974,7 +2996,7 @@ fn loads_compact_prompt_from_file() -> std::io::Result<()> { std::fs::write(&prompt_path, " summarize differently ")?; let cfg = ConfigToml { - experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(prompt_path)?), + experimental_compact_prompt_file: Some(prompt_path.abs()), ..Default::default() }; @@ -3071,7 +3093,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), - config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?), + config_file: Some(missing_path.abs()), nickname_candidates: None, }, )]), @@ -4082,7 +4104,7 @@ fn model_catalog_json_loads_from_path() -> std::io::Result<()> { )?; let cfg = ConfigToml { - model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; @@ -4103,7 +4125,7 @@ fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { std::fs::write(&catalog_path, r#"{"models":[]}"#)?; let cfg = ConfigToml { - model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; @@ -4240,7 +4262,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { let o3_profile_overrides = ConfigOverrides { config_profile: Some("o3".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let o3_profile_config: Config = Config::load_from_base_config_with_overrides( @@ -4368,7 +4390,7 @@ fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> { let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), @@ -4384,7 +4406,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { let gpt3_profile_overrides = ConfigOverrides { config_profile: Some("gpt3".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let gpt3_profile_config = Config::load_from_base_config_with_overrides( @@ -4505,7 +4527,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { // Verify that loading without specifying a profile in ConfigOverrides // uses the default profile from the config file (which is "gpt3"). let default_profile_overrides = ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; @@ -4525,7 +4547,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { let zdr_profile_overrides = ConfigOverrides { config_profile: Some("zdr".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let zdr_profile_config = Config::load_from_base_config_with_overrides( @@ -4652,7 +4674,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { let gpt5_profile_overrides = ConfigOverrides { config_profile: Some("gpt5".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let gpt5_profile_config = Config::load_from_base_config_with_overrides( @@ -4820,7 +4842,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any let config = Config::load_config_with_layer_stack( fixture.cfg.clone(), ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), @@ -5639,8 +5661,15 @@ fn write_fake_bwrap(contents: &str) -> tempfile::TempPath { use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; + // Bazel can mount the OS temp directory `noexec`, so prefer the current + // working directory for fake executables and fall back to the default temp + // dir outside that environment. + let temp_file = std::env::current_dir() + .ok() + .and_then(|dir| NamedTempFile::new_in(dir).ok()) + .unwrap_or_else(|| NamedTempFile::new().expect("temp file")); // Linux rejects exec-ing a file that is still open for writing. - let path = NamedTempFile::new().expect("temp file").into_temp_path(); + let path = temp_file.into_temp_path(); fs::write(&path, contents).expect("write fake bwrap"); let permissions = fs::Permissions::from_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod fake bwrap"); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 2865ace4b2..3f7e3b1170 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -46,8 +46,10 @@ pub enum ConfigEdit { RecordModelMigrationSeen { from: String, to: String }, /// Replace the entire `[mcp_servers]` table. ReplaceMcpServers(BTreeMap), - /// Set or clear a skill config entry under `[[skills.config]]`. + /// Set or clear a skill config entry under `[[skills.config]]` by path. SetSkillConfig { path: PathBuf, enabled: bool }, + /// Set or clear a skill config entry under `[[skills.config]]` by name. + SetSkillConfigByName { name: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -60,6 +62,12 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } +#[derive(Clone, Debug, PartialEq, Eq)] +enum SkillConfigSelector { + Name(String), + Path(PathBuf), +} + /// Produces a config edit that sets `[tui].theme = ""`. pub fn syntax_theme_edit(name: &str) -> ConfigEdit { ConfigEdit::SetPath { @@ -387,7 +395,10 @@ impl ConfigDocument { )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), ConfigEdit::SetSkillConfig { path, enabled } => { - Ok(self.set_skill_config(path.as_path(), *enabled)) + Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled)) + } + ConfigEdit::SetSkillConfigByName { name, enabled } => { + Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), @@ -478,8 +489,16 @@ impl ConfigDocument { true } - fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool { - let normalized_path = normalize_skill_config_path(path); + fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool { + let selector = match selector { + SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()), + SkillConfigSelector::Path(path) => { + SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path))) + } + }; + if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) { + return false; + } let mut remove_skills_table = false; let mut mutated = false; @@ -538,12 +557,8 @@ impl ConfigDocument { }; let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - table - .get("path") - .and_then(|item| item.as_str()) - .map(Path::new) - .map(normalize_skill_config_path) - .filter(|value| *value == normalized_path) + skill_config_selector_from_table(table) + .filter(|value| value == &selector) .map(|_| idx) }); @@ -561,7 +576,7 @@ impl ConfigDocument { } else if let Some(index) = existing_index { for (idx, table) in overrides.iter_mut().enumerate() { if idx == index { - table["path"] = value(normalized_path); + write_skill_config_selector(table, &selector); table["enabled"] = value(false); mutated = true; break; @@ -570,7 +585,7 @@ impl ConfigDocument { } else { let mut entry = TomlTable::new(); entry.set_implicit(false); - entry["path"] = value(normalized_path); + write_skill_config_selector(&mut entry, &selector); entry["enabled"] = value(false); overrides.push(entry); mutated = true; @@ -699,6 +714,38 @@ fn normalize_skill_config_path(path: &Path) -> String { .to_string() } +fn skill_config_selector_from_table(table: &TomlTable) -> Option { + let path = table + .get("path") + .and_then(|item| item.as_str()) + .map(Path::new) + .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path)))); + let name = table + .get("name") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| SkillConfigSelector::Name(name.to_string())); + + match (path, name) { + (Some(selector), None) | (None, Some(selector)) => Some(selector), + _ => None, + } +} + +fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) { + match selector { + SkillConfigSelector::Name(name) => { + table.remove("path"); + table["name"] = value(name.clone()); + } + SkillConfigSelector::Path(path) => { + table.remove("name"); + table["path"] = value(path.to_string_lossy().to_string()); + } + } +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 5a31d84dd0..632716f00d 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -110,6 +110,27 @@ enabled = false assert_eq!(contents, ""); } +#[test] +fn set_skill_config_writes_name_selector_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetSkillConfigByName { + name: "github:yeet".to_string(), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[skills.config]] +name = "github:yeet" +enabled = false +"#; + assert_eq!(contents, expected); +} + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cbbd023905..c785b04618 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -39,7 +39,6 @@ use crate::config_loader::McpServerRequirement; use crate::config_loader::ResidencyRequirement; use crate::config_loader::Sourced; use crate::config_loader::load_config_layers_state; -use crate::git_info::resolve_root_git_project_for_trust; use crate::memories::memory_root; use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; @@ -66,6 +65,7 @@ use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; use codex_features::Features; use codex_features::FeaturesToml; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; @@ -134,7 +134,7 @@ pub use service::ConfigService; pub use service::ConfigServiceError; pub use types::ApprovalsReviewer; -pub use codex_git::GhostSnapshotConfig; +pub use codex_git_utils::GhostSnapshotConfig; /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of @@ -395,10 +395,10 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, - /// The directory that should be treated as the current working directory - /// for the session. All relative paths inside the business-logic layer are - /// resolved against this path. - pub cwd: PathBuf, + /// The absolute directory that should be treated as the current working + /// directory for the session. All relative paths inside the business-logic + /// layer are resolved against this path. + pub cwd: AbsolutePathBuf, /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. @@ -477,7 +477,7 @@ pub struct Config { pub file_opener: UriBasedFileOpener, /// Path to the `codex-linux-sandbox` executable. This must be set if - /// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this + /// [`codex_sandboxing::SandboxType::LinuxSeccomp`] is used. Note that this /// cannot be set in the config file: it must be set in code via /// [`ConfigOverrides`]. /// @@ -683,7 +683,7 @@ impl ConfigBuilder { let loader_overrides = loader_overrides.unwrap_or_default(); let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); let cwd = match cwd_override { - Some(path) => AbsolutePathBuf::try_from(path)?, + Some(path) => AbsolutePathBuf::relative_to_current_dir(path)?, None => AbsolutePathBuf::current_dir()?, }; harness_overrides.cwd = Some(cwd.to_path_buf()); @@ -2104,7 +2104,7 @@ impl Config { let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); - let resolved_cwd = normalize_for_native_workdir({ + let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; match cwd { @@ -2121,13 +2121,13 @@ impl Config { current } } - }); + }))?; let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() - .map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd)) + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect::, _>>()?; let active_project = cfg - .get_active_project(&resolved_cwd) + .get_active_project(resolved_cwd.as_path()) .unwrap_or(ProjectConfig { trust_level: None }); let permission_config_syntax = resolve_permission_config_syntax( &config_layer_stack, @@ -2200,12 +2200,15 @@ impl Config { &mut startup_warnings, )?; let mut sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + .to_legacy_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_writable_roots(&resolved_cwd, &additional_writable_roots); + .with_additional_writable_roots( + resolved_cwd.as_path(), + &additional_writable_roots, + ); sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; } ( configured_network_proxy_config, @@ -2219,7 +2222,7 @@ impl Config { sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, - &resolved_cwd, + resolved_cwd.as_path(), Some(&constrained_sandbox_policy), ); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { @@ -2229,8 +2232,10 @@ impl Config { } } } - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &resolved_cwd); + let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + resolved_cwd.as_path(), + ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); ( configured_network_proxy_config, @@ -2566,11 +2571,11 @@ impl Config { } else { FileSystemSandboxPolicy::from_legacy_sandbox_policy( &effective_sandbox_policy, - &resolved_cwd, + resolved_cwd.as_path(), ) }; let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy - .with_additional_readable_roots(&resolved_cwd, &helper_readable_roots); + .with_additional_readable_roots(resolved_cwd.as_path(), &helper_readable_roots); let effective_network_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { network_sandbox_policy diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index bc3006a9a9..a1897c9ddd 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -237,6 +237,54 @@ async fn read_includes_origins_and_layers() { )); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> { + use base64::Engine; + + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?; + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let response = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("updated"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + assert_eq!(response.status, WriteStatus::Ok); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"), + "model = \"updated\"\n" + ); + + Ok(()) +} + #[tokio::test] async fn write_value_reports_override() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 3b20779cd5..7a94700ce9 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -804,7 +804,12 @@ impl Notice { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct SkillConfig { - pub path: AbsolutePathBuf, + /// Path-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub enabled: bool, } diff --git a/codex-rs/core/src/config/types_tests.rs b/codex-rs/core/src/config/types_tests.rs index adb65e1673..dde0cd18fc 100644 --- a/codex-rs/core/src/config/types_tests.rs +++ b/codex-rs/core/src/config/types_tests.rs @@ -243,6 +243,47 @@ fn deserialize_server_config_with_tool_filters() { assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()])); } +#[test] +fn deserialize_skill_config_with_name_selector() { + let cfg: SkillConfig = toml::from_str( + r#" + name = "github:yeet" + enabled = false + "#, + ) + .expect("should deserialize skill config with name selector"); + + assert_eq!(cfg.name.as_deref(), Some("github:yeet")); + assert_eq!(cfg.path, None); + assert!(!cfg.enabled); +} + +#[test] +fn deserialize_skill_config_with_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let cfg: SkillConfig = toml::from_str(&format!( + r#" + path = {path:?} + enabled = false + "#, + path = skill_path.display().to_string(), + )) + .expect("should deserialize skill config with path selector"); + + assert_eq!( + cfg, + SkillConfig { + path: Some( + AbsolutePathBuf::from_absolute_path(&skill_path) + .expect("skill path should be absolute"), + ), + name: None, + enabled: false, + } + ); +} + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 0c426b155f..767ffe635d 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -7,10 +7,10 @@ mod tests; use crate::config::ConfigToml; use crate::config_loader::layer_io::LoadedConfigLayers; -use crate::git_info::resolve_root_git_project_for_trust; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigRequirementsWithSources; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; @@ -285,9 +285,11 @@ pub async fn load_config_layers_state( )); } if let Some(config) = managed_config_from_mdm { + let managed_config = + resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, + managed_config, config.raw_toml, )); } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 03be02ebfe..6a2bc0b6b9 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -380,6 +380,54 @@ flag = false assert!(raw.contains("value = \"managed\"")); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()> +{ + use base64::Engine; + + let Some(home) = dirs::home_dir() else { + return Ok(()); + }; + let tmp = tempdir()?; + + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }) + .build() + .await?; + + let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; + match config.permissions.sandbox_policy.get() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_root) + .count(), + 1, + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 600ba9c6f9..fdb0f43222 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -103,6 +103,19 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) } +pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( + mcp_connection_manager: &McpConnectionManager, + config: &Config, +) -> Vec { + with_app_enabled_state( + accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await), + config, + ) + .into_iter() + .filter(|connector| connector.is_accessible && connector.is_enabled) + .collect() +} + pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6ed0370481..ec2df30d3b 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,13 +1,8 @@ -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::TurnContext; use crate::context_manager::normalize; +use crate::event_mapping::has_non_contextual_dev_message_content; +use crate::event_mapping::is_contextual_dev_message_content; use crate::event_mapping::is_contextual_user_message_content; -use crate::truncate::TruncationPolicy; -use crate::truncate::approx_bytes_for_tokens; -use crate::truncate::approx_token_count; -use crate::truncate::approx_tokens_from_byte_count_i64; -use crate::truncate::truncate_function_output_items_with_policy; -use crate::truncate::truncate_text; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::models::BaseInstructions; @@ -18,11 +13,18 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnContextItem; use codex_utils_cache::BlockingLruCache; use codex_utils_cache::sha1_digest; +use codex_utils_output_truncation::TruncationPolicy; +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_i64; +use codex_utils_output_truncation::truncate_function_output_items_with_policy; +use codex_utils_output_truncation::truncate_text; use std::num::NonZeroUsize; use std::ops::Deref; use std::sync::LazyLock; @@ -40,7 +42,9 @@ pub(crate) struct ContextManager { /// match the current turn after context updates are persisted. /// /// When this is `None`, settings diffing treats the next turn as having no - /// baseline and emits a full reinjection of context state. + /// baseline and emits a full reinjection of context state. Rollback may + /// also clear this when it trims a mixed initial-context developer bundle + /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option, } @@ -215,6 +219,12 @@ impl ContextManager { /// - if there are no user turns, this is a no-op /// - if `num_turns` exceeds the number of user turns, all user turns are dropped while /// preserving any items that occurred before the first user message. + /// + /// If rollback trims a pre-turn developer message that mixes contextual fragments with + /// persistent developer text from `build_initial_context`, this also clears + /// `reference_context_item`. The surviving history no longer contains the full bundle that + /// established the prior baseline, so future turns must fall back to full reinjection instead + /// of diffing against stale state. pub(crate) fn drop_last_n_user_turns(&mut self, num_turns: u32) { if num_turns == 0 { return; @@ -222,18 +232,21 @@ impl ContextManager { let snapshot = self.items.clone(); let user_positions = user_message_positions(&snapshot); - let Some(&first_user_idx) = user_positions.first() else { + let Some(&first_instruction_turn_idx) = user_positions.first() else { self.replace(snapshot); return; }; let n_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX); - let cut_idx = if n_from_end >= user_positions.len() { - first_user_idx + let mut cut_idx = if n_from_end >= user_positions.len() { + first_instruction_turn_idx } else { user_positions[user_positions.len() - n_from_end] }; + cut_idx = + self.trim_pre_turn_context_updates(&snapshot, first_instruction_turn_idx, cut_idx); + self.replace(snapshot[..cut_idx].to_vec()); } @@ -382,6 +395,53 @@ impl ContextManager { | ResponseItem::Other => item.clone(), } } + + /// Walk backward from a rollback cut and trim contiguous pre-turn context-update items. + /// + /// Returns the adjusted cut index after removing contextual developer/user items immediately + /// above the rolled-back turn boundary. + /// + /// `first_instruction_turn_idx` is the earliest rollback-eligible instruction-turn boundary + /// in `snapshot`; the trim walk never crosses it so any session-prefix items that predate the + /// first real turn survive rollback. + /// + /// `cut_idx` is the tentative slice boundary after dropping the requested number of + /// instruction turns, before stripping contextual pre-turn items that sit immediately above + /// that boundary. + /// + /// If any trimmed developer message was a mixed `build_initial_context` bundle containing both + /// rollback-trimmable contextual fragments and persistent developer text, this also clears the + /// stored `reference_context_item` baseline so the next real turn falls back to full + /// reinjection. + fn trim_pre_turn_context_updates( + &mut self, + snapshot: &[ResponseItem], + first_instruction_turn_idx: usize, + mut cut_idx: usize, + ) -> usize { + while cut_idx > first_instruction_turn_idx { + match &snapshot[cut_idx - 1] { + ResponseItem::Message { role, content, .. } + if role == "developer" && is_contextual_dev_message_content(content) => + { + if has_non_contextual_dev_message_content(content) { + // Mixed `build_initial_context` bundles are not reconstructible from + // steady-state diffs once trimmed, so the next real turn must fully + // reinject context instead of diffing against a stale baseline. + self.reference_context_item = None; + } + cut_idx -= 1; + } + ResponseItem::Message { role, content, .. } + if role == "user" && is_contextual_user_message_content(content) => + { + cut_idx -= 1; + } + _ => break, + } + } + cut_idx + } } fn truncate_function_output_payload( @@ -638,7 +698,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { } fn is_inter_agent_instruction_content(content: &[ContentItem]) -> bool { - InterAgentInstruction::is_message_content(content) + InterAgentCommunication::is_message_content(content) } fn user_message_positions(items: &[ResponseItem]) -> Vec { diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 4deb76ed6d..3c508e05ff 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,9 +1,9 @@ use super::*; -use crate::truncate; -use crate::truncate::TruncationPolicy; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use codex_git::GhostCommit; +use codex_git_utils::GhostCommit; +use codex_protocol::AgentPath; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; @@ -17,11 +17,18 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnContextItem; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use image::ImageBuffer; use image::ImageFormat; use image::Rgba; use pretty_assertions::assert_eq; use regex_lite::Regex; +use std::path::PathBuf; const EXEC_FORMAT_MAX_BYTES: usize = 10_000; const EXEC_FORMAT_MAX_TOKENS: usize = 2_500; @@ -39,11 +46,18 @@ fn assistant_msg(text: &str) -> ResponseItem { } fn inter_agent_assistant_msg(text: &str) -> ResponseItem { + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::root().join("worker").unwrap(), + Vec::new(), + text.to_string(), + true, + ); ResponseItem::Message { id: None, role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: text.to_string(), + text: serde_json::to_string(&communication).unwrap(), }], end_turn: None, phase: None, @@ -82,6 +96,56 @@ fn user_input_text_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(), + }], + end_turn: None, + phase: None, + } +} + +fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: texts + .iter() + .map(|text| ContentItem::InputText { + text: (*text).to_string(), + }) + .collect(), + end_turn: None, + phase: None, + } +} + +fn reference_context_item() -> TurnContextItem { + TurnContextItem { + turn_id: Some("reference-turn".to_string()), + trace_id: None, + cwd: PathBuf::from("/tmp/reference-cwd"), + current_date: Some("2026-03-23".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + network: None, + model: "gpt-test".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: Some(false), + effort: None, + summary: ReasoningSummary::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: Some(codex_protocol::protocol::TruncationPolicy::Tokens(10_000)), + } +} + fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { ResponseItem::CustomToolCallOutput { call_id: call_id.to_string(), @@ -115,7 +179,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { } fn truncate_exec_output(content: &str) -> String { - truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS)) + truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS)) } fn approx_token_count_for_text(text: &str) -> i64 { @@ -239,20 +303,25 @@ fn items_after_last_model_generated_tokens_are_zero_without_model_generated_item #[test] fn inter_agent_assistant_messages_are_turn_boundaries() { - let item = inter_agent_assistant_msg( - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", - ); + let item = inter_agent_assistant_msg("continue"); assert!(is_user_turn_boundary(&item)); } +#[test] +fn for_prompt_preserves_inter_agent_assistant_messages() { + let item = inter_agent_assistant_msg("continue"); + let history = create_history_with_items(vec![item.clone()]); + + assert_eq!(history.raw_items(), std::slice::from_ref(&item)); + assert_eq!(history.for_prompt(&default_input_modalities()), vec![item]); +} + #[test] fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_turns() { let first_turn = user_input_text_msg("first"); let first_reply = assistant_msg("done"); - let inter_agent_turn = inter_agent_assistant_msg( - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", - ); + let inter_agent_turn = inter_agent_assistant_msg("continue"); let inter_agent_reply = assistant_msg("worker reply"); let mut history = create_history_with_items(vec![ first_turn.clone(), @@ -266,6 +335,15 @@ fn drop_last_n_user_turns_treats_inter_agent_assistant_messages_as_instruction_t assert_eq!(history.raw_items(), &vec![first_turn, first_reply]); } +#[test] +fn legacy_inter_agent_assistant_messages_are_not_turn_boundaries() { + let item = assistant_msg( + "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue", + ); + + assert!(!is_user_turn_boundary(&item)); +} + #[test] fn total_token_usage_includes_all_items_after_last_model_generated_item() { let mut history = create_history_with_items(vec![assistant_msg("already counted by API")]); @@ -838,6 +916,75 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() { assert_eq!(history.for_prompt(&modalities), expected_prefix_only); } +#[test] +fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { + let items = vec![ + assistant_msg("session prefix item"), + user_input_text_msg("turn 1 user"), + assistant_msg("turn 1 assistant"), + developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), + developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), + user_input_text_msg( + "PRETURN_CONTEXT_DIFF_CWD", + ), + user_input_text_msg("turn 2 user"), + assistant_msg("turn 2 assistant"), + ]; + + let modalities = default_input_modalities(); + let mut history = create_history_with_items(items); + let reference_context_item = reference_context_item(); + history.set_reference_context_item(Some(reference_context_item.clone())); + history.drop_last_n_user_turns(1); + + assert_eq!( + history.clone().for_prompt(&modalities), + vec![ + assistant_msg("session prefix item"), + user_input_text_msg("turn 1 user"), + assistant_msg("turn 1 assistant"), + developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), + ] + ); + assert_eq!( + serde_json::to_value(history.reference_context_item()) + .expect("serialize retained reference context item"), + serde_json::to_value(Some(reference_context_item)) + .expect("serialize expected reference context item") + ); +} + +#[test] +fn drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles() { + let items = vec![ + user_input_text_msg("turn 1 user"), + assistant_msg("turn 1 assistant"), + developer_msg_with_fragments(&[ + "contextual permissions", + "persistent plugin instructions", + ]), + user_input_text_msg( + "PRETURN_CONTEXT_DIFF_CWD", + ), + user_input_text_msg("turn 2 user"), + assistant_msg("turn 2 assistant"), + ]; + + let modalities = default_input_modalities(); + let mut history = create_history_with_items(items); + history.set_reference_context_item(Some(reference_context_item())); + history.drop_last_n_user_turns(1); + + assert_eq!( + history.clone().for_prompt(&modalities), + vec![ + user_input_text_msg("turn 1 user"), + assistant_msg("turn 1 assistant"), + ] + ); + assert!(history.reference_context_item().is_none()); +} + #[test] fn remove_first_item_handles_custom_tool_pair() { let items = vec![ diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index c122187901..34fd41c083 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items( exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { + // TODO(ccunningham): build_settings_update_items still does not cover every + // model-visible item emitted by build_initial_context. Persist the remaining + // inputs or add explicit replay events so fork/resume can diff everything + // deterministically. let contextual_user_message = build_environment_update_item(previous, next, shell); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index e744fd5478..bbb39da113 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -70,8 +70,8 @@ impl EnvironmentContext { ) -> Self { let before_network = Self::network_from_turn_context_item(before); let after_network = Self::network_from_turn_context(after); - let cwd = if before.cwd != after.cwd { - Some(after.cwd.clone()) + let cwd = if before.cwd.as_path() != after.cwd.as_path() { + Some(after.cwd.to_path_buf()) } else { None }; @@ -94,7 +94,7 @@ impl EnvironmentContext { pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( - Some(turn_context.cwd.clone()), + Some(turn_context.cwd.to_path_buf()), shell.clone(), turn_context.current_date.clone(), turn_context.timezone.clone(), diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 80d60619dd..b0c55a55c6 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,8 +2,6 @@ use crate::exec::ExecToolCallOutput; use crate::network_policy_decision::NetworkPolicyDecisionPayload; use crate::token_data::KnownPlan; use crate::token_data::PlanType; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use chrono::DateTime; use chrono::Datelike; use chrono::Local; @@ -15,6 +13,8 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use reqwest::StatusCode; use serde_json; use std::io; @@ -460,6 +460,21 @@ impl std::fmt::Display for UsageLimitReachedError { "You've hit your usage limit.{}", retry_suffix(self.resets_at.as_ref()) ), + Some(PlanType::Unknown(plan)) + if plan.eq_ignore_ascii_case("self_serve_business_usage_based") => + { + match self + .rate_limits + .as_ref() + .and_then(|snapshot| snapshot.credits.as_ref()) + .map(|credits| credits.has_credits) + { + Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue." + .to_string(), + Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue." + .to_string(), + } + } Some(PlanType::Unknown(_)) | None => format!( "You've hit your usage limit.{}", retry_suffix(self.resets_at.as_ref()) diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 8f80547a4c..5e174944f1 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -14,6 +14,8 @@ use codex_protocol::models::is_image_close_tag_text; use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; use codex_protocol::models::is_local_image_open_tag_text; +use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::user_input::UserInput; use tracing::warn; use uuid::Uuid; @@ -22,10 +24,48 @@ use crate::contextual_user_message::is_contextual_user_fragment; use crate::contextual_user_message::parse_visible_hook_prompt_message; use crate::web_search::web_search_action_detail; +const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ + "", + "", + COLLABORATION_MODE_OPEN_TAG, + REALTIME_CONVERSATION_OPEN_TAG, + "", +]; + pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { message.iter().any(is_contextual_user_fragment) } +/// Returns true when a developer message contains any rollback-trimmable contextual fragment. +/// +/// `build_initial_context` can bundle these fragments together with persistent developer text in a +/// single developer message, so callers that care about invalidating a stored reference baseline +/// should pair this with `has_non_contextual_dev_message_content`. +pub(crate) fn is_contextual_dev_message_content(message: &[ContentItem]) -> bool { + message.iter().any(is_contextual_dev_fragment) +} + +/// Returns true when a developer message contains any fragment that is not part of the +/// rollback-trimmable contextual prefix set. +pub(crate) fn has_non_contextual_dev_message_content(message: &[ContentItem]) -> bool { + message + .iter() + .any(|content_item| !is_contextual_dev_fragment(content_item)) +} + +fn is_contextual_dev_fragment(content_item: &ContentItem) -> bool { + let ContentItem::InputText { text } = content_item else { + return false; + }; + + let trimmed = text.trim_start(); + CONTEXTUAL_DEVELOPER_PREFIXES.iter().any(|prefix| { + trimmed + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) + }) +} + fn parse_user_message(message: &[ContentItem]) -> Option { if is_contextual_user_message_content(message) { return None; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9be0518fa0..a4399ddc46 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use std::collections::BTreeSet; use std::collections::HashMap; use std::io; use std::path::Path; @@ -24,20 +25,24 @@ use crate::protocol::EventMsg; use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecOutputStream; use crate::protocol::SandboxPolicy; -use crate::sandboxing::CommandSpec; +use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; -use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxPermissions; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; -use crate::tools::sandboxing::SandboxablePreference; use codex_network_proxy::NetworkProxy; -#[cfg(any(target_os = "windows", test))] +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -88,6 +93,17 @@ pub struct ExecParams { pub arg0: Option, } +/// Extra filesystem deny-write carveouts for the non-elevated Windows +/// restricted-token backend. +/// +/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we +/// can support a narrow split-policy subset without changing legacy Windows +/// sandbox semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsRestrictedTokenFilesystemOverlay { + pub(crate) additional_deny_write_paths: Vec, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -178,31 +194,6 @@ impl ExecCapturePolicy { } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum SandboxType { - None, - - /// Only available on macOS. - MacosSeatbelt, - - /// Only available on Linux. - LinuxSeccomp, - - /// Only available on Windows. - WindowsRestrictedToken, -} - -impl SandboxType { - pub(crate) fn as_metric_tag(self) -> &'static str { - match self { - SandboxType::None => "none", - SandboxType::MacosSeatbelt => "seatbelt", - SandboxType::LinuxSeccomp => "seccomp", - SandboxType::WindowsRestrictedToken => "windows_sandbox", - } - } -} - #[derive(Clone)] pub struct StdoutStream { pub sub_id: String, @@ -263,10 +254,10 @@ pub fn build_exec_request( expiration, capture_policy, network, - sandbox_permissions, + sandbox_permissions: _, windows_sandbox_level, windows_sandbox_private_desktop, - justification, + justification: _, arg0: _, } = params; if let Some(network) = network.as_ref() { @@ -279,22 +270,21 @@ pub fn build_exec_request( )) })?; - let spec = CommandSpec { + let manager = SandboxManager::new(); + let command = SandboxCommand { program: program.clone(), args: args.to_vec(), cwd, env, + additional_permissions: None, + }; + let options = ExecOptions { expiration, capture_policy, - sandbox_permissions, - additional_permissions: None, - justification, }; - - let manager = SandboxManager::new(); - let exec_req = manager - .transform(crate::sandboxing::SandboxTransformRequest { - spec, + let mut exec_req = manager + .transform(SandboxTransformRequest { + command, policy: sandbox_policy, file_system_policy: file_system_sandbox_policy, network_policy: network_sandbox_policy, @@ -309,7 +299,18 @@ pub fn build_exec_request( windows_sandbox_level, windows_sandbox_private_desktop, }) + .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) .map_err(CodexErr::from)?; + exec_req.windows_restricted_token_filesystem_overlay = + resolve_windows_restricted_token_filesystem_overlay( + exec_req.sandbox, + &exec_req.sandbox_policy, + &exec_req.file_system_sandbox_policy, + exec_req.network_sandbox_policy, + sandbox_cwd, + exec_req.windows_sandbox_level, + ) + .map_err(CodexErr::UnsupportedOperation)?; Ok(exec_req) } @@ -329,11 +330,10 @@ pub(crate) async fn execute_exec_request( sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_permissions, sandbox_policy: _sandbox_policy_from_env, file_system_sandbox_policy, network_sandbox_policy, - justification, + windows_restricted_token_filesystem_overlay, arg0, } = exec_request; let _ = _sandbox_policy_from_env; @@ -345,10 +345,10 @@ pub(crate) async fn execute_exec_request( capture_policy, env, network: network.clone(), - sandbox_permissions, + sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop, - justification, + justification: None, arg0, }; @@ -358,6 +358,7 @@ pub(crate) async fn execute_exec_request( sandbox, sandbox_policy, &file_system_sandbox_policy, + windows_restricted_token_filesystem_overlay.as_ref(), network_sandbox_policy, stdout_stream, after_spawn, @@ -437,11 +438,11 @@ fn record_windows_sandbox_spawn_failure( async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, + windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, ) -> Result { use crate::config::find_codex_home; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::run_windows_sandbox_capture; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; + use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths; let ExecParams { command, @@ -480,6 +481,15 @@ async fn exec_windows_sandbox( let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let additional_deny_write_paths = windows_restricted_token_filesystem_overlay + .map(|overlay| { + overlay + .additional_deny_write_paths + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>() + }) + .unwrap_or_default(); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -493,7 +503,7 @@ async fn exec_windows_sandbox( windows_sandbox_private_desktop, ) } else { - run_windows_sandbox_capture( + run_windows_sandbox_capture_with_extra_deny_write_paths( policy_str.as_str(), &sandbox_cwd, codex_home.as_ref(), @@ -501,6 +511,7 @@ async fn exec_windows_sandbox( &cwd, env, timeout_ms, + &additional_deny_write_paths, windows_sandbox_private_desktop, ) } @@ -620,7 +631,7 @@ fn finalize_exec_result( pub(crate) mod errors { use super::CodexErr; - use crate::sandboxing::SandboxTransformError; + use codex_sandboxing::SandboxTransformError; impl From for CodexErr { fn from(err: SandboxTransformError) -> Self { @@ -809,29 +820,25 @@ impl Default for ExecToolCallOutput { } } -#[cfg_attr(not(target_os = "windows"), allow(unused_variables))] +#[allow(clippy::too_many_arguments)] async fn exec( params: ExecParams, - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox: SandboxType, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, network_sandbox_policy: NetworkSandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken { - let support = windows_restricted_token_sandbox_support( - sandbox, - params.windows_sandbox_level, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, - ); - if let Some(reason) = support.unsupported_reason { - return Err(CodexErr::Io(io::Error::other(reason))); - } - return exec_windows_sandbox(params, sandbox_policy).await; + if _sandbox == SandboxType::WindowsRestrictedToken { + return exec_windows_sandbox( + params, + _sandbox_policy, + _windows_restricted_token_filesystem_overlay, + ) + .await; } let ExecParams { command, @@ -876,54 +883,175 @@ async fn exec( } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -#[derive(Debug, PartialEq, Eq)] -struct WindowsRestrictedTokenSandboxSupport { - should_use: bool, - unsupported_reason: Option, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_restricted_token_sandbox_support( +fn should_use_windows_restricted_token_sandbox( sandbox: SandboxType, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, sandbox_policy: &SandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, -) -> WindowsRestrictedTokenSandboxSupport { - if sandbox != SandboxType::WindowsRestrictedToken { - return WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: None, - }; - } - - // Windows currently reuses SandboxType::WindowsRestrictedToken for both - // the legacy restricted-token backend and the elevated setup/runner path. - // The sandbox level decides whether restricted read-only policies are - // supported. - let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted +) -> bool { + sandbox == SandboxType::WindowsRestrictedToken + && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted && !matches!( sandbox_policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) - && (matches!( - windows_sandbox_level, - codex_protocol::config_types::WindowsSandboxLevel::Elevated - ) || sandbox_policy.has_full_disk_read_access()); +} - let unsupported_reason = if should_use { - None - } else { - Some(format!( +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + resolve_windows_restricted_token_filesystem_overlay( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() +} + +pub(crate) fn resolve_windows_restricted_token_filesystem_overlay( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken { + return Ok(None); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) { + return Err(format!( "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", file_system_sandbox_policy.kind, - )) - }; - - WindowsRestrictedTokenSandboxSupport { - should_use, - unsupported_reason, + )); } + + if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken { + return Err( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy.has_full_disk_read_access() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy + .get_unreadable_roots_with_cwd(sandbox_policy_cwd) + .is_empty() + { + return Err( + "windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_overlay_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths + .insert(normalize_windows_overlay_path(read_only_subpath.as_path())?); + } + } + } + + if additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +fn normalize_windows_overlay_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) } /// Consumes the output of a child process according to the configured capture diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index fc312ec88e..074db95e1c 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::time::Duration; @@ -268,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -307,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -375,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() { let policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() - ), - } + false ); } #[test] fn windows_restricted_token_runs_for_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + true ); } @@ -420,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - } - ); + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); } #[test] fn windows_restricted_token_allows_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } - ); -} - -#[test] -fn windows_restricted_token_rejects_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string() - ), - }, - "restricted-token should fail closed for restricted read-only policies" + None ); } @@ -493,62 +453,225 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { writable_roots: vec![], read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + None ); } #[test] -fn windows_elevated_sandbox_allows_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, +fn windows_restricted_token_rejects_split_only_filesystem_policies() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Elevated, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, + Some( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_rejects_root_write_read_only_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, }, - "elevated Windows sandbox should keep restricted read-only support enabled" + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, + ), + Some( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir"); + let docs = cwd.join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + resolve_windows_restricted_token_filesystem_overlay( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ], + })) + ); +} + +#[test] +fn windows_elevated_rejects_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::Elevated, + ), + Some( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string() + ) ); } #[test] fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() { - let expected = crate::get_platform_sandbox(false).unwrap_or(SandboxType::None); + let expected = codex_sandboxing::get_platform_sandbox(false).unwrap_or(SandboxType::None); assert_eq!( select_process_exec_tool_sandbox_type( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, - WindowsSandboxLevel::Disabled, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, false, ), expected @@ -580,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> "-c".to_string(), "sleep 60 & echo $!; sleep 60".to_string(), ]; + let cwd = std::env::current_dir()?; let env: HashMap = std::env::vars().collect(); let params = ExecParams { command, - cwd: std::env::current_dir()?, + cwd, expiration: 500.into(), capture_policy: ExecCapturePolicy::ShellTool, env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, @@ -600,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> SandboxType::None, &SandboxPolicy::new_read_only_policy(), &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + None, NetworkSandboxPolicy::Restricted, None, None, @@ -647,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, diff --git a/codex-rs/core/src/file_watcher.rs b/codex-rs/core/src/file_watcher.rs index 7b2cbd76b0..f8f0e4b11f 100644 --- a/codex-rs/core/src/file_watcher.rs +++ b/codex-rs/core/src/file_watcher.rs @@ -1,13 +1,15 @@ -//! Watches skill roots for changes and broadcasts coarse-grained -//! `FileWatcherEvent`s that higher-level components react to on the next turn. +//! Watches subscribed files or directories and routes coarse-grained change +//! notifications to the subscribers that own matching watched paths. +use std::collections::BTreeSet; use std::collections::HashMap; -use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::time::Duration; use notify::Event; @@ -16,22 +18,162 @@ use notify::RecommendedWatcher; use notify::RecursiveMode; use notify::Watcher; use tokio::runtime::Handle; -use tokio::sync::broadcast; +use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::Notify; use tokio::sync::mpsc; use tokio::time::Instant; use tokio::time::sleep_until; use tracing::warn; -use crate::config::Config; -use crate::skills::SkillsManager; - #[derive(Debug, Clone, PartialEq, Eq)] -pub enum FileWatcherEvent { - SkillsChanged { paths: Vec }, +/// Coalesced file change notification for a subscriber. +pub struct FileWatcherEvent { + /// Changed paths delivered in sorted order with duplicates removed. + pub paths: Vec, } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Path subscription registered by a [`FileWatcherSubscriber`]. +pub struct WatchPath { + /// Root path to watch. + pub path: PathBuf, + /// Whether events below `path` should match recursively. + pub recursive: bool, +} + +type SubscriberId = u64; + +#[derive(Default)] struct WatchState { - skills_root_ref_counts: HashMap, + next_subscriber_id: SubscriberId, + path_ref_counts: HashMap, + subscribers: HashMap, +} + +struct SubscriberState { + watched_paths: HashMap, + tx: WatchSender, +} + +/// Receives coalesced change notifications for a single subscriber. +pub struct Receiver { + inner: Arc, +} + +struct WatchSender { + inner: Arc, +} + +struct ReceiverInner { + changed_paths: AsyncMutex>, + notify: Notify, + sender_count: AtomicUsize, +} + +impl Receiver { + /// Waits for the next batch of changed paths, or returns `None` once the + /// corresponding subscriber has been removed and no more events can arrive. + pub async fn recv(&mut self) -> Option { + loop { + let notified = self.inner.notify.notified(); + { + let mut changed_paths = self.inner.changed_paths.lock().await; + if !changed_paths.is_empty() { + return Some(FileWatcherEvent { + paths: std::mem::take(&mut *changed_paths).into_iter().collect(), + }); + } + if self.inner.sender_count.load(Ordering::Acquire) == 0 { + return None; + } + } + notified.await; + } + } +} + +impl WatchSender { + async fn add_changed_paths(&self, paths: &[PathBuf]) { + if paths.is_empty() { + return; + } + + let mut changed_paths = self.inner.changed_paths.lock().await; + let previous_len = changed_paths.len(); + changed_paths.extend(paths.iter().cloned()); + if changed_paths.len() != previous_len { + self.inner.notify.notify_one(); + } + } +} + +impl Clone for WatchSender { + fn clone(&self) -> Self { + self.inner.sender_count.fetch_add(1, Ordering::Relaxed); + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl Drop for WatchSender { + fn drop(&mut self) { + if self.inner.sender_count.fetch_sub(1, Ordering::AcqRel) == 1 { + self.inner.notify.notify_waiters(); + } + } +} + +fn watch_channel() -> (WatchSender, Receiver) { + let inner = Arc::new(ReceiverInner { + changed_paths: AsyncMutex::new(BTreeSet::new()), + notify: Notify::new(), + sender_count: AtomicUsize::new(1), + }); + ( + WatchSender { + inner: Arc::clone(&inner), + }, + Receiver { inner }, + ) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PathWatchCounts { + non_recursive: usize, + recursive: usize, +} + +impl PathWatchCounts { + fn increment(&mut self, recursive: bool, amount: usize) { + if recursive { + self.recursive += amount; + } else { + self.non_recursive += amount; + } + } + + fn decrement(&mut self, recursive: bool, amount: usize) { + if recursive { + self.recursive = self.recursive.saturating_sub(amount); + } else { + self.non_recursive = self.non_recursive.saturating_sub(amount); + } + } + + fn effective_mode(self) -> Option { + if self.recursive > 0 { + Some(RecursiveMode::Recursive) + } else if self.non_recursive > 0 { + Some(RecursiveMode::NonRecursive) + } else { + None + } + } + + fn is_empty(self) -> bool { + self.non_recursive == 0 && self.recursive == 0 + } } struct FileWatcherInner { @@ -39,73 +181,95 @@ struct FileWatcherInner { watched_paths: HashMap, } -const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); - -/// Coalesces bursts of paths and emits at most once per interval. -struct ThrottledPaths { - pending: HashSet, - next_allowed_at: Instant, +/// Coalesces bursts of watch notifications and emits at most once per interval. +pub struct ThrottledWatchReceiver { + rx: Receiver, + interval: Duration, + next_allowed: Option, } -impl ThrottledPaths { - fn new(now: Instant) -> Self { +impl ThrottledWatchReceiver { + /// Creates a throttling wrapper around a raw watcher [`Receiver`]. + pub fn new(rx: Receiver, interval: Duration) -> Self { Self { - pending: HashSet::new(), - next_allowed_at: now, + rx, + interval, + next_allowed: None, } } - fn add(&mut self, paths: Vec) { - self.pending.extend(paths); - } - - fn next_deadline(&self, now: Instant) -> Option { - (!self.pending.is_empty() && now < self.next_allowed_at).then_some(self.next_allowed_at) - } - - fn take_ready(&mut self, now: Instant) -> Option> { - if self.pending.is_empty() || now < self.next_allowed_at { - return None; + /// Receives the next event, enforcing the configured minimum delay after + /// the previous emission. + pub async fn recv(&mut self) -> Option { + if let Some(next_allowed) = self.next_allowed { + sleep_until(next_allowed).await; } - Some(self.take_with_next_allowed(now)) - } - fn take_pending(&mut self, now: Instant) -> Option> { - if self.pending.is_empty() { - return None; + let event = self.rx.recv().await; + if event.is_some() { + self.next_allowed = Some(Instant::now() + self.interval); } - Some(self.take_with_next_allowed(now)) - } - - fn take_with_next_allowed(&mut self, now: Instant) -> Vec { - let mut paths: Vec = self.pending.drain().collect(); - paths.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str())); - self.next_allowed_at = now + WATCHER_THROTTLE_INTERVAL; - paths + event } } -pub(crate) struct FileWatcher { - inner: Option>, - state: Arc>, - tx: broadcast::Sender, +/// Handle used to register watched paths for one logical consumer. +pub struct FileWatcherSubscriber { + id: SubscriberId, + file_watcher: Arc, } -pub(crate) struct WatchRegistration { +impl FileWatcherSubscriber { + /// Registers the provided paths for this subscriber and returns an RAII + /// guard that unregisters them on drop. + pub fn register_paths(&self, watched_paths: Vec) -> WatchRegistration { + let watched_paths = dedupe_watched_paths(watched_paths); + self.file_watcher.register_paths(self.id, &watched_paths); + + WatchRegistration { + file_watcher: Arc::downgrade(&self.file_watcher), + subscriber_id: self.id, + watched_paths, + } + } + + #[cfg(test)] + pub(crate) fn register_path(&self, path: PathBuf, recursive: bool) -> WatchRegistration { + self.register_paths(vec![WatchPath { path, recursive }]) + } +} + +impl Drop for FileWatcherSubscriber { + fn drop(&mut self) { + self.file_watcher.remove_subscriber(self.id); + } +} + +/// RAII guard for a set of active path registrations. +pub struct WatchRegistration { file_watcher: std::sync::Weak, - roots: Vec, + subscriber_id: SubscriberId, + watched_paths: Vec, } impl Drop for WatchRegistration { fn drop(&mut self) { if let Some(file_watcher) = self.file_watcher.upgrade() { - file_watcher.unregister_roots(&self.roots); + file_watcher.unregister_paths(self.subscriber_id, &self.watched_paths); } } } +/// Multi-subscriber file watcher built on top of `notify`. +pub struct FileWatcher { + inner: Option>, + state: Arc>, +} + impl FileWatcher { - pub(crate) fn new(_codex_home: PathBuf) -> notify::Result { + /// Creates a live filesystem watcher and starts its background event loop + /// on the current Tokio runtime. + pub fn new() -> notify::Result { let (raw_tx, raw_rx) = mpsc::unbounded_channel(); let raw_tx_clone = raw_tx; let watcher = notify::recommended_watcher(move |res| { @@ -115,109 +279,101 @@ impl FileWatcher { watcher, watched_paths: HashMap::new(), }; - let (tx, _) = broadcast::channel(128); - let state = Arc::new(RwLock::new(WatchState { - skills_root_ref_counts: HashMap::new(), - })); + let state = Arc::new(RwLock::new(WatchState::default())); let file_watcher = Self { inner: Some(Mutex::new(inner)), - state: Arc::clone(&state), - tx: tx.clone(), + state, }; - file_watcher.spawn_event_loop(raw_rx, state, tx); + file_watcher.spawn_event_loop(raw_rx); Ok(file_watcher) } - pub(crate) fn noop() -> Self { - let (tx, _) = broadcast::channel(1); + /// Creates an inert watcher that only supports test-driven synthetic + /// notifications. + pub fn noop() -> Self { Self { inner: None, - state: Arc::new(RwLock::new(WatchState { - skills_root_ref_counts: HashMap::new(), - })), - tx, + state: Arc::new(RwLock::new(WatchState::default())), } } - pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.tx.subscribe() + /// Adds a new subscriber and returns both its registration handle and its + /// dedicated event receiver. + pub fn add_subscriber(self: &Arc) -> (FileWatcherSubscriber, Receiver) { + let (tx, rx) = watch_channel(); + let mut state = self + .state + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let subscriber_id = state.next_subscriber_id; + state.next_subscriber_id += 1; + state.subscribers.insert( + subscriber_id, + SubscriberState { + watched_paths: HashMap::new(), + tx, + }, + ); + + let subscriber = FileWatcherSubscriber { + id: subscriber_id, + file_watcher: self.clone(), + }; + (subscriber, rx) } - pub(crate) fn register_config( - self: &Arc, - config: &Config, - skills_manager: &SkillsManager, - ) -> WatchRegistration { - let deduped_roots: HashSet = skills_manager - .skill_roots_for_config(config) - .into_iter() - .map(|root| root.path) - .collect(); - let mut registered_roots: Vec = deduped_roots.into_iter().collect(); - registered_roots.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str())); - for root in ®istered_roots { - self.register_skills_root(root.clone()); - } + fn register_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) { + let mut state = self + .state + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut inner_guard: Option> = None; - WatchRegistration { - file_watcher: Arc::downgrade(self), - roots: registered_roots, + for watched_path in watched_paths { + { + let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else { + return; + }; + *subscriber + .watched_paths + .entry(watched_path.clone()) + .or_default() += 1; + } + + let counts = state + .path_ref_counts + .entry(watched_path.path.clone()) + .or_default(); + let previous_mode = counts.effective_mode(); + counts.increment(watched_path.recursive, /*amount*/ 1); + let next_mode = counts.effective_mode(); + if previous_mode != next_mode { + self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard); + } } } // Bridge `notify`'s callback-based events into the Tokio runtime and - // broadcast coarse-grained change signals to subscribers. - fn spawn_event_loop( - &self, - mut raw_rx: mpsc::UnboundedReceiver>, - state: Arc>, - tx: broadcast::Sender, - ) { + // notify the matching subscribers. + fn spawn_event_loop(&self, mut raw_rx: mpsc::UnboundedReceiver>) { if let Ok(handle) = Handle::try_current() { + let state = Arc::clone(&self.state); handle.spawn(async move { - let now = Instant::now(); - let mut skills = ThrottledPaths::new(now); - loop { - let now = Instant::now(); - let next_deadline = skills.next_deadline(now); - let timer_deadline = next_deadline - .unwrap_or_else(|| now + Duration::from_secs(60 * 60 * 24 * 365)); - let timer = sleep_until(timer_deadline); - tokio::pin!(timer); - - tokio::select! { - res = raw_rx.recv() => { - match res { - Some(Ok(event)) => { - let skills_paths = classify_event(&event, &state); - let now = Instant::now(); - skills.add(skills_paths); - - if let Some(paths) = skills.take_ready(now) { - let _ = tx.send(FileWatcherEvent::SkillsChanged { paths }); - } - } - Some(Err(err)) => { - warn!("file watcher error: {err}"); - } - None => { - // Flush any pending changes before shutdown so subscribers - // see the latest state. - let now = Instant::now(); - if let Some(paths) = skills.take_pending(now) { - let _ = tx.send(FileWatcherEvent::SkillsChanged { paths }); - } - break; - } + match raw_rx.recv().await { + Some(Ok(event)) => { + if !is_mutating_event(&event) { + continue; } - } - _ = &mut timer => { - let now = Instant::now(); - if let Some(paths) = skills.take_ready(now) { - let _ = tx.send(FileWatcherEvent::SkillsChanged { paths }); + if event.paths.is_empty() { + continue; } + Self::notify_subscribers(&state, &event.paths).await; } + Some(Err(err)) => { + warn!("file watcher error: {err}"); + } + None => break, } } }); @@ -226,127 +382,195 @@ impl FileWatcher { } } - fn register_skills_root(&self, root: PathBuf) { - let mut state = self - .state - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let count = state - .skills_root_ref_counts - .entry(root.clone()) - .or_insert(0); - *count += 1; - if *count == 1 { - self.watch_path(root, RecursiveMode::Recursive); - } - } - - fn unregister_roots(&self, roots: &[PathBuf]) { + fn unregister_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) { let mut state = self .state .write() .unwrap_or_else(std::sync::PoisonError::into_inner); let mut inner_guard: Option> = None; - for root in roots { - let mut should_unwatch = false; - if let Some(count) = state.skills_root_ref_counts.get_mut(root) { - if *count > 1 { - *count -= 1; - } else { - state.skills_root_ref_counts.remove(root); - should_unwatch = true; + for watched_path in watched_paths { + { + let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else { + return; + }; + let Some(subscriber_count) = subscriber.watched_paths.get_mut(watched_path) else { + continue; + }; + *subscriber_count = subscriber_count.saturating_sub(1); + if *subscriber_count == 0 { + subscriber.watched_paths.remove(watched_path); } } - - if !should_unwatch { - continue; - } - let Some(inner) = &self.inner else { + let Some(counts) = state.path_ref_counts.get_mut(&watched_path.path) else { continue; }; - if inner_guard.is_none() { - let guard = inner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - inner_guard = Some(guard); + let previous_mode = counts.effective_mode(); + counts.decrement(watched_path.recursive, /*amount*/ 1); + let next_mode = counts.effective_mode(); + if counts.is_empty() { + state.path_ref_counts.remove(&watched_path.path); } - - let Some(guard) = inner_guard.as_mut() else { - continue; - }; - if guard.watched_paths.remove(root).is_none() { - continue; - } - if let Err(err) = guard.watcher.unwatch(root) { - warn!("failed to unwatch {}: {err}", root.display()); + if previous_mode != next_mode { + self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard); } } } - fn watch_path(&self, path: PathBuf, mode: RecursiveMode) { + fn remove_subscriber(&self, subscriber_id: SubscriberId) { + let mut state = self + .state + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(subscriber) = state.subscribers.remove(&subscriber_id) else { + return; + }; + + let mut inner_guard: Option> = None; + for (watched_path, count) in subscriber.watched_paths { + let Some(path_counts) = state.path_ref_counts.get_mut(&watched_path.path) else { + continue; + }; + let previous_mode = path_counts.effective_mode(); + path_counts.decrement(watched_path.recursive, count); + let next_mode = path_counts.effective_mode(); + if path_counts.is_empty() { + state.path_ref_counts.remove(&watched_path.path); + } + if previous_mode != next_mode { + self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard); + } + } + } + + fn reconfigure_watch<'a>( + &'a self, + path: &Path, + next_mode: Option, + inner_guard: &mut Option>, + ) { let Some(inner) = &self.inner else { return; }; + if inner_guard.is_none() { + let guard = inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *inner_guard = Some(guard); + } + let Some(guard) = inner_guard.as_mut() else { + return; + }; + + let existing_mode = guard.watched_paths.get(path).copied(); + if existing_mode == next_mode { + return; + } + + if existing_mode.is_some() { + if let Err(err) = guard.watcher.unwatch(path) { + warn!("failed to unwatch {}: {err}", path.display()); + } + guard.watched_paths.remove(path); + } + + let Some(next_mode) = next_mode else { + return; + }; if !path.exists() { return; } - let watch_path = path; - let mut guard = inner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(existing) = guard.watched_paths.get(&watch_path) { - if *existing == RecursiveMode::Recursive || *existing == mode { - return; - } - if let Err(err) = guard.watcher.unwatch(&watch_path) { - warn!("failed to unwatch {}: {err}", watch_path.display()); - } - } - if let Err(err) = guard.watcher.watch(&watch_path, mode) { - warn!("failed to watch {}: {err}", watch_path.display()); + + if let Err(err) = guard.watcher.watch(path, next_mode) { + warn!("failed to watch {}: {err}", path.display()); return; } - guard.watched_paths.insert(watch_path, mode); + guard.watched_paths.insert(path.to_path_buf(), next_mode); + } + + async fn notify_subscribers(state: &RwLock, event_paths: &[PathBuf]) { + let subscribers_to_notify: Vec<(WatchSender, Vec)> = { + let state = state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state + .subscribers + .values() + .filter_map(|subscriber| { + let changed_paths: Vec = event_paths + .iter() + .filter(|event_path| { + subscriber.watched_paths.keys().any(|watched_path| { + watch_path_matches_event(watched_path, event_path) + }) + }) + .cloned() + .collect(); + (!changed_paths.is_empty()).then_some((subscriber.tx.clone(), changed_paths)) + }) + .collect() + }; + + for (subscriber, changed_paths) in subscribers_to_notify { + subscriber.add_changed_paths(&changed_paths).await; + } + } + + #[cfg(test)] + pub(crate) async fn send_paths_for_test(&self, paths: Vec) { + Self::notify_subscribers(&self.state, &paths).await; + } + + #[cfg(test)] + pub(crate) fn spawn_event_loop_for_test( + &self, + raw_rx: mpsc::UnboundedReceiver>, + ) { + self.spawn_event_loop(raw_rx); + } + + #[cfg(test)] + pub(crate) fn watch_counts_for_test(&self, path: &Path) -> Option<(usize, usize)> { + let state = self + .state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state + .path_ref_counts + .get(path) + .map(|counts| (counts.non_recursive, counts.recursive)) } } -fn classify_event(event: &Event, state: &RwLock) -> Vec { - if !matches!( +fn is_mutating_event(event: &Event) -> bool { + matches!( event.kind, EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) - ) { - return Vec::new(); - } - - let mut skills_paths = Vec::new(); - let skills_roots = match state.read() { - Ok(state) => state - .skills_root_ref_counts - .keys() - .cloned() - .collect::>(), - Err(err) => { - let state = err.into_inner(); - state - .skills_root_ref_counts - .keys() - .cloned() - .collect::>() - } - }; - - for path in &event.paths { - if is_skills_path(path, &skills_roots) { - skills_paths.push(path.clone()); - } - } - - skills_paths + ) } -fn is_skills_path(path: &Path, roots: &HashSet) -> bool { - roots.iter().any(|root| path.starts_with(root)) +fn dedupe_watched_paths(mut watched_paths: Vec) -> Vec { + watched_paths.sort_unstable_by(|a, b| { + a.path + .as_os_str() + .cmp(b.path.as_os_str()) + .then(a.recursive.cmp(&b.recursive)) + }); + watched_paths.dedup(); + watched_paths +} + +fn watch_path_matches_event(watched_path: &WatchPath, event_path: &Path) -> bool { + if event_path == watched_path.path { + return true; + } + if watched_path.path.starts_with(event_path) { + return true; + } + if !event_path.starts_with(&watched_path.path) { + return false; + } + watched_path.recursive || event_path.parent() == Some(watched_path.path.as_path()) } #[cfg(test)] diff --git a/codex-rs/core/src/file_watcher_tests.rs b/codex-rs/core/src/file_watcher_tests.rs index 995e7f7cea..53f6391f57 100644 --- a/codex-rs/core/src/file_watcher_tests.rs +++ b/codex-rs/core/src/file_watcher_tests.rs @@ -1,13 +1,13 @@ use super::*; -use notify::EventKind; use notify::event::AccessKind; use notify::event::AccessMode; use notify::event::CreateKind; use notify::event::ModifyKind; -use notify::event::RemoveKind; use pretty_assertions::assert_eq; use tokio::time::timeout; +const TEST_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); + fn path(name: &str) -> PathBuf { PathBuf::from(name) } @@ -20,147 +20,202 @@ fn notify_event(kind: EventKind, paths: Vec) -> Event { event } -#[test] -fn throttles_and_coalesces_within_interval() { - let start = Instant::now(); - let mut throttled = ThrottledPaths::new(start); +#[tokio::test] +async fn throttled_receiver_coalesces_within_interval() { + let (tx, rx) = watch_channel(); + let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); - throttled.add(vec![path("a")]); - let first = throttled.take_ready(start).expect("first emit"); - assert_eq!(first, vec![path("a")]); - - throttled.add(vec![path("b"), path("c")]); - assert_eq!(throttled.take_ready(start), None); - - let second = throttled - .take_ready(start + WATCHER_THROTTLE_INTERVAL) - .expect("coalesced emit"); - assert_eq!(second, vec![path("b"), path("c")]); -} - -#[test] -fn flushes_pending_on_shutdown() { - let start = Instant::now(); - let mut throttled = ThrottledPaths::new(start); - - throttled.add(vec![path("a")]); - let _ = throttled.take_ready(start).expect("first emit"); - - throttled.add(vec![path("b")]); - assert_eq!(throttled.take_ready(start), None); - - let flushed = throttled - .take_pending(start) - .expect("shutdown flush emits pending paths"); - assert_eq!(flushed, vec![path("b")]); -} - -#[test] -fn classify_event_filters_to_skills_roots() { - let root = path("/tmp/skills"); - let state = RwLock::new(WatchState { - skills_root_ref_counts: HashMap::from([(root.clone(), 1)]), - }); - let event = notify_event( - EventKind::Create(CreateKind::Any), - vec![ - root.join("demo/SKILL.md"), - path("/tmp/other/not-a-skill.txt"), - ], - ); - - let classified = classify_event(&event, &state); - assert_eq!(classified, vec![root.join("demo/SKILL.md")]); -} - -#[test] -fn classify_event_supports_multiple_roots_without_prefix_false_positives() { - let root_a = path("/tmp/skills"); - let root_b = path("/tmp/workspace/.codex/skills"); - let state = RwLock::new(WatchState { - skills_root_ref_counts: HashMap::from([(root_a.clone(), 1), (root_b.clone(), 1)]), - }); - let event = notify_event( - EventKind::Modify(ModifyKind::Any), - vec![ - root_a.join("alpha/SKILL.md"), - path("/tmp/skills-extra/not-under-skills.txt"), - root_b.join("beta/SKILL.md"), - ], - ); - - let classified = classify_event(&event, &state); + tx.add_changed_paths(&[path("a")]).await; + let first = timeout(Duration::from_secs(1), throttled.recv()) + .await + .expect("first emit timeout"); assert_eq!( - classified, - vec![root_a.join("alpha/SKILL.md"), root_b.join("beta/SKILL.md")] + first, + Some(FileWatcherEvent { + paths: vec![path("a")], + }) + ); + + tx.add_changed_paths(&[path("b"), path("c")]).await; + let blocked = timeout(TEST_THROTTLE_INTERVAL / 2, throttled.recv()).await; + assert_eq!(blocked.is_err(), true); + + let second = timeout(TEST_THROTTLE_INTERVAL * 2, throttled.recv()) + .await + .expect("second emit timeout"); + assert_eq!( + second, + Some(FileWatcherEvent { + paths: vec![path("b"), path("c")], + }) + ); +} + +#[tokio::test] +async fn throttled_receiver_flushes_pending_on_shutdown() { + let (tx, rx) = watch_channel(); + let mut throttled = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); + + tx.add_changed_paths(&[path("a")]).await; + let first = timeout(Duration::from_secs(1), throttled.recv()) + .await + .expect("first emit timeout"); + assert_eq!( + first, + Some(FileWatcherEvent { + paths: vec![path("a")], + }) + ); + + tx.add_changed_paths(&[path("b")]).await; + drop(tx); + + let second = timeout(Duration::from_secs(1), throttled.recv()) + .await + .expect("shutdown flush timeout"); + assert_eq!( + second, + Some(FileWatcherEvent { + paths: vec![path("b")], + }) + ); + + let closed = timeout(Duration::from_secs(1), throttled.recv()) + .await + .expect("closed recv timeout"); + assert_eq!(closed, None); +} + +#[test] +fn is_mutating_event_filters_non_mutating_event_kinds() { + assert_eq!( + is_mutating_event(¬ify_event( + EventKind::Create(CreateKind::Any), + vec![path("/tmp/created")] + )), + true + ); + assert_eq!( + is_mutating_event(¬ify_event( + EventKind::Modify(ModifyKind::Any), + vec![path("/tmp/modified")] + )), + true + ); + assert_eq!( + is_mutating_event(¬ify_event( + EventKind::Access(AccessKind::Open(AccessMode::Any)), + vec![path("/tmp/accessed")] + )), + false ); } #[test] -fn classify_event_ignores_non_mutating_event_kinds() { - let root = path("/tmp/skills"); - let state = RwLock::new(WatchState { - skills_root_ref_counts: HashMap::from([(root.clone(), 1)]), - }); - let path = root.join("demo/SKILL.md"); - - let access_event = notify_event( - EventKind::Access(AccessKind::Open(AccessMode::Any)), - vec![path.clone()], - ); - assert_eq!(classify_event(&access_event, &state), Vec::::new()); - - let any_event = notify_event(EventKind::Any, vec![path.clone()]); - assert_eq!(classify_event(&any_event, &state), Vec::::new()); - - let other_event = notify_event(EventKind::Other, vec![path]); - assert_eq!(classify_event(&other_event, &state), Vec::::new()); -} - -#[test] -fn register_skills_root_dedupes_state_entries() { - let watcher = FileWatcher::noop(); - let root = path("/tmp/skills"); - watcher.register_skills_root(root.clone()); - watcher.register_skills_root(root); - watcher.register_skills_root(path("/tmp/other-skills")); - - let state = watcher.state.read().expect("state lock"); - assert_eq!(state.skills_root_ref_counts.len(), 2); -} - -#[test] -fn watch_registration_drop_unregisters_roots() { +fn register_dedupes_by_path_and_scope() { let watcher = Arc::new(FileWatcher::noop()); - let root = path("/tmp/skills"); - watcher.register_skills_root(root.clone()); - let registration = WatchRegistration { - file_watcher: Arc::downgrade(&watcher), - roots: vec![root], - }; + let (subscriber, _rx) = watcher.add_subscriber(); + let _first = subscriber.register_path(path("/tmp/skills"), false); + let _second = subscriber.register_path(path("/tmp/skills"), false); + let _third = subscriber.register_path(path("/tmp/skills"), true); + let _fourth = subscriber.register_path(path("/tmp/other-skills"), true); + + assert_eq!( + watcher.watch_counts_for_test(&path("/tmp/skills")), + Some((2, 1)) + ); + assert_eq!( + watcher.watch_counts_for_test(&path("/tmp/other-skills")), + Some((0, 1)) + ); +} + +#[test] +fn watch_registration_drop_unregisters_paths() { + let watcher = Arc::new(FileWatcher::noop()); + let (subscriber, _rx) = watcher.add_subscriber(); + let registration = subscriber.register_path(path("/tmp/skills"), true); drop(registration); - let state = watcher.state.read().expect("state lock"); - assert_eq!(state.skills_root_ref_counts.len(), 0); + assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None); +} + +#[test] +fn subscriber_drop_unregisters_paths() { + let watcher = Arc::new(FileWatcher::noop()); + let registration = { + let (subscriber, _rx) = watcher.add_subscriber(); + subscriber.register_path(path("/tmp/skills"), true) + }; + + assert_eq!(watcher.watch_counts_for_test(&path("/tmp/skills")), None); + drop(registration); +} + +#[tokio::test] +async fn receiver_closes_when_subscriber_drops() { + let watcher = Arc::new(FileWatcher::noop()); + let (subscriber, mut rx) = watcher.add_subscriber(); + + drop(subscriber); + + let closed = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("closed recv timeout"); + assert_eq!(closed, None); +} + +#[test] +fn recursive_registration_downgrades_to_non_recursive_after_drop() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let root = temp_dir.path().join("watched-dir"); + std::fs::create_dir(&root).expect("create root"); + + let watcher = Arc::new(FileWatcher::new().expect("watcher")); + let (subscriber, _rx) = watcher.add_subscriber(); + let non_recursive = subscriber.register_path(root.clone(), false); + let recursive = subscriber.register_path(root.clone(), true); + + { + let inner = watcher.inner.as_ref().expect("watcher inner"); + let inner = inner.lock().expect("inner lock"); + assert_eq!( + inner.watched_paths.get(&root), + Some(&RecursiveMode::Recursive) + ); + } + + drop(recursive); + + { + let inner = watcher.inner.as_ref().expect("watcher inner"); + let inner = inner.lock().expect("inner lock"); + assert_eq!( + inner.watched_paths.get(&root), + Some(&RecursiveMode::NonRecursive) + ); + } + + drop(non_recursive); } #[test] fn unregister_holds_state_lock_until_unwatch_finishes() { let temp_dir = tempfile::tempdir().expect("temp dir"); - let root = temp_dir.path().join("skills"); + let root = temp_dir.path().join("watched-dir"); std::fs::create_dir(&root).expect("create root"); - let watcher = Arc::new(FileWatcher::new(temp_dir.path().to_path_buf()).expect("watcher")); - watcher.register_skills_root(root.clone()); + let watcher = Arc::new(FileWatcher::new().expect("watcher")); + let (unregister_subscriber, _unregister_rx) = watcher.add_subscriber(); + let (register_subscriber, _register_rx) = watcher.add_subscriber(); + let registration = unregister_subscriber.register_path(root.clone(), true); let inner = watcher.inner.as_ref().expect("watcher inner"); let inner_guard = inner.lock().expect("inner lock"); - let unregister_watcher = Arc::clone(&watcher); - let unregister_root = root.clone(); let unregister_thread = std::thread::spawn(move || { - unregister_watcher.unregister_roots(&[unregister_root]); + drop(registration); }); let state_lock_observed = (0..100).any(|_| { @@ -172,75 +227,128 @@ fn unregister_holds_state_lock_until_unwatch_finishes() { }); assert_eq!(state_lock_observed, true); - let register_watcher = Arc::clone(&watcher); let register_root = root.clone(); let register_thread = std::thread::spawn(move || { - register_watcher.register_skills_root(register_root); + let registration = register_subscriber.register_path(register_root, false); + (register_subscriber, registration) }); drop(inner_guard); unregister_thread.join().expect("unregister join"); - register_thread.join().expect("register join"); + let (register_subscriber, non_recursive) = register_thread.join().expect("register join"); - let state = watcher.state.read().expect("state lock"); - assert_eq!(state.skills_root_ref_counts.get(&root), Some(&1)); - drop(state); + assert_eq!(watcher.watch_counts_for_test(&root), Some((1, 0))); let inner = watcher.inner.as_ref().expect("watcher inner"); let inner = inner.lock().expect("inner lock"); assert_eq!( inner.watched_paths.get(&root), - Some(&RecursiveMode::Recursive) + Some(&RecursiveMode::NonRecursive) + ); + drop(inner); + + drop(non_recursive); + drop(register_subscriber); +} + +#[tokio::test] +async fn matching_subscribers_are_notified() { + let watcher = Arc::new(FileWatcher::noop()); + let (skills_subscriber, skills_rx) = watcher.add_subscriber(); + let (plugins_subscriber, plugins_rx) = watcher.add_subscriber(); + let _skills = skills_subscriber.register_path(path("/tmp/skills"), true); + let _plugins = plugins_subscriber.register_path(path("/tmp/plugins"), true); + let mut skills_rx = ThrottledWatchReceiver::new(skills_rx, TEST_THROTTLE_INTERVAL); + let mut plugins_rx = ThrottledWatchReceiver::new(plugins_rx, TEST_THROTTLE_INTERVAL); + + watcher + .send_paths_for_test(vec![path("/tmp/skills/rust/SKILL.md")]) + .await; + + let skills_event = timeout(Duration::from_secs(1), skills_rx.recv()) + .await + .expect("skills change timeout") + .expect("skills change"); + assert_eq!( + skills_event, + FileWatcherEvent { + paths: vec![path("/tmp/skills/rust/SKILL.md")], + } + ); + + let plugins_event = timeout(TEST_THROTTLE_INTERVAL, plugins_rx.recv()).await; + assert_eq!(plugins_event.is_err(), true); +} + +#[tokio::test] +async fn non_recursive_watch_ignores_grandchildren() { + let watcher = Arc::new(FileWatcher::noop()); + let (subscriber, rx) = watcher.add_subscriber(); + let _registration = subscriber.register_path(path("/tmp/skills"), false); + let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); + + watcher + .send_paths_for_test(vec![path("/tmp/skills/nested/SKILL.md")]) + .await; + + let event = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await; + assert_eq!(event.is_err(), true); +} + +#[tokio::test] +async fn ancestor_events_notify_child_watches() { + let watcher = Arc::new(FileWatcher::noop()); + let (subscriber, rx) = watcher.add_subscriber(); + let _registration = subscriber.register_path(path("/tmp/skills/rust/SKILL.md"), false); + let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); + + watcher.send_paths_for_test(vec![path("/tmp/skills")]).await; + + let event = timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("ancestor event timeout") + .expect("ancestor event"); + assert_eq!( + event, + FileWatcherEvent { + paths: vec![path("/tmp/skills")], + } ); } #[tokio::test] -async fn spawn_event_loop_flushes_pending_changes_on_shutdown() { - let watcher = FileWatcher::noop(); - let root = path("/tmp/skills"); - { - let mut state = watcher.state.write().expect("state lock"); - state.skills_root_ref_counts.insert(root.clone(), 1); - } - +async fn spawn_event_loop_filters_non_mutating_events() { + let watcher = Arc::new(FileWatcher::noop()); + let (subscriber, rx) = watcher.add_subscriber(); + let _registration = subscriber.register_path(path("/tmp/skills"), true); + let mut rx = ThrottledWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); let (raw_tx, raw_rx) = mpsc::unbounded_channel(); - let (tx, mut rx) = broadcast::channel(8); - watcher.spawn_event_loop(raw_rx, Arc::clone(&watcher.state), tx); + watcher.spawn_event_loop_for_test(raw_rx); + + raw_tx + .send(Ok(notify_event( + EventKind::Access(AccessKind::Open(AccessMode::Any)), + vec![path("/tmp/skills/SKILL.md")], + ))) + .expect("send access event"); + let blocked = timeout(TEST_THROTTLE_INTERVAL, rx.recv()).await; + assert_eq!(blocked.is_err(), true); raw_tx .send(Ok(notify_event( EventKind::Create(CreateKind::File), - vec![root.join("a/SKILL.md")], + vec![path("/tmp/skills/SKILL.md")], ))) - .expect("send first event"); - let first = timeout(Duration::from_secs(2), rx.recv()) + .expect("send create event"); + let event = timeout(Duration::from_secs(1), rx.recv()) .await - .expect("first watcher event") - .expect("broadcast recv first"); + .expect("create event timeout") + .expect("create event"); assert_eq!( - first, - FileWatcherEvent::SkillsChanged { - paths: vec![root.join("a/SKILL.md")] - } - ); - - raw_tx - .send(Ok(notify_event( - EventKind::Remove(RemoveKind::File), - vec![root.join("b/SKILL.md")], - ))) - .expect("send second event"); - drop(raw_tx); - - let second = timeout(Duration::from_secs(2), rx.recv()) - .await - .expect("second watcher event") - .expect("broadcast recv second"); - assert_eq!( - second, - FileWatcherEvent::SkillsChanged { - paths: vec![root.join("b/SKILL.md")] + event, + FileWatcherEvent { + paths: vec![path("/tmp/skills/SKILL.md")], } ); } diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index 73714ce42f..f6f77d1666 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -1,9 +1,15 @@ -use super::*; - +use codex_git_utils::GitInfo; +use codex_git_utils::GitSha; +use codex_git_utils::collect_git_info; +use codex_git_utils::get_has_changes; +use codex_git_utils::git_diff_to_remote; +use codex_git_utils::recent_commits; +use codex_git_utils::resolve_root_git_project_for_trust; use core_test_support::skip_if_sandbox; use std::fs; use std::path::PathBuf; use tempfile::TempDir; +use tokio::process::Command; // Helper function to create a test git repository async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf { @@ -191,7 +197,7 @@ async fn test_collect_git_info_git_repository() { // Should have commit hash assert!(git_info.commit_hash.is_some()); - let commit_hash = git_info.commit_hash.unwrap(); + let commit_hash = git_info.commit_hash.unwrap().0; assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit())); @@ -558,7 +564,7 @@ async fn test_get_git_working_tree_state_unpushed_commit() { #[test] fn test_git_info_serialization() { let git_info = GitInfo { - commit_hash: Some("abc123def456".to_string()), + commit_hash: Some(GitSha::new("abc123def456")), branch: Some("main".to_string()), repository_url: Some("https://github.com/example/repo.git".to_string()), }; diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index be02916487..5741a65fc0 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -7,9 +7,9 @@ use serde_json::Value; use crate::codex::Session; use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; -use crate::truncate::approx_bytes_for_tokens; -use crate::truncate::approx_token_count; -use crate::truncate::approx_tokens_from_byte_count; +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; use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS; use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 34f0b6298e..64e27c42cd 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -140,7 +140,7 @@ impl GuardianReviewSessionReuseKey { base_instructions: spawn_config.base_instructions.clone(), user_instructions: spawn_config.user_instructions.clone(), compact_prompt: spawn_config.compact_prompt.clone(), - cwd: spawn_config.cwd.clone(), + cwd: spawn_config.cwd.to_path_buf(), mcp_servers: spawn_config.mcp_servers.clone(), codex_linux_sandbox_exe: spawn_config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), @@ -512,8 +512,9 @@ async fn run_review_on_session( .codex .submit(Op::UserTurn { items: params.prompt_items.clone(), - cwd: params.parent_turn.cwd.clone(), + cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: params.model.clone(), effort: params.reasoning_effort, @@ -577,6 +578,7 @@ async fn wait_for_guardian_review( ) -> (GuardianReviewSessionOutcome, bool) { let timeout = tokio::time::sleep_until(deadline); tokio::pin!(timeout); + let mut last_error_message: Option = None; loop { tokio::select! { @@ -598,11 +600,22 @@ async fn wait_for_guardian_review( match event { Ok(event) => match event.msg { EventMsg::TurnComplete(turn_complete) => { + if turn_complete.last_agent_message.is_none() + && let Some(error_message) = last_error_message + { + return ( + GuardianReviewSessionOutcome::Completed(Err(anyhow!(error_message))), + true, + ); + } return ( GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)), true, ); } + EventMsg::Error(error) => { + last_error_message = Some(error.message); + } EventMsg::TurnAborted(_) => { return (GuardianReviewSessionOutcome::Aborted, true); } diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index e1595ea167..3e6d409e76 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -25,12 +25,14 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::ReviewDecision; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_response_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -321,7 +323,7 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() { ("/tmp", "/tmp/guardian.txt") }; let cwd = PathBuf::from(cwd); - let file = AbsolutePathBuf::try_from(file).expect("absolute path"); + let file = PathBuf::from(file).abs(); let action = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: cwd.clone(), @@ -355,7 +357,7 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() { let apply_patch = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), - files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")], + files: vec![PathBuf::from("/tmp/guardian.txt").abs()], change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), @@ -383,7 +385,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), - files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")], + files: vec![PathBuf::from("/tmp/guardian.txt").abs()], change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), @@ -511,7 +513,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() let (mut session, mut turn) = crate::codex::make_session_and_context().await; let temp_cwd = TempDir::new()?; let mut config = (*turn.config).clone(); - config.cwd = temp_cwd.path().to_path_buf(); + config.cwd = temp_cwd.abs(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); let models_manager = Arc::new(test_support::models_manager_with_provider( @@ -717,6 +719,100 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let error_message = + "Item 'rs_test' of type 'reasoning' was provided without its required following item."; + let _request_log = mount_response_once( + &server, + wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": error_message, + "type": "invalid_request_error", + "param": "input" + } + })), + ) + .await; + + let (mut session, mut turn, rx) = crate::codex::make_session_and_context_with_rx().await; + let mut config = (*turn.config).clone(); + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + config.user_instructions = None; + let config = Arc::new(config); + let models_manager = Arc::new(test_support::models_manager_with_provider( + config.codex_home.clone(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + Arc::get_mut(&mut session) + .expect("session should be uniquely owned") + .services + .models_manager = models_manager; + let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned"); + turn_mut.config = Arc::clone(&config); + turn_mut.provider = config.model_provider.clone(); + turn_mut.user_instructions = None; + + seed_guardian_parent_history(&session, &turn).await; + + let decision = review_approval_request( + &session, + &turn, + GuardianApprovalRequest::Shell { + id: "shell-guardian-error".to_string(), + command: vec!["git".to_string(), "push".to_string()], + cwd: PathBuf::from("/repo/codex-rs/core"), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: Some("Need to push the reviewed docs fix.".to_string()), + }, + None, + ) + .await; + + assert_eq!(decision, ReviewDecision::Denied); + + let mut warnings = Vec::new(); + let mut denial_rationales = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event.msg { + EventMsg::Warning(event) => warnings.push(event.message), + EventMsg::GuardianAssessment(event) + if event.status == GuardianAssessmentStatus::Denied => + { + denial_rationales.push(event.rationale) + } + _ => {} + } + } + + assert!( + warnings + .iter() + .any(|message| message.contains(error_message)), + "warning should include the underlying responses api error" + ); + assert!( + denial_rationales + .iter() + .flatten() + .any(|message| message.contains(error_message)), + "denial rationale should include the underlying responses api error" + ); + assert!( + denial_rationales.iter().flatten().all(|message| { + !message.contains("guardian review completed without an assessment payload") + }), + "denial rationale should not fall back to the generic missing payload error" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> { let first_assessment = serde_json::json!({ diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 26b49facc3..744db4886b 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_hooks::PreToolUseOutcome; +use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; @@ -90,7 +92,7 @@ pub(crate) async fn run_pending_session_start_hooks( let request = codex_hooks::SessionStartRequest { session_id: sess.conversation_id, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), @@ -109,6 +111,36 @@ pub(crate) async fn run_pending_session_start_hooks( .await } +pub(crate) async fn run_pre_tool_use_hooks( + sess: &Arc, + turn_context: &Arc, + tool_use_id: String, + command: String, +) -> Option { + let request = PreToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.to_path_buf(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name: "Bash".to_string(), + tool_use_id, + command, + }; + let preview_runs = sess.hooks().preview_pre_tool_use(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let PreToolUseOutcome { + hook_events, + should_block, + block_reason, + } = sess.hooks().run_pre_tool_use(request).await; + emit_hook_completed_events(sess, turn_context, hook_events).await; + + if should_block { block_reason } else { None } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, @@ -117,7 +149,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let request = UserPromptSubmitRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 19b3f7c6af..c1dc4ba542 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -5,6 +5,8 @@ use crate::spawn::spawn_child_async; use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_sandboxing::landlock::allow_network_for_proxy; +use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; @@ -59,112 +61,3 @@ where }) .await } - -pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { - // When managed network requirements are active, request proxy-only - // networking from the Linux sandbox helper. Without managed requirements, - // preserve existing behavior. - enforce_managed_network -} - -/// Converts the sandbox policies into the CLI invocation for -/// `codex-linux-sandbox`. -/// -/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after -/// parsing these arguments. Policy JSON flags are emitted before helper feature -/// flags so the argv order matches the helper's CLI shape. See -/// `docs/linux_sandbox.md` for the Linux semantics. -#[allow(clippy::too_many_arguments)] -pub fn create_linux_sandbox_command_args_for_policies( - command: Vec, - command_cwd: &Path, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, - sandbox_policy_cwd: &Path, - use_legacy_landlock: bool, - allow_network_for_proxy: bool, -) -> Vec { - let sandbox_policy_json = serde_json::to_string(sandbox_policy) - .unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}")); - let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy) - .unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}")); - let network_policy_json = serde_json::to_string(&network_sandbox_policy) - .unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}")); - let sandbox_policy_cwd = sandbox_policy_cwd - .to_str() - .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) - .to_string(); - let command_cwd = command_cwd - .to_str() - .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) - .to_string(); - - let mut linux_cmd: Vec = vec![ - "--sandbox-policy-cwd".to_string(), - sandbox_policy_cwd, - "--command-cwd".to_string(), - command_cwd, - "--sandbox-policy".to_string(), - sandbox_policy_json, - "--file-system-sandbox-policy".to_string(), - file_system_policy_json, - "--network-sandbox-policy".to_string(), - network_policy_json, - ]; - if use_legacy_landlock { - linux_cmd.push("--use-legacy-landlock".to_string()); - } - if allow_network_for_proxy { - linux_cmd.push("--allow-network-for-proxy".to_string()); - } - linux_cmd.push("--".to_string()); - linux_cmd.extend(command); - linux_cmd -} - -/// Converts the sandbox cwd and execution options into the CLI invocation for -/// `codex-linux-sandbox`. -#[cfg(test)] -pub(crate) fn create_linux_sandbox_command_args( - command: Vec, - command_cwd: &Path, - sandbox_policy_cwd: &Path, - use_legacy_landlock: bool, - allow_network_for_proxy: bool, -) -> Vec { - let command_cwd = command_cwd - .to_str() - .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) - .to_string(); - let sandbox_policy_cwd = sandbox_policy_cwd - .to_str() - .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) - .to_string(); - - let mut linux_cmd: Vec = vec![ - "--sandbox-policy-cwd".to_string(), - sandbox_policy_cwd, - "--command-cwd".to_string(), - command_cwd, - ]; - if use_legacy_landlock { - linux_cmd.push("--use-legacy-landlock".to_string()); - } - if allow_network_for_proxy { - linux_cmd.push("--allow-network-for-proxy".to_string()); - } - - // Separator so that command arguments starting with `-` are not parsed as - // options of the helper itself. - linux_cmd.push("--".to_string()); - - // Append the original tool command. - linux_cmd.extend(command); - - linux_cmd -} - -#[cfg(test)] -#[path = "landlock_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 3332dfefd7..4dec339bb6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -32,16 +32,17 @@ pub mod connectors; mod context_manager; mod contextual_user_message; pub mod custom_prompts; -pub mod env; +pub use codex_utils_path::env; mod environment_context; pub mod error; pub mod exec; pub mod exec_env; mod exec_policy; pub mod external_agent_config; -mod file_watcher; +pub mod file_watcher; mod flags; -pub mod git_info; +#[cfg(test)] +mod git_info_tests; mod guardian; mod hook_runtime; pub mod instructions; @@ -65,7 +66,8 @@ pub mod mention_syntax; mod mentions; pub mod message_history; mod model_provider_info; -pub mod path_utils; +pub mod utils; +pub use utils::path_utils; pub mod personality_migration; pub mod plugins; mod sandbox_tags; @@ -73,11 +75,11 @@ pub mod sandboxing; mod session_prefix; mod session_startup_prewarm; mod shell_detect; +mod skills_watcher; mod stream_events_utils; pub mod test_support; mod text_encoding; pub use codex_login::token_data; -mod truncate; mod unified_exec; pub mod windows_sandbox; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; @@ -94,10 +96,10 @@ mod event_mapping; mod response_debug_context; pub mod review_format; pub mod review_prompts; -mod seatbelt_permissions; mod thread_manager; pub mod web_search; pub mod windows_sandbox_read_grants; +pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; pub use thread_manager::ThreadManager; #[deprecated(note = "use ThreadManager")] @@ -123,11 +125,14 @@ pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; +mod session_rollout_init_error; pub mod shell; pub mod shell_snapshot; pub mod skills; pub mod spawn; -pub mod state_db; +pub mod state_db_bridge; +pub use codex_rollout::state_db; +mod thread_rollout_truncation; mod tools; pub mod turn_diff_tracker; mod turn_metadata; @@ -174,6 +179,7 @@ pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; pub use client_common::ResponseEvent; pub use client_common::ResponseStream; +pub use codex_sandboxing::get_platform_sandbox; pub use compact::content_items_to_text; pub use event_mapping::parse_turn_item; pub use exec_policy::ExecPolicyError; @@ -181,7 +187,6 @@ pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; pub use file_watcher::FileWatcherEvent; -pub use safety::get_platform_sandbox; pub use tools::spec::parse_tool_input_schema; pub use turn_metadata::build_turn_metadata_header; pub mod compact; diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 938d6d0b2b..eac8485048 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -1440,7 +1440,12 @@ async fn make_rmcp_client( } => { let command_os: OsString = command.into(); let args_os: Vec = args.into_iter().map(Into::into).collect(); - RmcpClient::new_stdio_client(command_os, args_os, env, &env_vars, cwd) + let env_os = env.map(|env| { + env.into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect::>() + }); + RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd) .await .map_err(|err| StartupOutcomeError::from(anyhow!(err))) } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 57b6a99cb1..fb4025b4a8 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -543,7 +543,7 @@ async fn maybe_request_mcp_tool_approval( approval_mode: AppToolApproval, ) -> Option { let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); - let approval_required = annotations.is_some_and(requires_mcp_tool_approval); + let approval_required = requires_mcp_tool_approval(annotations); let mut monitor_reason = None; let auto_approved_by_policy = approval_mode == AppToolApproval::Approve || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); @@ -1347,12 +1347,23 @@ async fn persist_codex_app_tool_approval( .await } -fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - if annotations.destructive_hint == Some(true) { +fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { + let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint); + if destructive_hint == Some(true) { return true; } - annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) + let read_only_hint = annotations + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false); + if read_only_hint { + return false; + } + + destructive_hint.unwrap_or(true) + || annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(true) } async fn notify_mcp_tool_call_skip( diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 0ac37f9ce8..9d7d1c3a41 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -64,19 +64,30 @@ fn prompt_options( #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), None); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_read_only_false_and_open_world() { let annotations = annotations(Some(false), None, Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); +} + +#[test] +fn approval_required_when_annotations_are_absent() { + assert_eq!(requires_mcp_tool_approval(None), true); +} + +#[test] +fn approval_not_required_when_read_only_and_other_hints_are_absent() { + let annotations = annotations(Some(true), None, None); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false); } #[test] @@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: None, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-3", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { use wiremock::Mock; diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index 2e0d7c4add..321b314abb 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -266,7 +266,16 @@ mod agent { let root = memory_root(&config.codex_home); let mut agent_config = config.as_ref().clone(); - agent_config.cwd = root; + match AbsolutePathBuf::from_absolute_path(root) { + Ok(root) => agent_config.cwd = root, + Err(err) => { + warn!( + "memory phase-2 consolidation could not set cwd from codex_home {}: {err}", + agent_config.codex_home.display() + ); + return None; + } + } // Consolidation threads must never feed back into phase-1 memory generation. agent_config.memories.generate_memories = false; // Approval policy diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index 1659e1c1c7..851179426f 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -1,13 +1,13 @@ use crate::memories::memory_root; use crate::memories::phase_one; use crate::memories::storage::rollout_summary_file_stem_from_parts; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use askama::Template; use codex_protocol::openai_models::ModelInfo; use codex_state::Phase2InputSelection; use codex_state::Stage1Output; use codex_state::Stage1OutputRef; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use std::path::Path; use tokio::fs; use tracing::warn; diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index e04a018a84..a929401b96 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -435,6 +435,7 @@ mod phase2 { use codex_state::Phase2JobClaimOutcome; use codex_state::Stage1Output; use codex_state::ThreadMetadataBuilder; + use core_test_support::PathBufExt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -469,7 +470,7 @@ mod phase2 { let codex_home = tempfile::tempdir().expect("create temp codex home"); let mut config = test_config(); config.codex_home = codex_home.path().to_path_buf(); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); let config = Arc::new(config); let state_db = codex_state::StateRuntime::init( @@ -507,7 +508,7 @@ mod phase2 { Utc::now(), SessionSource::Cli, ); - metadata_builder.cwd = self.config.cwd.clone(); + metadata_builder.cwd = self.config.cwd.to_path_buf(); metadata_builder.model_provider = Some(self.config.model_provider_id.clone()); let metadata = metadata_builder.build(&self.config.model_provider_id); @@ -882,7 +883,7 @@ mod phase2 { let codex_home = tempfile::tempdir().expect("create temp codex home"); let mut config = test_config(); config.codex_home = codex_home.path().to_path_buf(); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); let config = Arc::new(config); let state_db = codex_state::StateRuntime::init( @@ -904,7 +905,7 @@ mod phase2 { Utc::now(), SessionSource::Cli, ); - metadata_builder.cwd = config.cwd.clone(); + metadata_builder.cwd = config.cwd.to_path_buf(); metadata_builder.model_provider = Some(config.model_provider_id.clone()); let metadata = metadata_builder.build(&config.model_provider_id); state_db diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 7d3c6e9d10..055a6459d4 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -10,8 +10,8 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::openai_models::default_input_modalities; use crate::config::Config; -use crate::truncate::approx_bytes_for_tokens; use codex_features::Feature; +use codex_utils_output_truncation::approx_bytes_for_tokens; use tracing::warn; pub const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 5d054c2ff2..00efd58026 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -39,7 +39,8 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( .collect::>(); let marketplaces = plugins_manager .list_marketplaces_for_config(config, &[]) - .context("failed to list plugin marketplaces for tool suggestions")?; + .context("failed to list plugin marketplaces for tool suggestions")? + .marketplaces; let Some(curated_marketplace) = marketplaces .into_iter() .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index cb2ac15493..fd9df9ddf6 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = false +"#, + ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index f02758d273..d2d2e1b1c2 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -4,6 +4,7 @@ use super::load_plugin_manifest; use super::manifest::PluginManifestInterface; use super::marketplace::MarketplaceError; use super::marketplace::MarketplaceInterface; +use super::marketplace::MarketplaceListError; use super::marketplace::MarketplacePluginAuthPolicy; use super::marketplace::MarketplacePluginPolicy; use super::marketplace::MarketplacePluginSource; @@ -19,7 +20,6 @@ use super::remote::fetch_remote_featured_plugin_ids; use super::remote::fetch_remote_plugin_status; use super::remote::uninstall_remote_plugin; use super::startup_sync::start_startup_remote_plugin_sync_once; -use super::store::DEFAULT_PLUGIN_VERSION; use super::store::PluginId; use super::store::PluginIdError; use super::store::PluginInstallResult as StorePluginInstallResult; @@ -38,6 +38,9 @@ use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; use crate::config_loader::ConfigLayerStack; use crate::skills::SkillMetadata; +use crate::skills::config_rules::SkillConfigRules; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; @@ -152,6 +155,7 @@ pub struct PluginDetail { pub installed: bool, pub enabled: bool, pub skills: Vec, + pub disabled_skill_paths: HashSet, pub apps: Vec, pub mcp_server_names: Vec, } @@ -175,6 +179,12 @@ pub struct ConfiguredMarketplacePlugin { pub enabled: bool, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConfiguredMarketplaceListOutcome { + pub marketplaces: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, PartialEq)] pub struct LoadedPlugin { pub config_name: String, @@ -183,6 +193,8 @@ pub struct LoadedPlugin { pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, + pub disabled_skill_paths: HashSet, + pub has_enabled_skills: bool, pub mcp_servers: HashMap, pub apps: Vec, pub error: Option, @@ -235,7 +247,7 @@ impl PluginCapabilitySummary { .clone() .unwrap_or_else(|| plugin.config_name.clone()), description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), - has_skills: !plugin.skill_roots.is_empty(), + has_skills: plugin.has_enabled_skills, mcp_server_names, app_connector_ids: plugin.apps.clone(), }; @@ -258,11 +270,16 @@ impl PluginCapabilitySummary { impl From for PluginCapabilitySummary { fn from(value: PluginDetail) -> Self { + let has_skills = value.skills.iter().any(|skill| { + !value + .disabled_skill_paths + .contains(&skill.path_to_skills_md) + }); Self { config_name: value.id, display_name: value.name, description: prompt_safe_plugin_description(value.description.as_deref()), - has_skills: !value.skills.is_empty(), + has_skills, mcp_server_names: value.mcp_server_names, app_connector_ids: value.apps, } @@ -531,7 +548,11 @@ impl PluginsManager { return outcome; } - let outcome = load_plugins_from_layer_stack(&config.config_layer_stack, &self.store); + let outcome = load_plugins_from_layer_stack( + &config.config_layer_stack, + &self.store, + self.restriction_product, + ); log_plugin_load_errors(&outcome); let mut cache = match self.cached_enabled_outcome.write() { Ok(cache) => cache, @@ -982,16 +1003,16 @@ impl PluginsManager { &self, config: &Config, additional_roots: &[AbsolutePathBuf], - ) -> Result, MarketplaceError> { + ) -> Result { if !config.features.enabled(Feature::Plugins) { - return Ok(Vec::new()); + return Ok(ConfiguredMarketplaceListOutcome::default()); } let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); - let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?; + let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?; let mut seen_plugin_keys = HashSet::new(); - - Ok(marketplaces + let marketplaces = marketplace_outcome + .marketplaces .into_iter() .filter_map(|marketplace| { let marketplace_name = marketplace.name.clone(); @@ -1029,7 +1050,12 @@ impl PluginsManager { plugins, }) }) - .collect()) + .collect(); + + Ok(ConfiguredMarketplaceListOutcome { + marketplaces, + errors: marketplace_outcome.errors, + }) } pub fn read_plugin_for_config( @@ -1070,6 +1096,11 @@ impl PluginsManager { let source_path = match &plugin.source { MarketplacePluginSource::Local { path } => path.clone(), }; + if !source_path.as_path().is_dir() { + return Err(MarketplaceError::InvalidPlugin( + "path does not exist or is not a directory".to_string(), + )); + } let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| { MarketplaceError::InvalidPlugin( "missing or invalid .codex-plugin/plugin.json".to_string(), @@ -1077,15 +1108,13 @@ impl PluginsManager { })?; let description = manifest.description.clone(); let manifest_paths = &manifest.paths; - let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths); - let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot { - path, - scope: SkillScope::User, - })) - .skills - .into_iter() - .filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product)) - .collect(); + let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + let resolved_skills = load_plugin_skills( + source_path.as_path(), + manifest_paths, + self.restriction_product, + &skill_config_rules, + ); let apps = load_plugin_apps(source_path.as_path()); let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths); let mut mcp_server_names = Vec::new(); @@ -1111,7 +1140,8 @@ impl PluginsManager { interface: plugin.interface, installed: installed_plugins.contains(&plugin_key), enabled: enabled_plugins.contains(&plugin_key), - skills, + skills: resolved_skills.skills, + disabled_skill_paths: resolved_skills.disabled_skill_paths, apps, mcp_server_names, }, @@ -1347,7 +1377,9 @@ struct PluginAppConfig { pub(crate) fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, store: &PluginStore, + restriction_product: Option, ) -> PluginLoadOutcome { + let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack) .into_iter() .collect(); @@ -1356,7 +1388,13 @@ pub(crate) fn load_plugins_from_layer_stack( let mut plugins = Vec::with_capacity(configured_plugins.len()); let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { - let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store); + let loaded_plugin = load_plugin( + configured_name.clone(), + &plugin, + store, + restriction_product, + &skill_config_rules, + ); for name in loaded_plugin.mcp_servers.keys() { if let Some(previous_plugin) = seen_mcp_server_names.insert(name.clone(), configured_name.clone()) @@ -1463,16 +1501,24 @@ fn configured_plugins_from_stack( } } -fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin { - let plugin_root = PluginId::parse(&config_name).map(|plugin_id| { - store - .active_plugin_root(&plugin_id) - .unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION)) - }); - let root = match &plugin_root { - Ok(plugin_root) => plugin_root.clone(), - Err(_) => store.root().clone(), - }; +fn load_plugin( + config_name: String, + plugin: &PluginConfig, + store: &PluginStore, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> LoadedPlugin { + let plugin_id = PluginId::parse(&config_name); + let active_plugin_root = plugin_id + .as_ref() + .ok() + .and_then(|plugin_id| store.active_plugin_root(plugin_id)); + let root = active_plugin_root + .clone() + .unwrap_or_else(|| match &plugin_id { + Ok(plugin_id) => store.plugin_base_root(plugin_id), + Err(_) => store.root().clone(), + }); let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, @@ -1480,6 +1526,8 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) root, enabled: plugin.enabled, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -1489,8 +1537,14 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) return loaded_plugin; } - let plugin_root = match plugin_root { - Ok(plugin_root) => plugin_root, + let plugin_root = match plugin_id { + Ok(_) => match active_plugin_root { + Some(plugin_root) => plugin_root, + None => { + loaded_plugin.error = Some("plugin is not installed".to_string()); + return loaded_plugin; + } + }, Err(err) => { loaded_plugin.error = Some(err.to_string()); return loaded_plugin; @@ -1508,9 +1562,25 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) }; let manifest_paths = &manifest.paths; - loaded_plugin.manifest_name = Some(manifest.name.clone()); + loaded_plugin.manifest_name = manifest + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .or_else(|| Some(manifest.name.clone())); loaded_plugin.manifest_description = manifest.description.clone(); loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths); + let resolved_skills = load_plugin_skills( + plugin_root.as_path(), + manifest_paths, + restriction_product, + skill_config_rules, + ); + let has_enabled_skills = resolved_skills.has_enabled_skills(); + loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths; + loaded_plugin.has_enabled_skills = has_enabled_skills; let mut mcp_servers = HashMap::new(); for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) { let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path); @@ -1530,6 +1600,52 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) loaded_plugin } +struct ResolvedPluginSkills { + skills: Vec, + disabled_skill_paths: HashSet, + had_errors: bool, +} + +impl ResolvedPluginSkills { + fn has_enabled_skills(&self) -> bool { + // Keep the plugin visible in capability summaries if skill loading was partial. + self.had_errors + || self + .skills + .iter() + .any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md)) + } +} + +fn load_plugin_skills( + plugin_root: &Path, + manifest_paths: &PluginManifestPaths, + restriction_product: Option, + skill_config_rules: &SkillConfigRules, +) -> ResolvedPluginSkills { + let outcome = load_skills_from_roots( + plugin_skill_roots(plugin_root, manifest_paths) + .into_iter() + .map(|path| SkillRoot { + path, + scope: SkillScope::User, + }), + ); + let had_errors = !outcome.errors.is_empty(); + let skills = outcome + .skills + .into_iter() + .filter(|skill| skill.matches_product_restriction_for_product(restriction_product)) + .collect::>(); + let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules); + + ResolvedPluginSkills { + skills, + disabled_skill_paths, + had_errors, + } +} + fn plugin_skill_roots(plugin_root: &Path, manifest_paths: &PluginManifestPaths) -> Vec { let mut paths = default_skill_roots(plugin_root); if let Some(path) = &manifest_paths.skills { diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index cd8541a85c..3d61c39375 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -13,6 +13,7 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::protocol::Product; use pretty_assertions::assert_eq; use std::fs; use tempfile::TempDir; @@ -139,6 +140,8 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills")], + disabled_skill_paths: HashSet::new(), + has_enabled_skills: true, mcp_servers: HashMap::from([( "sample".to_string(), McpServerConfig { @@ -185,6 +188,89 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[test] +fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + let skill_path = plugin_root.join("skills/sample-search/SKILL.md"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &skill_path, + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()); + let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); + + assert_eq!( + outcome.plugins[0].disabled_skill_paths, + HashSet::from([skill_path]) + ); + assert!(!outcome.plugins[0].has_enabled_skills); + assert!(outcome.capability_summaries().is_empty()); +} + +#[test] +fn load_plugins_ignores_unknown_disabled_skill_names() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + + let config_toml = r#"[features] +plugins = true + +[[skills.config]] +name = "sample:missing-skill" +enabled = false + +[plugins."sample@test"] +enabled = true +"#; + let outcome = load_plugins_from_config(config_toml, codex_home.path()); + + assert!(outcome.plugins[0].disabled_skill_paths.is_empty()); + assert!(outcome.plugins[0].has_enabled_skills); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); +} + #[test] fn plugin_telemetry_metadata_uses_default_mcp_config_path() { let codex_home = TempDir::new().unwrap(); @@ -540,6 +626,8 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -651,6 +739,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), + disabled_skill_paths: HashSet::new(), + has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, @@ -664,6 +754,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { let outcome = PluginLoadOutcome::from_plugins(vec![ LoadedPlugin { skill_roots: vec![codex_home.path().join("skills-plugin/skills")], + has_enabled_skills: true, ..plugin("skills@test", "skills-plugin", "skills-plugin") }, LoadedPlugin { @@ -944,7 +1035,8 @@ enabled = false let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) - .unwrap(); + .unwrap() + .marketplaces; let marketplace = marketplaces .into_iter() @@ -1039,7 +1131,8 @@ enabled = true let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!(marketplaces, Vec::new()); } @@ -1086,7 +1179,8 @@ plugins = true let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) - .unwrap(); + .unwrap() + .marketplaces; let marketplace = marketplaces .into_iter() @@ -1166,6 +1260,70 @@ enabled = true assert!(matches!(err, MarketplaceError::PluginsDisabled)); } +#[tokio::test] +async fn read_plugin_for_config_uses_user_layer_skill_settings_only() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let plugin_root = repo_root.join("enabled-plugin"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "enabled-plugin", + "source": { + "source": "local", + "path": "./enabled-plugin" + } + } + ] +}"#, + ); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"enabled-plugin"}"#, + ); + write_file( + &plugin_root.join("skills/sample-search/SKILL.md"), + "---\nname: sample-search\ndescription: search sample data\n---\n", + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."enabled-plugin@debug"] +enabled = true +"#, + ); + write_file( + &repo_root.join(".codex/config.toml"), + r#"[[skills.config]] +name = "enabled-plugin:sample-search" +enabled = false +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let outcome = PluginsManager::new(tmp.path().to_path_buf()) + .read_plugin_for_config( + &config, + &PluginReadRequest { + plugin_name: "enabled-plugin".to_string(), + marketplace_path: AbsolutePathBuf::try_from( + repo_root.join(".agents/plugins/marketplace.json"), + ) + .unwrap(), + }, + ) + .unwrap(); + + assert!(outcome.plugin.disabled_skill_paths.is_empty()); +} + #[tokio::test] async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); @@ -1227,7 +1385,8 @@ plugins = true let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[]) - .unwrap(); + .unwrap() + .marketplaces; let curated_marketplace = marketplaces .into_iter() @@ -1332,7 +1491,8 @@ enabled = false AbsolutePathBuf::try_from(repo_b_root).unwrap(), ], ) - .unwrap(); + .unwrap() + .marketplaces; let repo_a_marketplace = marketplaces .iter() @@ -1435,7 +1595,8 @@ enabled = true let config = load_config(tmp.path(), &repo_root).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) - .unwrap(); + .unwrap() + .marketplaces; let marketplace = marketplaces .into_iter() @@ -1578,6 +1739,7 @@ enabled = true let curated_marketplace = manager .list_marketplaces_for_config(&synced_config, &[]) .unwrap() + .marketplaces .into_iter() .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) .unwrap(); @@ -2082,8 +2244,11 @@ fn load_plugins_ignores_project_config_files() { ) .expect("config layer stack should build"); - let outcome = - load_plugins_from_layer_stack(&stack, &PluginStore::new(codex_home.path().to_path_buf())); + let outcome = load_plugins_from_layer_stack( + &stack, + &PluginStore::new(codex_home.path().to_path_buf()), + Some(Product::Codex), + ); assert_eq!(outcome, PluginLoadOutcome::default()); } diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index 17b37f8cc9..5563f0c3aa 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -2,9 +2,9 @@ use super::PluginManifestInterface; use super::load_plugin_manifest; use super::store::PluginId; use super::store::PluginIdError; -use crate::git_info::get_git_repo_root; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use dirs::home_dir; @@ -33,6 +33,18 @@ pub struct Marketplace { pub plugins: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceListError { + pub path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MarketplaceListOutcome { + pub marketplaces: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceInterface { pub display_name: Option, @@ -195,7 +207,7 @@ pub fn resolve_marketplace_plugin( pub fn list_marketplaces( additional_roots: &[AbsolutePathBuf], -) -> Result, MarketplaceError> { +) -> Result { list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } @@ -246,23 +258,27 @@ pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result, -) -> Result, MarketplaceError> { - let mut marketplaces = Vec::new(); +) -> Result { + let mut outcome = MarketplaceListOutcome::default(); for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { match load_marketplace(&marketplace_path) { - Ok(marketplace) => marketplaces.push(marketplace), + Ok(marketplace) => outcome.marketplaces.push(marketplace), Err(err) => { warn!( path = %marketplace_path.display(), error = %err, "skipping marketplace that failed to load" ); + outcome.errors.push(MarketplaceListError { + path: marketplace_path, + message: err.to_string(), + }); } } } - Ok(marketplaces) + Ok(outcome) } fn discover_marketplace_paths_from_roots( diff --git a/codex-rs/core/src/plugins/marketplace_tests.rs b/codex-rs/core/src/plugins/marketplace_tests.rs index faf60250a0..0c5e72e641 100644 --- a/codex-rs/core/src/plugins/marketplace_tests.rs +++ b/codex-rs/core/src/plugins/marketplace_tests.rs @@ -130,7 +130,8 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], Some(&home_root), ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces, @@ -254,7 +255,8 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], Some(&home_root), ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces, @@ -342,7 +344,8 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { ], None, ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces, @@ -396,7 +399,8 @@ fn list_marketplaces_reads_marketplace_display_name() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].interface, @@ -456,12 +460,66 @@ fn list_marketplaces_skips_marketplaces_that_fail_to_load() { ], None, ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!(marketplaces.len(), 1); assert_eq!(marketplaces[0].name, "valid-marketplace"); } +#[test] +fn list_marketplaces_reports_marketplace_load_errors() { + let tmp = tempdir().unwrap(); + let valid_repo_root = tmp.path().join("valid-repo"); + let invalid_repo_root = tmp.path().join("invalid-repo"); + + fs::create_dir_all(valid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap(); + fs::write( + valid_repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + let invalid_marketplace_path = + AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(); + fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap(); + + let outcome = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(valid_repo_root).unwrap(), + AbsolutePathBuf::try_from(invalid_repo_root).unwrap(), + ], + None, + ) + .unwrap(); + + assert_eq!(outcome.marketplaces.len(), 1); + assert_eq!(outcome.marketplaces[0].name, "valid-marketplace"); + assert_eq!(outcome.errors.len(), 1); + assert_eq!(outcome.errors[0].path, invalid_marketplace_path); + assert!( + outcome.errors[0] + .message + .contains("invalid marketplace file"), + "unexpected errors: {:?}", + outcome.errors + ); +} + #[test] fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { let tmp = tempdir().unwrap(); @@ -510,7 +568,8 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].plugins[0].policy.installation, @@ -577,7 +636,8 @@ fn list_marketplaces_ignores_legacy_top_level_policy_fields() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].plugins[0].policy.installation, @@ -632,7 +692,8 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].plugins[0].interface, diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 3e1e6db28d..34403f4d1f 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -15,6 +15,7 @@ pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplace; +pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; pub use manager::LoadedPlugin; pub use manager::OPENAI_CURATED_MARKETPLACE_NAME; @@ -40,6 +41,7 @@ pub use manifest::PluginManifestInterface; pub(crate) use manifest::PluginManifestPaths; pub(crate) use manifest::load_plugin_manifest; pub use marketplace::MarketplaceError; +pub use marketplace::MarketplaceListError; pub use marketplace::MarketplacePluginAuthPolicy; pub use marketplace::MarketplacePluginInstallPolicy; pub use marketplace::MarketplacePluginPolicy; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 262ca10286..97b0ccefe1 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -110,10 +110,15 @@ impl PluginStore { .filter(|version| validate_plugin_segment(version, "plugin version").is_ok()) .collect::>(); discovered_versions.sort_unstable(); - if discovered_versions.len() == 1 { - discovered_versions.pop() - } else { + if discovered_versions.is_empty() { None + } else if discovered_versions + .iter() + .any(|version| version == DEFAULT_PLUGIN_VERSION) + { + Some(DEFAULT_PLUGIN_VERSION.to_string()) + } else { + discovered_versions.pop() } } diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index b1da11a8a6..61c3460255 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -130,6 +130,50 @@ fn active_plugin_version_reads_version_directory_name() { ); } +#[test] +fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/local", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("local".to_string()) + ); +} + +#[test] +fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/0123456789abcdef", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/fedcba9876543210", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("fedcba9876543210".to_string()) + ); +} + #[test] fn plugin_root_rejects_path_separators_in_key_segments() { let err = PluginId::parse("../../etc@debug").unwrap_err(); diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 7ad122c404..1b72f64610 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -184,7 +184,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result std::io::Result> { - let mut dir = config.cwd.clone(); + let mut dir = config.cwd.to_path_buf(); if let Ok(canon) = normalize_path(&dir) { dir = canon; } diff --git a/codex-rs/core/src/project_doc_tests.rs b/codex-rs/core/src/project_doc_tests.rs index 4cea541be3..866831d8b1 100644 --- a/codex-rs/core/src/project_doc_tests.rs +++ b/codex-rs/core/src/project_doc_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::config::ConfigBuilder; use codex_features::Feature; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -18,7 +20,7 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - .await .expect("defaults for test should always succeed"); - config.cwd = root.path().to_path_buf(); + config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); @@ -62,7 +64,7 @@ async fn make_config_with_project_root_markers( .await .expect("defaults for test should always succeed"); - config.cwd = root.path().to_path_buf(); + config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); config @@ -136,7 +138,7 @@ async fn finds_doc_in_repo_root() { // Build config pointing at the nested dir. let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "root level doc"); @@ -261,7 +263,7 @@ async fn concatenates_root_and_cwd_docs() { fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "root doc\n\ncrate doc"); @@ -278,13 +280,13 @@ async fn project_root_markers_are_honored_for_agents_discovery() { fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); let mut cfg = make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); let expected_parent = dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); let expected_child = - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"); + dunce::canonicalize(cfg.cwd.as_path().join("AGENTS.md")).expect("canonical child doc path"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index 5e714a8308..1b845db1e9 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -1,13 +1,13 @@ use crate::codex::Session; use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; -use crate::git_info::resolve_root_git_project_for_trust; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use chrono::Utc; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::models::ResponseItem; use codex_state::SortKey; use codex_state::ThreadMetadata; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use dirs::home_dir; use std::cmp::Reverse; use std::collections::HashMap; diff --git a/codex-rs/core/src/review_prompts.rs b/codex-rs/core/src/review_prompts.rs index cfcb7e0b13..a5bf8e5f99 100644 --- a/codex-rs/core/src/review_prompts.rs +++ b/codex-rs/core/src/review_prompts.rs @@ -1,4 +1,4 @@ -use codex_git::merge_base_with_head; +use codex_git_utils::merge_base_with_head; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use std::path::Path; diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs new file mode 100644 index 0000000000..c3a7218710 --- /dev/null +++ b/codex-rs/core/src/rollout.rs @@ -0,0 +1,63 @@ +use crate::config::Config; +pub use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; +pub use codex_rollout::INTERACTIVE_SESSION_SOURCES; +pub use codex_rollout::RolloutRecorder; +pub use codex_rollout::RolloutRecorderParams; +pub use codex_rollout::SESSIONS_SUBDIR; +pub use codex_rollout::SessionMeta; +pub use codex_rollout::append_thread_name; +pub use codex_rollout::find_archived_thread_path_by_id_str; +#[deprecated(note = "use find_thread_path_by_id_str")] +pub use codex_rollout::find_conversation_path_by_id_str; +pub use codex_rollout::find_thread_name_by_id; +pub use codex_rollout::find_thread_path_by_id_str; +pub use codex_rollout::find_thread_path_by_name_str; +pub use codex_rollout::rollout_date_parts; + +impl codex_rollout::RolloutConfigView for Config { + fn codex_home(&self) -> &std::path::Path { + self.codex_home.as_path() + } + + fn sqlite_home(&self) -> &std::path::Path { + self.sqlite_home.as_path() + } + + fn cwd(&self) -> &std::path::Path { + self.cwd.as_path() + } + + fn model_provider_id(&self) -> &str { + self.model_provider_id.as_str() + } + + fn generate_memories(&self) -> bool { + self.memories.generate_memories + } +} + +pub mod list { + pub use codex_rollout::list::*; +} + +pub(crate) mod metadata { + pub(crate) use codex_rollout::metadata::builder_from_items; +} + +pub mod policy { + pub use codex_rollout::policy::*; +} + +pub mod recorder { + pub use codex_rollout::recorder::*; +} + +pub mod session_index { + pub use codex_rollout::session_index::*; +} + +pub(crate) use crate::session_rollout_init_error::map_session_init_error; + +pub(crate) mod truncation { + pub(crate) use crate::thread_rollout_truncation::*; +} diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 37bc9065d6..61fdc8f6e1 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -2,16 +2,15 @@ use std::path::Component; use std::path::Path; use std::path::PathBuf; -use codex_apply_patch::ApplyPatchAction; -use codex_apply_patch::ApplyPatchFileChange; - -use crate::exec::SandboxType; -use crate::util::resolve_path; - use crate::protocol::AskForApproval; use crate::protocol::FileSystemSandboxPolicy; use crate::protocol::SandboxPolicy; +use crate::util::resolve_path; +use codex_apply_patch::ApplyPatchAction; +use codex_apply_patch::ApplyPatchFileChange; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; #[derive(Debug, PartialEq)] pub enum SafetyCheck { @@ -106,22 +105,6 @@ pub fn assess_patch_safety( } } -pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option { - if cfg!(target_os = "macos") { - Some(SandboxType::MacosSeatbelt) - } else if cfg!(target_os = "linux") { - Some(SandboxType::LinuxSeccomp) - } else if cfg!(target_os = "windows") { - if windows_sandbox_enabled { - Some(SandboxType::WindowsRestrictedToken) - } else { - None - } - } else { - None - } -} - fn is_write_patch_constrained_to_writable_paths( action: &ApplyPatchAction, file_system_sandbox_policy: &FileSystemSandboxPolicy, diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index 6a66d17dd0..57a99a8038 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -1,7 +1,7 @@ -use crate::exec::SandboxType; use crate::protocol::SandboxPolicy; -use crate::safety::get_platform_sandbox; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; pub(crate) fn sandbox_tag( policy: &SandboxPolicy, diff --git a/codex-rs/core/src/sandbox_tags_tests.rs b/codex-rs/core/src/sandbox_tags_tests.rs index 7084d5ff92..40062e031b 100644 --- a/codex-rs/core/src/sandbox_tags_tests.rs +++ b/codex-rs/core/src/sandbox_tags_tests.rs @@ -1,9 +1,9 @@ use super::sandbox_tag; -use crate::exec::SandboxType; use crate::protocol::SandboxPolicy; -use crate::safety::get_platform_sandbox; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::protocol::NetworkAccess; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; use pretty_assertions::assert_eq; #[test] diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 277ff2b241..26f4f733c6 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -1,65 +1,36 @@ /* Module: sandboxing -Build platform wrappers and produce ExecRequest for execution. Owns low-level -sandbox placement and transformation of portable CommandSpec into a -ready‑to‑spawn environment. +Core-owned adapter types for exec/runtime plumbing. Policy selection and +command transformation live in the codex-sandboxing crate; this module keeps +the exec-only metadata and translates transformed sandbox commands back into +ExecRequest for execution. */ -pub(crate) mod macos_permissions; - use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::StdoutStream; +use crate::exec::WindowsRestrictedTokenFilesystemOverlay; use crate::exec::execute_exec_request; -use crate::landlock::allow_network_for_proxy; -use crate::landlock::create_linux_sandbox_command_args_for_policies; -use crate::protocol::SandboxPolicy; -#[cfg(target_os = "macos")] -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -#[cfg(target_os = "macos")] -use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use crate::tools::sandboxing::SandboxablePreference; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::MacOsSeatbeltProfileExtensions; -use codex_protocol::models::NetworkPermissions; -use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::SandboxPermissions; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::ReadOnlyAccess; -use codex_utils_absolute_path::AbsolutePathBuf; -use dunce::canonicalize; -use macos_permissions::intersect_macos_seatbelt_profile_extensions; -use macos_permissions::merge_macos_seatbelt_profile_extensions; +use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxExecRequest; +use codex_sandboxing::SandboxType; use std::collections::HashMap; -use std::collections::HashSet; -use std::path::Path; use std::path::PathBuf; #[derive(Debug)] -pub struct CommandSpec { - pub program: String, - pub args: Vec, - pub cwd: PathBuf, - pub env: HashMap, - pub expiration: ExecExpiration, - pub capture_policy: ExecCapturePolicy, - pub sandbox_permissions: SandboxPermissions, - pub additional_permissions: Option, - pub justification: Option, +pub(crate) struct ExecOptions { + pub(crate) expiration: ExecExpiration, + pub(crate) capture_policy: ExecCapturePolicy, } #[derive(Debug)] @@ -73,658 +44,96 @@ pub struct ExecRequest { pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, - pub sandbox_permissions: SandboxPermissions, pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, - pub justification: Option, + pub(crate) windows_restricted_token_filesystem_overlay: + Option, pub arg0: Option, } -/// Bundled arguments for sandbox transformation. -/// -/// This keeps call sites self-documenting when several fields are optional. -pub(crate) struct SandboxTransformRequest<'a> { - pub spec: CommandSpec, - pub policy: &'a SandboxPolicy, - pub file_system_policy: &'a FileSystemSandboxPolicy, - pub network_policy: NetworkSandboxPolicy, - pub sandbox: SandboxType, - pub enforce_managed_network: bool, - // TODO(viyatb): Evaluate switching this to Option> - // to make shared ownership explicit across runtime/sandbox plumbing. - pub network: Option<&'a NetworkProxy>, - pub sandbox_policy_cwd: &'a Path, - #[cfg(target_os = "macos")] - pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, - pub codex_linux_sandbox_exe: Option<&'a PathBuf>, - pub use_legacy_landlock: bool, - pub windows_sandbox_level: WindowsSandboxLevel, - pub windows_sandbox_private_desktop: bool, -} - -pub enum SandboxPreference { - Auto, - Require, - Forbid, -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum SandboxTransformError { - #[error("missing codex-linux-sandbox executable path")] - MissingLinuxSandboxExecutable, - #[cfg(not(target_os = "macos"))] - #[error("seatbelt sandbox is only available on macOS")] - SeatbeltUnavailable, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct EffectiveSandboxPermissions { - pub(crate) sandbox_policy: SandboxPolicy, - pub(crate) macos_seatbelt_profile_extensions: Option, -} - -impl EffectiveSandboxPermissions { - pub(crate) fn new( - sandbox_policy: &SandboxPolicy, - macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, - additional_permissions: Option<&PermissionProfile>, - ) -> Self { - let Some(additional_permissions) = additional_permissions else { - return Self { - sandbox_policy: sandbox_policy.clone(), - macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(), - }; - }; - - Self { - sandbox_policy: sandbox_policy_with_additional_permissions( - sandbox_policy, - additional_permissions, - ), - macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions( - macos_seatbelt_profile_extensions, - additional_permissions.macos.as_ref(), - ), - } - } -} - -pub(crate) fn normalize_additional_permissions( - additional_permissions: PermissionProfile, -) -> Result { - let network = additional_permissions - .network - .filter(|network| !network.is_empty()); - let file_system = additional_permissions - .file_system - .map(|file_system| { - let read = file_system - .read - .map(|paths| normalize_permission_paths(paths, "file_system.read")); - let write = file_system - .write - .map(|paths| normalize_permission_paths(paths, "file_system.write")); - FileSystemPermissions { read, write } - }) - .filter(|file_system| !file_system.is_empty()); - let macos = additional_permissions.macos; - - Ok(PermissionProfile { - network, - file_system, - macos, - }) -} - -pub(crate) fn merge_permission_profiles( - base: Option<&PermissionProfile>, - permissions: Option<&PermissionProfile>, -) -> Option { - let Some(permissions) = permissions else { - return base.cloned(); - }; - - match base { - Some(base) => { - let network = match (base.network.as_ref(), permissions.network.as_ref()) { - ( - Some(NetworkPermissions { - enabled: Some(true), - }), - _, - ) - | ( - _, - Some(NetworkPermissions { - enabled: Some(true), - }), - ) => Some(NetworkPermissions { - enabled: Some(true), - }), - _ => None, - }; - let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) { - (Some(base), Some(permissions)) => Some(FileSystemPermissions { - read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()), - write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()), - }) - .filter(|file_system| !file_system.is_empty()), - (Some(base), None) => Some(base.clone()), - (None, Some(permissions)) => Some(permissions.clone()), - (None, None) => None, - }; - let macos = merge_macos_seatbelt_profile_extensions( - base.macos.as_ref(), - permissions.macos.as_ref(), - ); - - Some(PermissionProfile { - network, - file_system, - macos, - }) - .filter(|permissions| !permissions.is_empty()) - } - None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()), - } -} - -pub fn intersect_permission_profiles( - requested: PermissionProfile, - granted: PermissionProfile, -) -> PermissionProfile { - let file_system = requested - .file_system - .map(|requested_file_system| { - let granted_file_system = granted.file_system.unwrap_or_default(); - let read = requested_file_system - .read - .map(|requested_read| { - let granted_read = granted_file_system.read.unwrap_or_default(); - requested_read - .into_iter() - .filter(|path| granted_read.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - let write = requested_file_system - .write - .map(|requested_write| { - let granted_write = granted_file_system.write.unwrap_or_default(); - requested_write - .into_iter() - .filter(|path| granted_write.contains(path)) - .collect() - }) - .filter(|paths: &Vec<_>| !paths.is_empty()); - FileSystemPermissions { read, write } - }) - .filter(|file_system| !file_system.is_empty()); - let network = match (requested.network, granted.network) { - ( - Some(NetworkPermissions { - enabled: Some(true), - }), - Some(NetworkPermissions { - enabled: Some(true), - }), - ) => Some(NetworkPermissions { - enabled: Some(true), - }), - _ => None, - }; - - let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos); - - PermissionProfile { - network, - file_system, - macos, - } -} - -fn normalize_permission_paths( - paths: Vec, - _permission_kind: &str, -) -> Vec { - let mut out = Vec::with_capacity(paths.len()); - let mut seen = HashSet::new(); - - for path in paths { - let canonicalized = canonicalize(path.as_path()) - .ok() - .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) - .unwrap_or(path); - if seen.insert(canonicalized.clone()) { - out.push(canonicalized); - } - } - - out -} - -fn merge_permission_paths( - base: Option<&Vec>, - permissions: Option<&Vec>, -) -> Option> { - match (base, permissions) { - (Some(base), Some(permissions)) => { - let mut merged = Vec::with_capacity(base.len() + permissions.len()); - let mut seen = HashSet::with_capacity(base.len() + permissions.len()); - - for path in base.iter().chain(permissions.iter()) { - if seen.insert(path.clone()) { - merged.push(path.clone()); - } - } - - Some(merged).filter(|paths| !paths.is_empty()) - } - (Some(base), None) => Some(base.clone()), - (None, Some(permissions)) => Some(permissions.clone()), - (None, None) => None, - } -} - -fn dedup_absolute_paths(paths: Vec) -> Vec { - let mut out = Vec::with_capacity(paths.len()); - let mut seen = HashSet::new(); - for path in paths { - if seen.insert(path.to_path_buf()) { - out.push(path); - } - } - out -} - -fn additional_permission_roots( - additional_permissions: &PermissionProfile, -) -> (Vec, Vec) { - ( - dedup_absolute_paths( - additional_permissions - .file_system - .as_ref() - .and_then(|file_system| file_system.read.clone()) - .unwrap_or_default(), - ), - dedup_absolute_paths( - additional_permissions - .file_system - .as_ref() - .and_then(|file_system| file_system.write.clone()) - .unwrap_or_default(), - ), - ) -} - -fn merge_file_system_policy_with_additional_permissions( - file_system_policy: &FileSystemSandboxPolicy, - extra_reads: Vec, - extra_writes: Vec, -) -> FileSystemSandboxPolicy { - match file_system_policy.kind { - FileSystemSandboxKind::Restricted => { - let mut merged_policy = file_system_policy.clone(); - for path in extra_reads { - let entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Read, - }; - if !merged_policy.entries.contains(&entry) { - merged_policy.entries.push(entry); - } - } - for path in extra_writes { - let entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Write, - }; - if !merged_policy.entries.contains(&entry) { - merged_policy.entries.push(entry); - } - } - merged_policy - } - FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { - file_system_policy.clone() - } - } -} - -pub(crate) fn effective_file_system_sandbox_policy( - file_system_policy: &FileSystemSandboxPolicy, - additional_permissions: Option<&PermissionProfile>, -) -> FileSystemSandboxPolicy { - let Some(additional_permissions) = additional_permissions else { - return file_system_policy.clone(); - }; - - let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); - if extra_reads.is_empty() && extra_writes.is_empty() { - file_system_policy.clone() - } else { - merge_file_system_policy_with_additional_permissions( - file_system_policy, - extra_reads, - extra_writes, - ) - } -} - -fn merge_read_only_access_with_additional_reads( - read_only_access: &ReadOnlyAccess, - extra_reads: Vec, -) -> ReadOnlyAccess { - match read_only_access { - ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => { - let mut merged = readable_roots.clone(); - merged.extend(extra_reads); - ReadOnlyAccess::Restricted { - include_platform_defaults: *include_platform_defaults, - readable_roots: dedup_absolute_paths(merged), - } - } - } -} - -fn merge_network_access( - base_network_access: bool, - additional_permissions: &PermissionProfile, -) -> bool { - base_network_access - || additional_permissions - .network - .as_ref() - .and_then(|network| network.enabled) - .unwrap_or(false) -} - -fn sandbox_policy_with_additional_permissions( - sandbox_policy: &SandboxPolicy, - additional_permissions: &PermissionProfile, -) -> SandboxPolicy { - if additional_permissions.is_empty() { - return sandbox_policy.clone(); - } - - let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); - - match sandbox_policy { - SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess, - SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox { - network_access: if merge_network_access( - network_access.is_enabled(), - additional_permissions, - ) { - NetworkAccess::Enabled - } else { - NetworkAccess::Restricted - }, - }, - SandboxPolicy::WorkspaceWrite { - writable_roots, - read_only_access, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => { - let mut merged_writes = writable_roots.clone(); - merged_writes.extend(extra_writes); - SandboxPolicy::WorkspaceWrite { - writable_roots: dedup_absolute_paths(merged_writes), - read_only_access: merge_read_only_access_with_additional_reads( - read_only_access, - extra_reads, - ), - network_access: merge_network_access(*network_access, additional_permissions), - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - } - } - SandboxPolicy::ReadOnly { - access, - network_access, - } => { - if extra_writes.is_empty() { - SandboxPolicy::ReadOnly { - access: merge_read_only_access_with_additional_reads(access, extra_reads), - network_access: merge_network_access(*network_access, additional_permissions), - } - } else { - // todo(dylan) - for now, this grants more access than the request. We should restrict this, - // but we should add a new SandboxPolicy variant to handle this. While the feature is still - // UnderDevelopment, it's a useful approximation of the desired behavior. - SandboxPolicy::WorkspaceWrite { - writable_roots: dedup_absolute_paths(extra_writes), - read_only_access: merge_read_only_access_with_additional_reads( - access, - extra_reads, - ), - network_access: merge_network_access(*network_access, additional_permissions), - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - } - } - } -} - -pub(crate) fn should_require_platform_sandbox( - file_system_policy: &FileSystemSandboxPolicy, - network_policy: NetworkSandboxPolicy, - has_managed_network_requirements: bool, -) -> bool { - if has_managed_network_requirements { - return true; - } - - if !network_policy.is_enabled() { - return !matches!( - file_system_policy.kind, - FileSystemSandboxKind::ExternalSandbox - ); - } - - match file_system_policy.kind { - FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(), - FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false, - } -} - -#[derive(Default)] -pub struct SandboxManager; - -impl SandboxManager { - pub fn new() -> Self { - Self - } - - pub(crate) fn select_initial( - &self, - file_system_policy: &FileSystemSandboxPolicy, - network_policy: NetworkSandboxPolicy, - pref: SandboxablePreference, +impl ExecRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + command: Vec, + cwd: PathBuf, + env: HashMap, + network: Option, + expiration: ExecExpiration, + capture_policy: ExecCapturePolicy, + sandbox: SandboxType, windows_sandbox_level: WindowsSandboxLevel, - has_managed_network_requirements: bool, - ) -> SandboxType { - match pref { - SandboxablePreference::Forbid => SandboxType::None, - SandboxablePreference::Require => { - // Require a platform sandbox when available; on Windows this - // respects the experimental_windows_sandbox feature. - crate::safety::get_platform_sandbox( - windows_sandbox_level != WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None) - } - SandboxablePreference::Auto => { - if should_require_platform_sandbox( - file_system_policy, - network_policy, - has_managed_network_requirements, - ) { - crate::safety::get_platform_sandbox( - windows_sandbox_level != WindowsSandboxLevel::Disabled, - ) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } - } - } - - pub(crate) fn transform( - &self, - request: SandboxTransformRequest<'_>, - ) -> Result { - let SandboxTransformRequest { - mut spec, - policy, - file_system_policy, - network_policy, - sandbox, - enforce_managed_network, + windows_sandbox_private_desktop: bool, + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + arg0: Option, + ) -> Self { + Self { + command, + cwd, + env, network, - sandbox_policy_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, - codex_linux_sandbox_exe, - use_legacy_landlock, + expiration, + capture_policy, + sandbox, windows_sandbox_level, windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, + arg0, + } + } + + pub(crate) fn from_sandbox_exec_request( + request: SandboxExecRequest, + options: ExecOptions, + ) -> Self { + let SandboxExecRequest { + command, + cwd, + mut env, + network, + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + arg0, } = request; - #[cfg(not(target_os = "macos"))] - let macos_seatbelt_profile_extensions = None; - let additional_permissions = spec.additional_permissions.take(); - let EffectiveSandboxPermissions { - sandbox_policy: effective_policy, - macos_seatbelt_profile_extensions: _effective_macos_seatbelt_profile_extensions, - } = EffectiveSandboxPermissions::new( - policy, - macos_seatbelt_profile_extensions, - additional_permissions.as_ref(), - ); - let (effective_file_system_policy, effective_network_policy) = - if let Some(additional_permissions) = additional_permissions { - let (extra_reads, extra_writes) = - additional_permission_roots(&additional_permissions); - let file_system_sandbox_policy = - if extra_reads.is_empty() && extra_writes.is_empty() { - file_system_policy.clone() - } else { - merge_file_system_policy_with_additional_permissions( - file_system_policy, - extra_reads, - extra_writes, - ) - }; - let network_sandbox_policy = - if merge_network_access(network_policy.is_enabled(), &additional_permissions) { - NetworkSandboxPolicy::Enabled - } else { - NetworkSandboxPolicy::Restricted - }; - (file_system_sandbox_policy, network_sandbox_policy) - } else { - (file_system_policy.clone(), network_policy) - }; - let mut env = spec.env; - if !effective_network_policy.is_enabled() { + let ExecOptions { + expiration, + capture_policy, + } = options; + if !network_sandbox_policy.is_enabled() { env.insert( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), "1".to_string(), ); } - - let mut command = Vec::with_capacity(1 + spec.args.len()); - command.push(spec.program); - command.append(&mut spec.args); - - let (command, sandbox_env, arg0_override) = match sandbox { - SandboxType::None => (command, HashMap::new(), None), - #[cfg(target_os = "macos")] - SandboxType::MacosSeatbelt => { - let mut seatbelt_env = HashMap::new(); - seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); - let mut args = create_seatbelt_command_args_for_policies_with_extensions( - command.clone(), - &effective_file_system_policy, - effective_network_policy, - sandbox_policy_cwd, - enforce_managed_network, - network, - _effective_macos_seatbelt_profile_extensions.as_ref(), - ); - let mut full_command = Vec::with_capacity(1 + args.len()); - full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); - full_command.append(&mut args); - (full_command, seatbelt_env, None) - } - #[cfg(not(target_os = "macos"))] - SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), - SandboxType::LinuxSeccomp => { - let exe = codex_linux_sandbox_exe - .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; - let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); - let mut args = create_linux_sandbox_command_args_for_policies( - command.clone(), - spec.cwd.as_path(), - &effective_policy, - &effective_file_system_policy, - effective_network_policy, - sandbox_policy_cwd, - use_legacy_landlock, - allow_proxy_network, - ); - let mut full_command = Vec::with_capacity(1 + args.len()); - full_command.push(exe.to_string_lossy().to_string()); - full_command.append(&mut args); - ( - full_command, - HashMap::new(), - Some("codex-linux-sandbox".to_string()), - ) - } - // On Windows, the restricted token sandbox executes in-process via the - // codex-windows-sandbox crate. We leave the command unchanged here and - // branch during execution based on the sandbox type. - #[cfg(target_os = "windows")] - SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), - // When building for non-Windows targets, this variant is never constructed. - #[cfg(not(target_os = "windows"))] - SandboxType::WindowsRestrictedToken => (command, HashMap::new(), None), - }; - - env.extend(sandbox_env); - - Ok(ExecRequest { + #[cfg(target_os = "macos")] + if sandbox == SandboxType::MacosSeatbelt { + env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); + } + Self { command, - cwd: spec.cwd, + cwd, env, - network: network.cloned(), - expiration: spec.expiration, - capture_policy: spec.capture_policy, + network, + expiration, + capture_policy, sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_permissions: spec.sandbox_permissions, - sandbox_policy: effective_policy, - file_system_sandbox_policy: effective_file_system_policy, - network_sandbox_policy: effective_network_policy, - justification: spec.justification, - arg0: arg0_override, - }) - } - - pub fn denied(&self, sandbox: SandboxType, out: &ExecToolCallOutput) -> bool { - crate::exec::is_likely_sandbox_denied(sandbox, out) + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, + arg0, + } } } @@ -750,7 +159,3 @@ pub async fn execute_exec_request_with_after_spawn( let effective_policy = exec_request.sandbox_policy.clone(); execute_exec_request(exec_request, &effective_policy, stdout_stream, after_spawn).await } - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/seatbelt.rs b/codex-rs/core/src/seatbelt.rs index 2d1e0a7b5d..f81016a990 100644 --- a/codex-rs/core/src/seatbelt.rs +++ b/codex-rs/core/src/seatbelt.rs @@ -1,40 +1,19 @@ #![cfg(target_os = "macos")] -use codex_network_proxy::NetworkProxy; -use codex_network_proxy::PROXY_URL_ENV_KEYS; -use codex_network_proxy::has_proxy_url_env_vars; -use codex_network_proxy::proxy_url_env_value; -use codex_utils_absolute_path::AbsolutePathBuf; -use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::collections::HashMap; -use std::ffi::CStr; -use std::path::Path; -use std::path::PathBuf; -use tokio::process::Child; -use tracing::warn; -use url::Url; - use crate::protocol::SandboxPolicy; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; -use crate::seatbelt_permissions::build_seatbelt_extensions; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::SpawnChildRequest; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; +use codex_network_proxy::NetworkProxy; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; - -const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); -const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); -const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = - include_str!("restricted_read_only_platform_defaults.sbpl"); - -/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` -/// to defend against an attacker trying to inject a malicious version on the -/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker -/// already has root access. -pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use tokio::process::Child; pub async fn spawn_command_under_seatbelt( command: Vec, @@ -45,12 +24,14 @@ pub async fn spawn_command_under_seatbelt( network: Option<&NetworkProxy>, mut env: HashMap, ) -> std::io::Result { - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_for_policies_with_extensions( command, - sandbox_policy, + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), + NetworkSandboxPolicy::from(sandbox_policy), sandbox_policy_cwd, /*enforce_managed_network*/ false, network, + /*extensions*/ None, ); let arg0 = None; env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string()); @@ -66,529 +47,3 @@ pub async fn spawn_command_under_seatbelt( }) .await } - -fn is_loopback_host(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" -} - -fn proxy_scheme_default_port(scheme: &str) -> u16 { - match scheme { - "https" => 443, - "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, - _ => 80, - } -} - -fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { - let mut ports = BTreeSet::new(); - for key in PROXY_URL_ENV_KEYS { - let Some(proxy_url) = proxy_url_env_value(env, key) else { - continue; - }; - let trimmed = proxy_url.trim(); - if trimmed.is_empty() { - continue; - } - - let candidate = if trimmed.contains("://") { - trimmed.to_string() - } else { - format!("http://{trimmed}") - }; - let Ok(parsed) = Url::parse(&candidate) else { - continue; - }; - let Some(host) = parsed.host_str() else { - continue; - }; - if !is_loopback_host(host) { - continue; - } - - let scheme = parsed.scheme().to_ascii_lowercase(); - let port = parsed - .port() - .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); - ports.insert(port); - } - ports.into_iter().collect() -} - -#[derive(Debug, Default)] -struct ProxyPolicyInputs { - ports: Vec, - has_proxy_config: bool, - allow_local_binding: bool, - unix_domain_socket_policy: UnixDomainSocketPolicy, -} - -#[derive(Debug, Clone)] -// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. -enum UnixDomainSocketPolicy { - AllowAll, - Restricted { allowed: Vec }, -} - -impl Default for UnixDomainSocketPolicy { - fn default() -> Self { - Self::Restricted { allowed: vec![] } - } -} - -#[derive(Debug, Clone)] -struct UnixSocketPathParam { - index: usize, - path: AbsolutePathBuf, -} - -fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { - if let Some(network) = network { - let mut env = HashMap::new(); - network.apply_to_env(&mut env); - let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { - UnixDomainSocketPolicy::AllowAll - } else { - let allowed = network - .allow_unix_sockets() - .iter() - .filter_map( - |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { - Some(path) => Some((path.to_string_lossy().to_string(), path)), - None => { - warn!( - "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" - ); - None - } - }, - ) - .collect::>() - .into_values() - .collect(); - UnixDomainSocketPolicy::Restricted { allowed } - }; - return ProxyPolicyInputs { - ports: proxy_loopback_ports_from_env(&env), - has_proxy_config: has_proxy_url_env_vars(&env), - allow_local_binding: network.allow_local_binding(), - unix_domain_socket_policy, - }; - } - - ProxyPolicyInputs::default() -} - -fn normalize_path_for_sandbox(path: &Path) -> Option { - // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current - // working directory, so keep the explicit check to avoid silently accepting relative entries. - if !path.is_absolute() { - return None; - } - - let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; - let normalized_path = absolute_path - .as_path() - .canonicalize() - .ok() - .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); - normalized_path.or(Some(absolute_path)) -} - -fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { - let mut deduped_paths: BTreeMap = BTreeMap::new(); - let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { - return vec![]; - }; - for path in allowed { - deduped_paths - .entry(path.to_string_lossy().to_string()) - .or_insert_with(|| path.clone()); - } - - deduped_paths - .into_values() - .enumerate() - .map(|(index, path)| UnixSocketPathParam { index, path }) - .collect() -} - -fn unix_socket_path_param_key(index: usize) -> String { - format!("UNIX_SOCKET_PATH_{index}") -} - -fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { - unix_socket_path_params(proxy) - .into_iter() - .map(|param| { - ( - unix_socket_path_param_key(param.index), - param.path.into_path_buf(), - ) - }) - .collect() -} - -/// Returns zero or more complete Seatbelt policy lines for unix socket rules. -/// When non-empty, the returned string is newline-terminated so callers can -/// append it directly to larger policy blocks. -fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { - let socket_params = unix_socket_path_params(proxy); - let has_unix_socket_access = matches!( - proxy.unix_domain_socket_policy, - UnixDomainSocketPolicy::AllowAll - ) || !socket_params.is_empty(); - if !has_unix_socket_access { - return String::new(); - } - - let mut policy = String::new(); - policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n"); - if matches!( - proxy.unix_domain_socket_policy, - UnixDomainSocketPolicy::AllowAll - ) { - // Keep AllowAll genuinely broad here; path qualifiers look narrower - // without a clear macOS behavioral benefit. - policy.push_str("(allow network-bind (local unix-socket))\n"); - policy.push_str("(allow network-outbound (remote unix-socket))\n"); - return policy; - } - - for param in socket_params { - let key = unix_socket_path_param_key(param.index); - // Use subpath so allowlists cover sockets created beneath approved directories. - policy.push_str(&format!( - "(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n" - )); - policy.push_str(&format!( - "(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n" - )); - } - policy -} - -#[cfg_attr(not(test), allow(dead_code))] -fn dynamic_network_policy( - sandbox_policy: &SandboxPolicy, - enforce_managed_network: bool, - proxy: &ProxyPolicyInputs, -) -> String { - dynamic_network_policy_for_network( - NetworkSandboxPolicy::from(sandbox_policy), - enforce_managed_network, - proxy, - ) -} - -fn dynamic_network_policy_for_network( - network_policy: NetworkSandboxPolicy, - enforce_managed_network: bool, - proxy: &ProxyPolicyInputs, -) -> String { - let should_use_restricted_network_policy = - !proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network; - if should_use_restricted_network_policy { - let mut policy = String::new(); - if proxy.allow_local_binding { - policy.push_str("; allow loopback local binding and loopback traffic\n"); - policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); - policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); - policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n"); - } - for port in &proxy.ports { - policy.push_str(&format!( - "(allow network-outbound (remote ip \"localhost:{port}\"))\n" - )); - } - let unix_socket_policy = unix_socket_policy(proxy); - if !unix_socket_policy.is_empty() { - policy.push_str("; allow unix domain sockets for local IPC\n"); - policy.push_str(&unix_socket_policy); - } - return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); - } - - if proxy.has_proxy_config { - // Proxy configuration is present but we could not infer any valid loopback endpoints. - // Fail closed to avoid silently widening network access in proxy-enforced sessions. - return String::new(); - } - - if enforce_managed_network { - // Managed network requirements are active but no usable proxy endpoints - // are available. Fail closed for network access. - return String::new(); - } - - if network_policy.is_enabled() { - // No proxy env is configured: retain the existing full-network behavior. - format!( - "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" - ) - } else { - String::new() - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn create_seatbelt_command_args( - command: Vec, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, -) -> Vec { - create_seatbelt_command_args_with_extensions( - command, - sandbox_policy, - sandbox_policy_cwd, - enforce_managed_network, - network, - /*extensions*/ None, - ) -} - -fn root_absolute_path() -> AbsolutePathBuf { - match AbsolutePathBuf::from_absolute_path(Path::new("/")) { - Ok(path) => path, - Err(err) => panic!("root path must be absolute: {err}"), - } -} - -#[derive(Debug, Clone)] -struct SeatbeltAccessRoot { - root: AbsolutePathBuf, - excluded_subpaths: Vec, -} - -fn build_seatbelt_access_policy( - action: &str, - param_prefix: &str, - roots: Vec, -) -> (String, Vec<(String, PathBuf)>) { - let mut policy_components = Vec::new(); - let mut params = Vec::new(); - - for (index, access_root) in roots.into_iter().enumerate() { - let root = - normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root); - let root_param = format!("{param_prefix}_{index}"); - params.push((root_param.clone(), root.into_path_buf())); - - if access_root.excluded_subpaths.is_empty() { - policy_components.push(format!("(subpath (param \"{root_param}\"))")); - continue; - } - - let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")]; - for (excluded_index, excluded_subpath) in - access_root.excluded_subpaths.into_iter().enumerate() - { - let excluded_subpath = - normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath); - let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}"); - params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); - require_parts.push(format!( - "(require-not (subpath (param \"{excluded_param}\")))" - )); - } - policy_components.push(format!("(require-all {} )", require_parts.join(" "))); - } - - if policy_components.is_empty() { - (String::new(), Vec::new()) - } else { - ( - format!("(allow {action}\n{}\n)", policy_components.join(" ")), - params, - ) - } -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn create_seatbelt_command_args_with_extensions( - command: Vec, - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - create_seatbelt_command_args_for_policies_with_extensions( - command, - &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), - NetworkSandboxPolicy::from(sandbox_policy), - sandbox_policy_cwd, - enforce_managed_network, - network, - extensions, - ) -} - -pub fn create_seatbelt_command_args_for_policies_with_extensions( - command: Vec, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, - sandbox_policy_cwd: &Path, - enforce_managed_network: bool, - network: Option<&NetworkProxy>, - extensions: Option<&MacOsSeatbeltProfileExtensions>, -) -> Vec { - let unreadable_roots = - file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); - let (file_write_policy, file_write_dir_params) = - if file_system_sandbox_policy.has_full_disk_write_access() { - if unreadable_roots.is_empty() { - // Allegedly, this is more permissive than `(allow file-write*)`. - ( - r#"(allow file-write* (regex #"^/"))"#.to_string(), - Vec::new(), - ) - } else { - build_seatbelt_access_policy( - "file-write*", - "WRITABLE_ROOT", - vec![SeatbeltAccessRoot { - root: root_absolute_path(), - excluded_subpaths: unreadable_roots.clone(), - }], - ) - } - } else { - build_seatbelt_access_policy( - "file-write*", - "WRITABLE_ROOT", - file_system_sandbox_policy - .get_writable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(|root| SeatbeltAccessRoot { - root: root.root, - excluded_subpaths: root.read_only_subpaths, - }) - .collect(), - ) - }; - - let (file_read_policy, file_read_dir_params) = - if file_system_sandbox_policy.has_full_disk_read_access() { - if unreadable_roots.is_empty() { - ( - "; allow read-only file operations\n(allow file-read*)".to_string(), - Vec::new(), - ) - } else { - let (policy, params) = build_seatbelt_access_policy( - "file-read*", - "READABLE_ROOT", - vec![SeatbeltAccessRoot { - root: root_absolute_path(), - excluded_subpaths: unreadable_roots, - }], - ); - ( - format!("; allow read-only file operations\n{policy}"), - params, - ) - } - } else { - let (policy, params) = build_seatbelt_access_policy( - "file-read*", - "READABLE_ROOT", - file_system_sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(|root| SeatbeltAccessRoot { - excluded_subpaths: unreadable_roots - .iter() - .filter(|path| path.as_path().starts_with(root.as_path())) - .cloned() - .collect(), - root, - }) - .collect(), - ); - if policy.is_empty() { - (String::new(), params) - } else { - ( - format!("; allow read-only file operations\n{policy}"), - params, - ) - } - }; - - let proxy = proxy_policy_inputs(network); - let network_policy = - dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); - let seatbelt_extensions = extensions.map_or_else( - || { - // Backward-compatibility default when no extension profile is provided. - build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) - }, - build_seatbelt_extensions, - ); - - let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); - let mut policy_sections = vec![ - MACOS_SEATBELT_BASE_POLICY.to_string(), - file_read_policy, - file_write_policy, - network_policy, - ]; - if include_platform_defaults { - policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); - } - if !seatbelt_extensions.policy.is_empty() { - policy_sections.push(seatbelt_extensions.policy.clone()); - } - - let full_policy = policy_sections.join("\n"); - - let dir_params = [ - file_read_dir_params, - file_write_dir_params, - macos_dir_params(), - unix_socket_dir_params(&proxy), - seatbelt_extensions.dir_params, - ] - .concat(); - - let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; - let definition_args = dir_params - .into_iter() - .map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())); - seatbelt_args.extend(definition_args); - seatbelt_args.push("--".to_string()); - seatbelt_args.extend(command); - seatbelt_args -} - -/// Wraps libc::confstr to return a String. -fn confstr(name: libc::c_int) -> Option { - let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; - let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; - if len == 0 { - return None; - } - // confstr guarantees NUL-termination when len > 0. - let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; - cstr.to_str().ok().map(ToString::to_string) -} - -/// Wraps confstr to return a canonicalized PathBuf. -fn confstr_path(name: libc::c_int) -> Option { - let s = confstr(name)?; - let path = PathBuf::from(s); - path.canonicalize().ok().or(Some(path)) -} - -fn macos_dir_params() -> Vec<(String, PathBuf)> { - if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { - return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; - } - vec![] -} - -#[cfg(test)] -#[path = "seatbelt_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/rollout/error.rs b/codex-rs/core/src/session_rollout_init_error.rs similarity index 100% rename from codex-rs/core/src/rollout/error.rs rename to codex-rs/core/src/session_rollout_init_error.rs diff --git a/codex-rs/core/src/skills/config_rules.rs b/codex-rs/core/src/skills/config_rules.rs new file mode 100644 index 0000000000..fa1be878bb --- /dev/null +++ b/codex-rs/core/src/skills/config_rules.rs @@ -0,0 +1,135 @@ +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_app_server_protocol::ConfigLayerSource; +use tracing::warn; + +use crate::config::types::SkillConfig; +use crate::config::types::SkillsConfig; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; +use crate::skills::SkillMetadata; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(crate) enum SkillConfigRuleSelector { + Name(String), + Path(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct SkillConfigRule { + pub selector: SkillConfigRuleSelector, + pub enabled: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub(crate) struct SkillConfigRules { + pub entries: Vec, +} + +pub(crate) fn skill_config_rules_from_stack( + config_layer_stack: &ConfigLayerStack, +) -> SkillConfigRules { + let mut entries = Vec::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(skills_value) = layer.config.get("skills") else { + continue; + }; + let skills: SkillsConfig = match skills_value.clone().try_into() { + Ok(skills) => skills, + Err(err) => { + warn!("invalid skills config: {err}"); + continue; + } + }; + + for entry in skills.config { + let Some(selector) = skill_config_rule_selector(&entry) else { + continue; + }; + // Preserve layer order so a later name selector can override an earlier path selector + // for the same loaded skill. + entries.retain(|entry: &SkillConfigRule| entry.selector != selector); + entries.push(SkillConfigRule { + selector, + enabled: entry.enabled, + }); + } + } + + SkillConfigRules { entries } +} + +pub(crate) fn resolve_disabled_skill_paths( + skills: &[SkillMetadata], + rules: &SkillConfigRules, +) -> HashSet { + let mut disabled_paths = HashSet::new(); + + for entry in &rules.entries { + match &entry.selector { + SkillConfigRuleSelector::Path(path) => { + if entry.enabled { + disabled_paths.remove(path); + } else { + disabled_paths.insert(path.clone()); + } + } + SkillConfigRuleSelector::Name(name) => { + for path in skills + .iter() + .filter(|skill| skill.name == *name) + .map(|skill| skill.path_to_skills_md.clone()) + { + if entry.enabled { + disabled_paths.remove(&path); + } else { + disabled_paths.insert(path); + } + } + } + } + } + + disabled_paths +} + +fn skill_config_rule_selector(entry: &SkillConfig) -> Option { + match (entry.path.as_ref(), entry.name.as_deref()) { + (Some(path), None) => Some(SkillConfigRuleSelector::Path(normalize_rule_path( + path.as_path(), + ))), + (None, Some(name)) => { + let name = name.trim(); + if name.is_empty() { + warn!("ignoring empty skills.config name override"); + None + } else { + Some(SkillConfigRuleSelector::Name(name.to_string())) + } + } + (Some(_), Some(_)) => { + warn!("ignoring skills.config entry with both path and name selectors"); + None + } + (None, None) => { + warn!("ignoring skills.config entry without a path or name selector"); + None + } + } +} + +fn normalize_rule_path(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index 982780f821..446580b6c7 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -5,7 +5,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; -use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -16,12 +15,14 @@ use tracing::warn; use crate::config::Config; use crate::config::types::SkillsConfig; use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::plugins::PluginsManager; use crate::skills::SkillLoadOutcome; use crate::skills::build_implicit_skill_path_indexes; +use crate::skills::config_rules::SkillConfigRules; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use crate::skills::loader::skill_roots; @@ -81,15 +82,13 @@ impl SkillsManager { /// to share a directory. pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome { let roots = self.skill_roots_for_config(config); - let cache_key = config_skills_cache_key(&roots, &config.config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + let cache_key = config_skills_cache_key(&roots, &skill_config_rules); if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { return outcome; } - let outcome = crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack), - self.restriction_product, - ); + let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_config .write() @@ -192,7 +191,8 @@ impl SkillsManager { scope: SkillScope::User, }), ); - let outcome = self.build_skill_outcome(roots, &config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack); + let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_cwd .write() @@ -204,12 +204,14 @@ impl SkillsManager { fn build_skill_outcome( &self, roots: Vec, - config_layer_stack: &crate::config_loader::ConfigLayerStack, + skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { - crate::skills::filter_skill_load_outcome_for_product( - finalize_skill_outcome(load_skills_from_roots(roots), config_layer_stack), + let outcome = crate::skills::filter_skill_load_outcome_for_product( + load_skills_from_roots(roots), self.restriction_product, - ) + ); + let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules); + finalize_skill_outcome(outcome, disabled_paths) } pub fn clear_cache(&self) { @@ -256,7 +258,7 @@ impl SkillsManager { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { roots: Vec<(PathBuf, u8)>, - disabled_paths: Vec, + skill_config_rules: SkillConfigRules, } pub(crate) fn bundled_skills_enabled_from_stack( @@ -281,53 +283,10 @@ pub(crate) fn bundled_skills_enabled_from_stack( skills.bundled.unwrap_or_default().enabled } -fn disabled_paths_from_stack( - config_layer_stack: &crate::config_loader::ConfigLayerStack, -) -> HashSet { - let mut configs = HashMap::new(); - for layer in config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) { - if !matches!( - layer.name, - ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags - ) { - continue; - } - - let Some(skills_value) = layer.config.get("skills") else { - continue; - }; - let skills: SkillsConfig = match skills_value.clone().try_into() { - Ok(skills) => skills, - Err(err) => { - warn!("invalid skills config: {err}"); - continue; - } - }; - - for entry in skills.config { - let path = normalize_override_path(entry.path.as_path()); - configs.insert(path, entry.enabled); - } - } - - configs - .into_iter() - .filter_map(|(path, enabled)| (!enabled).then_some(path)) - .collect() -} - fn config_skills_cache_key( roots: &[SkillRoot], - config_layer_stack: &crate::config_loader::ConfigLayerStack, + skill_config_rules: &SkillConfigRules, ) -> ConfigSkillsCacheKey { - let mut disabled_paths: Vec = disabled_paths_from_stack(config_layer_stack) - .into_iter() - .collect(); - disabled_paths.sort_unstable(); - ConfigSkillsCacheKey { roots: roots .iter() @@ -341,15 +300,15 @@ fn config_skills_cache_key( (root.path.clone(), scope_rank) }) .collect(), - disabled_paths, + skill_config_rules: skill_config_rules.clone(), } } fn finalize_skill_outcome( mut outcome: SkillLoadOutcome, - config_layer_stack: &crate::config_loader::ConfigLayerStack, + disabled_paths: HashSet, ) -> SkillLoadOutcome { - outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack); + outcome.disabled_paths = disabled_paths; let (by_scripts_dir, by_doc_path) = build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation()); outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir); @@ -357,10 +316,6 @@ fn finalize_skill_outcome( outcome } -fn normalize_override_path(path: &Path) -> PathBuf { - dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec { let mut normalized: Vec = extra_user_roots .iter() diff --git a/codex-rs/core/src/skills/manager_tests.rs b/codex-rs/core/src/skills/manager_tests.rs index cb3c48ed7f..d9e5859f4e 100644 --- a/codex-rs/core/src/skills/manager_tests.rs +++ b/codex-rs/core/src/skills/manager_tests.rs @@ -5,6 +5,10 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirementsToml; use crate::plugins::PluginsManager; +use crate::skills::SkillMetadata; +use crate::skills::config_rules::resolve_disabled_skill_paths; +use crate::skills::config_rules::skill_config_rules_from_stack; +use codex_app_server_protocol::ConfigLayerSource; use pretty_assertions::assert_eq; use std::fs; use std::path::PathBuf; @@ -17,6 +21,49 @@ fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &s fs::write(skill_dir.join("SKILL.md"), content).unwrap(); } +fn write_plugin_skill( + codex_home: &TempDir, + marketplace: &str, + plugin_name: &str, + dir: &str, + name: &str, + description: &str, +) -> PathBuf { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace) + .join(plugin_name) + .join("local"); + let skill_dir = plugin_root.join("skills").join(dir); + fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}"}}"#), + ) + .unwrap(); + let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n"); + let skill_path = skill_dir.join("SKILL.md"); + fs::write(&skill_path, content).unwrap(); + skill_path +} + +fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: "test".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + permission_profile: None, + managed_network_override: None, + path_to_skills_md: path, + scope: SkillScope::User, + } +} + #[test] fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -68,6 +115,68 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { assert_eq!(outcome2.skills, outcome1.skills); } +#[tokio::test] +async fn skills_for_config_disables_plugin_skills_by_name() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let skill_path = write_plugin_skill( + &codex_home, + "test", + "sample", + "sample-search", + "sample-search", + "search sample data", + ); + fs::write( + codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[[skills.config]] +name = "sample:sample-search" +enabled = false + +[plugins."sample@test"] +enabled = true +"#, + ) + .expect("write config"); + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .expect("load config"); + + let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); + let skills_manager = SkillsManager::new( + codex_home.path().to_path_buf(), + plugins_manager, + config.bundled_skills_enabled(), + ); + + let outcome = skills_manager.skills_for_config(&config); + let skill = outcome + .skills + .iter() + .find(|skill| skill.name == "sample:sample-search") + .expect("plugin skill should load"); + let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); + + assert_eq!(skill.path_to_skills_md, skill_path); + assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md)); + assert!( + !outcome + .allowed_skills_for_implicit_invocation() + .iter() + .any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md) + ); +} + #[tokio::test] async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -282,9 +391,10 @@ fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() { #[cfg_attr(windows, ignore)] #[test] -fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() { +fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { let tempdir = tempfile::tempdir().expect("tempdir"); let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("demo-skill", skill_path.clone()); let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( @@ -316,14 +426,19 @@ enabled = true ) .expect("valid config layer stack"); - assert_eq!(disabled_paths_from_stack(&stack), HashSet::new()); + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); } #[cfg_attr(windows, ignore)] #[test] -fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() { +fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() { let tempdir = tempfile::tempdir().expect("tempdir"); let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("demo-skill", skill_path.clone()); let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( @@ -355,12 +470,88 @@ enabled = false ) .expect("valid config layer stack"); + let skill_config_rules = skill_config_rules_from_stack(&stack); assert_eq!( - disabled_paths_from_stack(&stack), + resolve_disabled_skill_paths(&[skill], &skill_config_rules), HashSet::from([skill_path]) ); } +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_disables_matching_name_selectors() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str( + r#"[[skills.config]] +name = "github:yeet" +enabled = false +"#, + ) + .expect("user layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::from([skill_path]) + ); +} + +#[cfg_attr(windows, ignore)] +#[test] +fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md"); + let skill = test_skill("github:yeet", skill_path.clone()); + let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) + .expect("user config path should be absolute"); + let user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + toml::from_str(&format!( + r#"[[skills.config]] +path = "{}" +enabled = false +"#, + skill_path.display() + )) + .expect("user layer toml"), + ); + let session_layer = ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str( + r#"[[skills.config]] +name = "github:yeet" +enabled = true +"#, + ) + .expect("session layer toml"), + ); + let stack = ConfigLayerStack::new( + vec![user_layer, session_layer], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let skill_config_rules = skill_config_rules_from_stack(&stack); + assert_eq!( + resolve_disabled_skill_paths(&[skill], &skill_config_rules), + HashSet::new() + ); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() { diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 4138ecbb86..ad76cd9da0 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod config_rules; mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; diff --git a/codex-rs/core/src/skills_watcher.rs b/codex-rs/core/src/skills_watcher.rs new file mode 100644 index 0000000000..fc0b86d90d --- /dev/null +++ b/codex-rs/core/src/skills_watcher.rs @@ -0,0 +1,116 @@ +//! Skills-specific watcher built on top of the generic [`FileWatcher`]. + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use tokio::runtime::Handle; +use tokio::sync::broadcast; +use tracing::warn; + +use crate::config::Config; +use crate::file_watcher::FileWatcher; +use crate::file_watcher::FileWatcherSubscriber; +use crate::file_watcher::Receiver; +use crate::file_watcher::ThrottledWatchReceiver; +use crate::file_watcher::WatchPath; +use crate::file_watcher::WatchRegistration; +use crate::skills::SkillsManager; + +#[cfg(not(test))] +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); +#[cfg(test)] +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillsWatcherEvent { + SkillsChanged { paths: Vec }, +} + +pub(crate) struct SkillsWatcher { + subscriber: FileWatcherSubscriber, + tx: broadcast::Sender, +} + +impl SkillsWatcher { + pub(crate) fn new(file_watcher: &Arc) -> Self { + let (subscriber, rx) = file_watcher.add_subscriber(); + let (tx, _) = broadcast::channel(128); + let skills_watcher = Self { + subscriber, + tx: tx.clone(), + }; + Self::spawn_event_loop(rx, tx); + skills_watcher + } + + pub(crate) fn noop() -> Self { + Self::new(&Arc::new(FileWatcher::noop())) + } + + pub(crate) fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + pub(crate) fn register_config( + &self, + config: &Config, + skills_manager: &SkillsManager, + ) -> WatchRegistration { + let roots = skills_manager + .skill_roots_for_config(config) + .into_iter() + .map(|root| WatchPath { + path: root.path, + recursive: true, + }) + .collect(); + self.subscriber.register_paths(roots) + } + + fn spawn_event_loop(rx: Receiver, tx: broadcast::Sender) { + let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL); + if let Ok(handle) = Handle::try_current() { + handle.spawn(async move { + while let Some(event) = rx.recv().await { + let _ = tx.send(SkillsWatcherEvent::SkillsChanged { paths: event.paths }); + } + }); + } else { + warn!("skills watcher listener skipped: no Tokio runtime available"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tokio::time::Duration; + use tokio::time::timeout; + + #[tokio::test] + async fn forwards_file_watcher_events() { + let file_watcher = Arc::new(FileWatcher::noop()); + let skills_watcher = SkillsWatcher::new(&file_watcher); + let mut rx = skills_watcher.subscribe(); + let _registration = skills_watcher + .subscriber + .register_path(PathBuf::from("/tmp/skill"), true); + + file_watcher + .send_paths_for_test(vec![PathBuf::from("/tmp/skill/SKILL.md")]) + .await; + + let event = timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("skills watcher event") + .expect("broadcast recv"); + assert_eq!( + event, + SkillsWatcherEvent::SkillsChanged { + paths: vec![PathBuf::from("/tmp/skill/SKILL.md")], + } + ); + } +} diff --git a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap index 90bb82d409..d55f29a2a7 100644 --- a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap +++ b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -1,17 +1,15 @@ --- source: core/src/codex_tests.rs -assertion_line: 1282 +assertion_line: 1254 expression: snapshot --- -Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode. +Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode. ## First Forked Turn Request 00:message/developer: 01:message/user:> 02:message/user:fork seed -03:message/developer: -04:message/user:> -05:message/developer[2]: +03:message/developer[2]: [01] [02] Fork turn collaboration instructions. -06:message/user:after fork +04:message/user:after fork diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index ceab67f1c7..f12f7ef971 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -8,12 +8,12 @@ use crate::analytics_client::AnalyticsEventsClient; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; -use crate::file_watcher::FileWatcher; use crate::mcp::McpManager; use crate::mcp_connection_manager::McpConnectionManager; use crate::models_manager::manager::ModelsManager; use crate::plugins::PluginsManager; use crate::skills::SkillsManager; +use crate::skills_watcher::SkillsWatcher; use crate::state_db::StateDbHandle; use crate::tools::code_mode::CodeModeService; use crate::tools::network_approval::NetworkApprovalService; @@ -54,7 +54,7 @@ pub(crate) struct SessionServices { pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, - pub(crate) file_watcher: Arc, + pub(crate) skills_watcher: Arc, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, pub(crate) network_approval: Arc, diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 563e8b3403..1a14236163 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -2,6 +2,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; +use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::collections::HashSet; @@ -11,10 +12,9 @@ use crate::context_manager::ContextManager; use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; -use crate::sandboxing::merge_permission_profiles; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; -use crate::truncate::TruncationPolicy; use codex_protocol::protocol::TurnContextItem; +use codex_utils_output_truncation::TruncationPolicy; /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index a6ae1ba8ca..ebfa5a8bb8 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,5 +1,6 @@ //! Turn-scoped state and active turn metadata scaffolding. +use codex_sandboxing::policy_transforms::merge_permission_profiles; use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; @@ -19,7 +20,6 @@ use tokio::sync::oneshot; use crate::codex::TurnContext; use crate::protocol::ReviewDecision; use crate::protocol::TokenUsage; -use crate::sandboxing::merge_permission_profiles; use crate::tasks::SessionTask; use codex_protocol::models::PermissionProfile; @@ -198,6 +198,10 @@ impl TurnState { } } + pub(crate) fn pending_input_snapshot(&self) -> Vec { + self.pending_input.clone() + } + pub(crate) fn has_pending_input(&self) -> bool { !self.pending_input.is_empty() } diff --git a/codex-rs/core/src/state_db_bridge.rs b/codex-rs/core/src/state_db_bridge.rs new file mode 100644 index 0000000000..f073c498b5 --- /dev/null +++ b/codex-rs/core/src/state_db_bridge.rs @@ -0,0 +1,21 @@ +use codex_rollout::state_db as rollout_state_db; +pub use codex_rollout::state_db::StateDbHandle; +pub use codex_rollout::state_db::apply_rollout_items; +pub use codex_rollout::state_db::find_rollout_path_by_id; +pub use codex_rollout::state_db::get_dynamic_tools; +pub use codex_rollout::state_db::list_thread_ids_db; +pub use codex_rollout::state_db::list_threads_db; +pub use codex_rollout::state_db::mark_thread_memory_mode_polluted; +pub use codex_rollout::state_db::normalize_cwd_for_state_db; +pub use codex_rollout::state_db::open_if_present; +pub use codex_rollout::state_db::persist_dynamic_tools; +pub use codex_rollout::state_db::read_repair_rollout_path; +pub use codex_rollout::state_db::reconcile_rollout; +pub use codex_rollout::state_db::touch_thread_updated_at; +pub use codex_state::LogEntry; + +use crate::config::Config; + +pub async fn get_state_db(config: &Config) -> Option { + rollout_state_db::get_state_db(config).await +} diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs index 01aa9758f7..6028c311cc 100644 --- a/codex-rs/core/src/tasks/ghost_snapshot.rs +++ b/codex-rs/core/src/tasks/ghost_snapshot.rs @@ -5,10 +5,10 @@ use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use async_trait::async_trait; -use codex_git::CreateGhostCommitOptions; -use codex_git::GhostSnapshotReport; -use codex_git::GitToolingError; -use codex_git::create_ghost_commit_with_report; +use codex_git_utils::CreateGhostCommitOptions; +use codex_git_utils::GhostSnapshotReport; +use codex_git_utils::GitToolingError; +use codex_git_utils::create_ghost_commit_with_report; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use codex_utils_readiness::Readiness; diff --git a/codex-rs/core/src/tasks/ghost_snapshot_tests.rs b/codex-rs/core/src/tasks/ghost_snapshot_tests.rs index 1884a9bd05..68bcbf9f22 100644 --- a/codex-rs/core/src/tasks/ghost_snapshot_tests.rs +++ b/codex-rs/core/src/tasks/ghost_snapshot_tests.rs @@ -1,5 +1,5 @@ use super::*; -use codex_git::LargeUntrackedDir; +use codex_git_utils::LargeUntrackedDir; use pretty_assertions::assert_eq; use std::path::PathBuf; diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b91e28319b..58b0752dd6 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -22,6 +22,7 @@ use tracing::warn; use crate::AuthManager; use crate::codex::Session; use crate::codex::TurnContext; +use crate::contextual_user_message::TURN_ABORTED_CLOSE_TAG; use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG; use crate::hook_runtime::PendingInputHookDisposition; use crate::hook_runtime::inspect_pending_input; @@ -29,7 +30,6 @@ use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::models_manager::manager::ModelsManager; use crate::protocol::EventMsg; -use crate::protocol::TokenUsage; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; use crate::protocol::TurnCompleteEvent; @@ -60,6 +60,22 @@ pub(crate) use user_shell::execute_user_shell_command; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; +/// Shared model-visible marker used by both the real interrupt path and +/// interrupted fork snapshots. +pub(crate) fn interrupted_turn_history_marker() -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: format!( + "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n{TURN_ABORTED_CLOSE_TAG}" + ), + }], + end_turn: None, + phase: None, + } +} + fn emit_turn_network_proxy_metric( session_telemetry: &SessionTelemetry, network_proxy_active: bool, @@ -175,11 +191,6 @@ impl Session { let cancellation_token = CancellationToken::new(); let done = Arc::new(Notify::new()); - let timer = turn_context - .session_telemetry - .start_timer(TURN_E2E_DURATION_METRIC, &[]) - .ok(); - let done_clone = Arc::clone(&done); let handle = { let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); @@ -220,6 +231,21 @@ impl Session { ) }; + let queued_response_items = self.take_queued_response_items_for_next_turn().await; + let mut active = self.active_turn.lock().await; + let mut turn = ActiveTurn::default(); + let mut turn_state = turn.turn_state.lock().await; + turn_state.token_usage_at_turn_start = token_usage_at_turn_start; + for item in queued_response_items { + turn_state.push_pending_input(item); + } + drop(turn_state); + + let timer = turn_context + .session_telemetry + .start_timer(TURN_E2E_DURATION_METRIC, &[]) + .ok(); + let running_task = RunningTask { done, handle: Arc::new(AbortOnDropHandle::new(handle)), @@ -229,8 +255,8 @@ impl Session { turn_context: Arc::clone(&turn_context), _timer: timer, }; - self.register_new_active_task(running_task, token_usage_at_turn_start) - .await; + turn.add_task(running_task); + *active = Some(turn); } pub(crate) async fn ensure_task_for_queued_response_items(self: &Arc) { @@ -390,22 +416,6 @@ impl Session { self.send_event(turn_context.as_ref(), event).await; } - async fn register_new_active_task( - &self, - task: RunningTask, - token_usage_at_turn_start: TokenUsage, - ) { - let mut active = self.active_turn.lock().await; - let mut turn = ActiveTurn::default(); - let mut turn_state = turn.turn_state.lock().await; - turn_state.token_usage_at_turn_start = token_usage_at_turn_start; - for item in self.take_queued_response_items_for_next_turn().await { - turn_state.push_pending_input(item); - } - drop(turn_state); - turn.add_task(task); - *active = Some(turn); - } async fn take_active_turn(&self) -> Option { let mut active = self.active_turn.lock().await; active.take() @@ -457,17 +467,7 @@ impl Session { if reason == TurnAbortReason::Interrupted { self.cleanup_after_interrupt(&task.turn_context).await; - let marker = ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: format!( - "{TURN_ABORTED_OPEN_TAG}\n{TURN_ABORTED_INTERRUPTED_GUIDANCE}\n" - ), - }], - end_turn: None, - phase: None, - }; + let marker = interrupted_turn_history_marker(); self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) .await; self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 6deb9abdb6..171c0adaa3 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -63,14 +63,22 @@ impl SessionTask for RegularTask { Some(*prewarmed_client_session) } }; - run_turn( - sess, - ctx, - input, - prewarmed_client_session, - cancellation_token, - ) - .instrument(run_turn_span) - .await + let mut next_input = input; + let mut prewarmed_client_session = prewarmed_client_session; + loop { + let last_agent_message = run_turn( + Arc::clone(&sess), + Arc::clone(&ctx), + next_input, + prewarmed_client_session.take(), + cancellation_token.child_token(), + ) + .instrument(run_turn_span.clone()) + .await; + if !sess.has_pending_input().await { + return last_agent_message; + } + next_input = Vec::new(); + } } } diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index 9d899cb30d..7fd2ea07e8 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -8,8 +8,8 @@ use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use async_trait::async_trait; -use codex_git::RestoreGhostCommitOptions; -use codex_git::restore_ghost_commit_with_options; +use codex_git_utils::RestoreGhostCommitOptions; +use codex_git_utils::restore_ghost_commit_with_options; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 6b42be3cef..b90717e715 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -12,7 +12,6 @@ use uuid::Uuid; use crate::codex::TurnContext; use crate::exec::ExecCapturePolicy; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::StreamOutput; use crate::exec::execute_exec_request; @@ -26,11 +25,11 @@ use crate::protocol::ExecCommandStatus; use crate::protocol::SandboxPolicy; use crate::protocol::TurnStartedEvent; use crate::sandboxing::ExecRequest; -use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::user_shell_command::user_shell_command_record_item; +use codex_sandboxing::SandboxType; use super::SessionTask; use super::SessionTaskContext; @@ -146,7 +145,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -157,7 +156,7 @@ pub(crate) async fn execute_user_shell_command( let sandbox_policy = SandboxPolicy::DangerFullAccess; let exec_env = ExecRequest { command: exec_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), env: create_env( &turn_context.shell_environment_policy, Some(session.conversation_id), @@ -173,11 +172,10 @@ pub(crate) async fn execute_user_shell_command( .config .permissions .windows_sandbox_private_desktop, - sandbox_permissions: SandboxPermissions::UseDefault, sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - justification: None, + windows_restricted_token_filesystem_overlay: None, arg0: None, }; @@ -223,7 +221,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -247,7 +245,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -291,7 +289,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command, - cwd, + cwd: cwd.to_path_buf(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d93bf37187..6ce55c6229 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -3,8 +3,6 @@ use crate::CodexAuth; use crate::ModelProviderInfo; use crate::OPENAI_PROVIDER_ID; use crate::agent::AgentControl; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; use crate::codex::CodexSpawnOk; @@ -14,7 +12,6 @@ use crate::config::Config; use crate::error::CodexErr; use crate::error::Result as CodexResult; use crate::file_watcher::FileWatcher; -use crate::file_watcher::FileWatcherEvent; use crate::mcp::McpManager; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::models_manager::manager::ModelsManager; @@ -26,6 +23,11 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::shell_snapshot::ShellSnapshot; use crate::skills::SkillsManager; +use crate::skills_watcher::SkillsWatcher; +use crate::skills_watcher::SkillsWatcherEvent; +use crate::tasks::interrupted_turn_history_marker; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationModeMask; #[cfg(test)] @@ -36,6 +38,8 @@ use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::W3cTraceContext; use futures::StreamExt; use futures::stream::FuturesUnordered; @@ -80,32 +84,33 @@ impl Drop for TempCodexHomeGuard { } } -fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc) -> Arc { +fn build_skills_watcher(skills_manager: Arc) -> Arc { if should_use_test_thread_manager_behavior() && let Ok(handle) = Handle::try_current() && handle.runtime_flavor() == RuntimeFlavor::CurrentThread { // The real watcher spins background tasks that can starve the // current-thread test runtime and cause event waits to time out. - warn!("using noop file watcher under current-thread test runtime"); - return Arc::new(FileWatcher::noop()); + warn!("using noop skills watcher under current-thread test runtime"); + return Arc::new(SkillsWatcher::noop()); } - let file_watcher = match FileWatcher::new(codex_home) { + let file_watcher = match FileWatcher::new() { Ok(file_watcher) => Arc::new(file_watcher), Err(err) => { warn!("failed to initialize file watcher: {err}"); Arc::new(FileWatcher::noop()) } }; + let skills_watcher = Arc::new(SkillsWatcher::new(&file_watcher)); - let mut rx = file_watcher.subscribe(); + let mut rx = skills_watcher.subscribe(); let skills_manager = Arc::clone(&skills_manager); if let Ok(handle) = Handle::try_current() { handle.spawn(async move { loop { match rx.recv().await { - Ok(FileWatcherEvent::SkillsChanged { .. }) => { + Ok(SkillsWatcherEvent::SkillsChanged { .. }) => { skills_manager.clear_cache(); } Err(broadcast::error::RecvError::Closed) => break, @@ -114,10 +119,10 @@ fn build_file_watcher(codex_home: PathBuf, skills_manager: Arc) - } }); } else { - warn!("file watcher listener skipped: no Tokio runtime available"); + warn!("skills watcher listener skipped: no Tokio runtime available"); } - file_watcher + skills_watcher } /// Represents a newly created Codex thread (formerly called a conversation), including the first event @@ -128,6 +133,45 @@ pub struct NewThread { pub session_configured: SessionConfiguredEvent, } +// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once +// core can represent sampling boundaries directly instead of relying on +// whichever items happened to be persisted mid-turn. +// +// Two likely future variants: +// - `TruncateToLastSamplingBoundary` for callers that want a coherent fork from +// the last stable model boundary without synthesizing an interrupt. +// - `WaitUntilNextSamplingBoundary` (or similar) for callers that prefer to +// fork after the next sampling boundary rather than interrupting immediately. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForkSnapshot { + /// Fork a committed prefix ending strictly before the nth user message. + /// + /// When `n` is within range, this cuts before that 0-based user-message + /// boundary. When `n` is out of range and the source thread is currently + /// mid-turn, this instead cuts before the active turn's opening boundary + /// so the fork drops the unfinished turn suffix. When `n` is out of range + /// and the source thread is already at a turn boundary, this returns the + /// full committed history unchanged. + TruncateBeforeNthUserMessage(usize), + + /// Fork the current persisted history as if the source thread had been + /// interrupted now. + /// + /// If the persisted snapshot ends mid-turn, this appends the same + /// `` marker produced by a real interrupt. If the snapshot is + /// already at a turn boundary, this returns the current persisted history + /// unchanged. + Interrupted, +} + +/// Preserve legacy `fork_thread(usize, ...)` callsites by mapping them to the +/// existing truncate-before-nth-user-message snapshot mode. +impl From for ForkSnapshot { + fn from(value: usize) -> Self { + Self::TruncateBeforeNthUserMessage(value) + } +} + #[derive(Debug, Default, PartialEq, Eq)] pub struct ThreadShutdownReport { pub completed: Vec, @@ -159,7 +203,7 @@ pub(crate) struct ThreadManagerState { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, - file_watcher: Arc, + skills_watcher: Arc, session_source: SessionSource, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, @@ -191,7 +235,7 @@ impl ThreadManager { config.bundled_skills_enabled(), restriction_product, )); - let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager)); + let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager)); Self { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), @@ -206,7 +250,7 @@ impl ThreadManager { skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, auth_manager, session_source, ops_log: should_use_test_thread_manager_behavior() @@ -257,7 +301,7 @@ impl ThreadManager { /*bundled_skills_enabled*/ true, restriction_product, )); - let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager)); + let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager)); Self { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), @@ -270,7 +314,7 @@ impl ThreadManager { skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, auth_manager, session_source: SessionSource::Exec, ops_log: should_use_test_thread_manager_behavior() @@ -300,10 +344,6 @@ impl ThreadManager { self.state.mcp_manager.clone() } - pub fn subscribe_file_watcher(&self) -> broadcast::Receiver { - self.state.file_watcher.subscribe() - } - pub fn get_models_manager(&self) -> Arc { self.state.models_manager.clone() } @@ -543,20 +583,41 @@ impl ThreadManager { report } - /// Fork an existing thread by taking messages up to the given position (not including - /// the message at the given position) and starting a new thread with identical - /// configuration (unless overridden by the caller's `config`). The new thread will have - /// a fresh id. Pass `usize::MAX` to keep the full rollout history. - pub async fn fork_thread( + /// Fork an existing thread by snapshotting rollout history according to + /// `snapshot` and starting a new thread with identical configuration + /// (unless overridden by the caller's `config`). The new thread will have + /// a fresh id. + pub async fn fork_thread( &self, - nth_user_message: usize, + snapshot: S, config: Config, path: PathBuf, persist_extended_history: bool, parent_trace: Option, - ) -> CodexResult { + ) -> CodexResult + where + S: Into, + { + let snapshot = snapshot.into(); let history = RolloutRecorder::get_rollout_history(&path).await?; - let history = truncate_before_nth_user_message(history, nth_user_message); + let snapshot_state = snapshot_turn_state(&history); + let history = match snapshot { + ForkSnapshot::TruncateBeforeNthUserMessage(nth_user_message) => { + truncate_before_nth_user_message(history, nth_user_message, &snapshot_state) + } + ForkSnapshot::Interrupted => { + let history = match history { + InitialHistory::New => InitialHistory::New, + InitialHistory::Forked(history) => InitialHistory::Forked(history), + InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history), + }; + if snapshot_state.ends_mid_turn { + append_interrupted_boundary(history, snapshot_state.active_turn_id) + } else { + history + } + } + }; Box::pin(self.state.spawn_thread( config, history, @@ -621,18 +682,6 @@ impl ThreadManagerState { thread.append_message(message).await } - pub(crate) async fn deliver_inter_agent_instruction( - &self, - thread_id: ThreadId, - instruction: InterAgentInstruction, - delivery: InterAgentDelivery, - ) -> CodexResult { - let thread = self.get_thread(thread_id).await?; - thread - .deliver_inter_agent_instruction(instruction, delivery) - .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) @@ -787,7 +836,7 @@ impl ThreadManagerState { user_shell_override: Option, ) -> CodexResult { let watch_registration = self - .file_watcher + .skills_watcher .register_config(&config, self.skills_manager.as_ref()); let CodexSpawnOk { codex, thread_id, .. @@ -798,7 +847,7 @@ impl ThreadManagerState { skills_manager: Arc::clone(&self.skills_manager), plugins_manager: Arc::clone(&self.plugins_manager), mcp_manager: Arc::clone(&self.mcp_manager), - file_watcher: Arc::clone(&self.file_watcher), + skills_watcher: Arc::clone(&self.skills_watcher), conversation_history: initial_history, session_source, agent_control, @@ -852,11 +901,31 @@ impl ThreadManagerState { } } -/// Return a prefix of `items` obtained by cutting strictly before the nth user message -/// (0-based) and all items that follow it. -fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory { +/// Return a fork snapshot cut strictly before the nth user message (0-based). +/// +/// Out-of-range values keep the full committed history at a turn boundary, but +/// when the source thread is currently mid-turn they fall back to cutting +/// before the active turn's opening boundary so the fork omits the unfinished +/// suffix entirely. +fn truncate_before_nth_user_message( + history: InitialHistory, + n: usize, + snapshot_state: &SnapshotTurnState, +) -> InitialHistory { let items: Vec = history.get_rollout_items(); - let rolled = truncation::truncate_rollout_before_nth_user_message_from_start(&items, n); + let user_positions = truncation::user_message_positions_in_rollout(&items); + let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() { + if let Some(cut_idx) = snapshot_state + .active_turn_start_index + .or_else(|| user_positions.last().copied()) + { + items[..cut_idx].to_vec() + } else { + items + } + } else { + truncation::truncate_rollout_before_nth_user_message_from_start(&items, n) + }; if rolled.is_empty() { InitialHistory::New @@ -865,6 +934,95 @@ fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> Initia } } +#[derive(Debug, Eq, PartialEq)] +struct SnapshotTurnState { + ends_mid_turn: bool, + active_turn_id: Option, + active_turn_start_index: Option, +} + +fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { + let rollout_items = history.get_rollout_items(); + let mut builder = ThreadHistoryBuilder::new(); + for item in &rollout_items { + builder.handle_rollout_item(item); + } + let active_turn_id = builder.active_turn_id_if_explicit(); + if builder.has_active_turn() && active_turn_id.is_some() { + let active_turn_snapshot = builder.active_turn_snapshot(); + if active_turn_snapshot + .as_ref() + .is_some_and(|turn| turn.status != TurnStatus::InProgress) + { + return SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }; + } + + return SnapshotTurnState { + ends_mid_turn: true, + active_turn_id, + active_turn_start_index: builder.active_turn_start_index(), + }; + } + + let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items) + .last() + .copied() + else { + return SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }; + }; + + // Synthetic fork/resume histories can contain user/assistant response items + // without explicit turn lifecycle events. If the persisted snapshot has no + // terminating boundary after its last user message, treat it as mid-turn. + SnapshotTurnState { + ends_mid_turn: !rollout_items[last_user_position + 1..].iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) + ) + }), + active_turn_id: None, + active_turn_start_index: None, + } +} + +/// Append the same persisted interrupt boundary used by the live interrupt path +/// to an existing fork snapshot after the source thread has been confirmed to +/// be mid-turn. +fn append_interrupted_boundary(history: InitialHistory, turn_id: Option) -> InitialHistory { + let aborted_event = RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id, + reason: TurnAbortReason::Interrupted, + })); + + match history { + InitialHistory::New => InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + aborted_event, + ]), + InitialHistory::Forked(mut history) => { + history.push(RolloutItem::ResponseItem(interrupted_turn_history_marker())); + history.push(aborted_event); + InitialHistory::Forked(history) + } + InitialHistory::Resumed(mut resumed) => { + resumed + .history + .push(RolloutItem::ResponseItem(interrupted_turn_history_marker())); + resumed.history.push(aborted_event); + InitialHistory::Forked(resumed.history) + } + } +} + #[cfg(test)] #[path = "thread_manager_tests.rs"] mod tests; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index e69e88fe73..64830fa727 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -3,11 +3,16 @@ use crate::codex::make_session_and_context; use crate::config::test_config; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; use crate::models_manager::manager::RefreshStrategy; -use assert_matches::assert_matches; +use crate::rollout::RolloutRecorder; +use crate::tasks::interrupted_turn_history_marker; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AgentMessageEvent; +use codex_protocol::protocol::TurnStartedEvent; +use codex_protocol::protocol::UserMessageEvent; +use core_test_support::PathExt; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use std::time::Duration; @@ -38,7 +43,7 @@ fn assistant_msg(text: &str) -> ResponseItem { } #[test] -fn drops_from_last_user_only() { +fn truncates_before_requested_user_message() { let items = [ user_msg("u1"), assistant_msg("a1"), @@ -68,7 +73,15 @@ fn drops_from_last_user_only() { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1); + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(initial), + 1, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); let got_items = truncated.get_rollout_items(); let expected_items = vec![ RolloutItem::ResponseItem(items[0].clone()), @@ -85,8 +98,99 @@ fn drops_from_last_user_only() { .cloned() .map(RolloutItem::ResponseItem) .collect(); - let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2); - assert_matches!(truncated2, InitialHistory::New); + let truncated2 = truncate_before_nth_user_message( + InitialHistory::Forked(initial2.clone()), + 2, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); + assert_eq!( + serde_json::to_value(truncated2.get_rollout_items()).unwrap(), + serde_json::to_value(initial2).unwrap() + ); +} + +#[test] +fn out_of_range_truncation_drops_only_unfinished_suffix_mid_turn() { + let items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]; + + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(items.clone()), + usize::MAX, + &SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: None, + active_turn_start_index: None, + }, + ); + + assert_eq!( + serde_json::to_value(truncated.get_rollout_items()).unwrap(), + serde_json::to_value(items[..2].to_vec()).unwrap() + ); +} + +#[test] +fn fork_thread_accepts_legacy_usize_snapshot_argument() { + fn assert_legacy_snapshot_callsite( + manager: &ThreadManager, + config: Config, + path: std::path::PathBuf, + ) { + let _future = manager.fork_thread( + usize::MAX, + config, + path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ); + } + + let _: fn(&ThreadManager, Config, std::path::PathBuf) = assert_legacy_snapshot_callsite; +} + +#[test] +fn out_of_range_truncation_drops_pre_user_active_turn_prefix() { + let items = vec![ + RolloutItem::ResponseItem(user_msg("u1")), + RolloutItem::ResponseItem(assistant_msg("a1")), + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-2".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_msg("u2")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]; + + let snapshot_state = snapshot_turn_state(&InitialHistory::Forked(items.clone())); + assert_eq!( + snapshot_state, + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: Some("turn-2".to_string()), + active_turn_start_index: Some(2), + }, + ); + + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(items.clone()), + usize::MAX, + &snapshot_state, + ); + + assert_eq!( + serde_json::to_value(truncated.get_rollout_items()).unwrap(), + serde_json::to_value(items[..2].to_vec()).unwrap() + ); } #[tokio::test] @@ -104,7 +208,15 @@ async fn ignores_session_prefix_messages_when_truncating() { .map(RolloutItem::ResponseItem) .collect(); - let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); + let truncated = truncate_before_nth_user_message( + InitialHistory::Forked(rollout_items), + 1, + &SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); let got_items = truncated.get_rollout_items(); let expected: Vec = vec![ @@ -125,7 +237,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let manager = ThreadManager::with_models_provider_and_home_for_tests( @@ -164,7 +276,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); config.model_catalog = None; config @@ -185,3 +297,421 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { let _ = manager.list_models(RefreshStrategy::Online).await; assert_eq!(models_mock.requests().len(), 1); } + +#[test] +fn interrupted_fork_snapshot_appends_interrupt_boundary() { + let committed_history = + InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("hello"))]); + + assert_eq!( + serde_json::to_value( + append_interrupted_boundary(committed_history, /*turn_id*/ None).get_rollout_items() + ) + .expect("serialize interrupted fork history"), + serde_json::to_value(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: None, + reason: TurnAbortReason::Interrupted, + })), + ]) + .expect("serialize expected interrupted fork history"), + ); + assert_eq!( + serde_json::to_value( + append_interrupted_boundary(InitialHistory::New, /*turn_id*/ None).get_rollout_items() + ) + .expect("serialize interrupted empty fork history"), + serde_json::to_value(vec![ + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: None, + reason: TurnAbortReason::Interrupted, + })), + ]) + .expect("serialize expected interrupted empty history"), + ); +} + +#[test] +fn interrupted_snapshot_is_not_mid_turn() { + let interrupted_history = InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + RolloutItem::ResponseItem(interrupted_turn_history_marker()), + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + })), + ]); + + assert_eq!( + snapshot_turn_state(&interrupted_history), + SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[test] +fn completed_legacy_event_history_is_not_mid_turn() { + let completed_history = InitialHistory::Forked(vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "hello".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "done".to_string(), + phase: None, + memory_citation: None, + })), + ]); + + assert_eq!( + snapshot_turn_state(&completed_history), + SnapshotTurnState { + ends_mid_turn: false, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[test] +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 { + message: "hello".to_string(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + })), + ]); + + assert_eq!( + snapshot_turn_state(&mixed_history), + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: None, + active_turn_start_index: None, + }, + ); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from completed history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + let source_snapshot_state = snapshot_turn_state(&source_history); + assert!(source_snapshot_state.ends_mid_turn); + let expected_turn_id = source_snapshot_state.active_turn_id.clone(); + assert_eq!(expected_turn_id, None); + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + assert!(!snapshot_turn_state(&history).ends_mid_turn); + let rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + let interrupted_marker_json = + serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker())) + .expect("serialize interrupted marker"); + let interrupted_abort_json = serde_json::to_value(RolloutItem::EventMsg( + EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: expected_turn_id, + reason: TurnAbortReason::Interrupted, + }), + )) + .expect("serialize interrupted abort event"); + assert_eq!( + rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + assert_eq!( + rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize rollout item") + == interrupted_abort_json + }) + .count(), + 1, + ); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-explicit".to_string(), + model_context_window: None, + collaboration_mode_kind: Default::default(), + })), + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from explicit partial history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + let source_snapshot_state = snapshot_turn_state(&source_history); + assert_eq!( + source_snapshot_state, + SnapshotTurnState { + ends_mid_turn: true, + active_turn_id: Some("turn-explicit".to_string()), + active_turn_start_index: Some(1), + }, + ); + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + let rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + + assert!(rollout_items.iter().any(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn_id), + reason: TurnAbortReason::Interrupted, + })) if turn_id == "turn-explicit" + ) + })); +} + +#[tokio::test] +async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config(); + config.codex_home = temp_dir.path().join("codex-home"); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let manager = ThreadManager::new( + &config, + auth_manager.clone(), + SessionSource::Exec, + CollaborationModesConfig::default(), + ); + + let source = manager + .resume_thread_with_history( + config.clone(), + InitialHistory::Forked(vec![ + RolloutItem::ResponseItem(user_msg("hello")), + RolloutItem::ResponseItem(assistant_msg("partial")), + ]), + auth_manager, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("create source thread from partial history"); + let source_path = source + .thread + .rollout_path() + .expect("source rollout path should exist"); + let source_history = RolloutRecorder::get_rollout_history(&source_path) + .await + .expect("read source rollout history"); + assert!(snapshot_turn_state(&source_history).ends_mid_turn); + manager.remove_thread(&source.thread_id).await; + + let forked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config.clone(), + source_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("fork interrupted snapshot"); + let forked_path = forked + .thread + .rollout_path() + .expect("forked rollout path should exist"); + let history = RolloutRecorder::get_rollout_history(&forked_path) + .await + .expect("read forked rollout history"); + assert!(!snapshot_turn_state(&history).ends_mid_turn); + + let forked_rollout_items: Vec<_> = history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + let interrupted_marker_json = + serde_json::to_value(RolloutItem::ResponseItem(interrupted_turn_history_marker())) + .expect("serialize interrupted marker"); + assert_eq!( + forked_rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize forked rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + + manager.remove_thread(&forked.thread_id).await; + let reforked = manager + .fork_thread( + ForkSnapshot::Interrupted, + config, + forked_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) + .await + .expect("re-fork interrupted snapshot"); + let reforked_path = reforked + .thread + .rollout_path() + .expect("re-forked rollout path should exist"); + let reforked_history = RolloutRecorder::get_rollout_history(&reforked_path) + .await + .expect("read re-forked rollout history"); + let reforked_rollout_items: Vec<_> = reforked_history + .get_rollout_items() + .into_iter() + .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) + .collect(); + + assert_eq!( + reforked_rollout_items + .iter() + .filter(|item| { + serde_json::to_value(item).expect("serialize re-forked rollout item") + == interrupted_marker_json + }) + .count(), + 1, + ); + assert_eq!( + reforked_rollout_items + .iter() + .filter(|item| { + matches!( + item, + RolloutItem::EventMsg(EventMsg::TurnAborted(TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + .. + })) + ) + }) + .count(), + 1, + ); +} diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs similarity index 92% rename from codex-rs/core/src/rollout/truncation.rs rename to codex-rs/core/src/thread_rollout_truncation.rs index 490bf42b97..5fa1881ffa 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -46,8 +46,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec Option ArtifactRuntimeMan async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) { let emitter = ToolEmitter::shell( vec![ARTIFACTS_TOOL_NAME.to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ true, ); @@ -247,7 +247,7 @@ async fn emit_exec_end( }; let emitter = ToolEmitter::shell( vec![ARTIFACTS_TOOL_NAME.to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ true, ); diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs deleted file mode 100644 index fdb0fce7be..0000000000 --- a/codex-rs/core/src/tools/handlers/grep_files.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::path::Path; -use std::time::Duration; - -use async_trait::async_trait; -use serde::Deserialize; -use tokio::process::Command; -use tokio::time::timeout; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct GrepFilesHandler; - -const DEFAULT_LIMIT: usize = 100; -const MAX_LIMIT: usize = 2000; -const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); - -fn default_limit() -> usize { - DEFAULT_LIMIT -} - -#[derive(Deserialize)] -struct GrepFilesArgs { - pattern: String, - #[serde(default)] - include: Option, - #[serde(default)] - path: Option, - #[serde(default = "default_limit")] - limit: usize, -} - -#[async_trait] -impl ToolHandler for GrepFilesHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, turn, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "grep_files handler received unsupported payload".to_string(), - )); - } - }; - - let args: GrepFilesArgs = parse_arguments(&arguments)?; - - let pattern = args.pattern.trim(); - if pattern.is_empty() { - return Err(FunctionCallError::RespondToModel( - "pattern must not be empty".to_string(), - )); - } - - if args.limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let limit = args.limit.min(MAX_LIMIT); - let search_path = turn.resolve_path(args.path.clone()); - - verify_path_exists(&search_path).await?; - - let include = args.include.as_deref().map(str::trim).and_then(|val| { - if val.is_empty() { - None - } else { - Some(val.to_string()) - } - }); - - let search_results = - run_rg_search(pattern, include.as_deref(), &search_path, limit, &turn.cwd).await?; - - if search_results.is_empty() { - Ok(FunctionToolOutput::from_text( - "No matches found.".to_string(), - Some(false), - )) - } else { - Ok(FunctionToolOutput::from_text( - search_results.join("\n"), - Some(true), - )) - } - } -} - -async fn verify_path_exists(path: &Path) -> Result<(), FunctionCallError> { - tokio::fs::metadata(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("unable to access `{}`: {err}", path.display())) - })?; - Ok(()) -} - -async fn run_rg_search( - pattern: &str, - include: Option<&str>, - search_path: &Path, - limit: usize, - cwd: &Path, -) -> Result, FunctionCallError> { - let mut command = Command::new("rg"); - command - .current_dir(cwd) - .arg("--files-with-matches") - .arg("--sortr=modified") - .arg("--regexp") - .arg(pattern) - .arg("--no-messages"); - - if let Some(glob) = include { - command.arg("--glob").arg(glob); - } - - command.arg("--").arg(search_path); - - let output = timeout(COMMAND_TIMEOUT, command.output()) - .await - .map_err(|_| { - FunctionCallError::RespondToModel("rg timed out after 30 seconds".to_string()) - })? - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to launch rg: {err}. Ensure ripgrep is installed and on PATH." - )) - })?; - - match output.status.code() { - Some(0) => Ok(parse_results(&output.stdout, limit)), - Some(1) => Ok(Vec::new()), - _ => { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(FunctionCallError::RespondToModel(format!( - "rg failed: {stderr}" - ))) - } - } -} - -fn parse_results(stdout: &[u8], limit: usize) -> Vec { - let mut results = Vec::new(); - for line in stdout.split(|byte| *byte == b'\n') { - if line.is_empty() { - continue; - } - if let Ok(text) = std::str::from_utf8(line) { - if text.is_empty() { - continue; - } - results.push(text.to_string()); - if results.len() == limit { - break; - } - } - } - results -} - -#[cfg(test)] -#[path = "grep_files_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs index b380a7107d..43b986e0d8 100644 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ b/codex-rs/core/src/tools/handlers/js_repl.rs @@ -61,7 +61,7 @@ async fn emit_js_repl_exec_begin( ) { let emitter = ToolEmitter::shell( vec!["js_repl".to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ false, ); @@ -80,7 +80,7 @@ async fn emit_js_repl_exec_end( let exec_output = build_js_repl_exec_output(output, error, duration); let emitter = ToolEmitter::shell( vec!["js_repl".to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ false, ); diff --git a/codex-rs/core/src/tools/handlers/js_repl_tests.rs b/codex-rs/core/src/tools/handlers/js_repl_tests.rs index 14dc222ffe..9613a8303f 100644 --- a/codex-rs/core/src/tools/handlers/js_repl_tests.rs +++ b/codex-rs/core/src/tools/handlers/js_repl_tests.rs @@ -77,7 +77,7 @@ async fn emit_js_repl_exec_end_sends_event() { assert_eq!(event.call_id, "call-1"); assert_eq!(event.turn_id, turn.sub_id); assert_eq!(event.command, vec!["js_repl".to_string()]); - assert_eq!(event.cwd, turn.cwd); + assert_eq!(event.cwd, turn.cwd.to_path_buf()); assert_eq!(event.source, ExecCommandSource::Agent); assert_eq!(event.interaction_input, None); assert_eq!(event.stdout, "hello"); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 7ae1d1428d..ba0a0eb50d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,14 +2,14 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod artifacts; mod dynamic; -mod grep_files; mod js_repl; mod list_dir; mod mcp; mod mcp_resource; pub(crate) mod multi_agents; +pub(crate) mod multi_agents_common; +pub(crate) mod multi_agents_v2; mod plan; -mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -19,6 +19,9 @@ mod tool_suggest; pub(crate) mod unified_exec; mod view_image; +use codex_sandboxing::policy_transforms::intersect_permission_profiles; +use codex_sandboxing::policy_transforms::merge_permission_profiles; +use codex_sandboxing::policy_transforms::normalize_additional_permissions; use codex_utils_absolute_path::AbsolutePathBufGuard; pub use plan::PLAN_TOOL; use serde::Deserialize; @@ -29,8 +32,6 @@ use std::path::PathBuf; use crate::codex::Session; use crate::function_tool::FunctionCallError; use crate::sandboxing::SandboxPermissions; -use crate::sandboxing::merge_permission_profiles; -use crate::sandboxing::normalize_additional_permissions; pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler; pub(crate) use crate::tools::code_mode::CodeModeWaitHandler; pub use apply_patch::ApplyPatchHandler; @@ -38,14 +39,12 @@ pub use artifacts::ArtifactsHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; -pub use read_file::ReadFileHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; @@ -206,10 +205,8 @@ pub(super) async fn apply_granted_turn_permissions( ); let permissions_preapproved = match (effective_permissions.as_ref(), granted_permissions) { (Some(effective_permissions), Some(granted_permissions)) => { - crate::sandboxing::intersect_permission_profiles( - effective_permissions.clone(), - granted_permissions, - ) == *effective_permissions + intersect_permission_profiles(effective_permissions.clone(), granted_permissions) + == *effective_permissions } _ => false, }; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 897af0d5f0..0bf7b7bcd3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -11,45 +11,33 @@ use crate::agent::agent_resolver::resolve_agent_targets; use crate::agent::exceeds_thread_spawn_depth_limit; use crate::codex::Session; use crate::codex::TurnContext; -use crate::config::Config; -use crate::error::CodexErr; use crate::function_tool::FunctionCallError; -use crate::models_manager::manager::RefreshStrategy; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; -use codex_features::Feature; -use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabAgentStatusEntry; use codex_protocol::protocol::CollabCloseBeginEvent; use codex_protocol::protocol::CollabCloseEndEvent; use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::CollabWaitingBeginEvent; use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; -use std::collections::HashMap; -use std::sync::Arc; pub(crate) use close_agent::Handler as CloseAgentHandler; pub(crate) use resume_agent::Handler as ResumeAgentHandler; @@ -57,368 +45,12 @@ pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; -/// Minimum wait timeout to prevent tight polling loops from burning CPU. -pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; -pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; - -fn function_arguments(payload: ToolPayload) -> Result { - match payload { - ToolPayload::Function { arguments } => Ok(arguments), - _ => Err(FunctionCallError::RespondToModel( - "collab handler received unsupported payload".to_string(), - )), - } -} - -fn tool_output_json_text(value: &T, tool_name: &str) -> String -where - T: Serialize, -{ - serde_json::to_string(value).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() - }) -} - -fn tool_output_response_item( - call_id: &str, - payload: &ToolPayload, - value: &T, - success: Option, - tool_name: &str, -) -> ResponseInputItem -where - T: Serialize, -{ - FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) - .to_response_item(call_id, payload) -} - -fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue -where - T: Serialize, -{ - serde_json::to_value(value).unwrap_or_else(|err| { - JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) - }) -} - pub mod close_agent; mod resume_agent; mod send_input; mod spawn; pub(crate) mod wait; -fn build_wait_agent_statuses( - statuses: &HashMap, - receiver_agents: &[CollabAgentRef], -) -> Vec { - if statuses.is_empty() { - return Vec::new(); - } - - let mut entries = Vec::with_capacity(statuses.len()); - let mut seen = HashMap::with_capacity(receiver_agents.len()); - for receiver_agent in receiver_agents { - seen.insert(receiver_agent.thread_id, ()); - if let Some(status) = statuses.get(&receiver_agent.thread_id) { - entries.push(CollabAgentStatusEntry { - thread_id: receiver_agent.thread_id, - agent_nickname: receiver_agent.agent_nickname.clone(), - agent_role: receiver_agent.agent_role.clone(), - status: status.clone(), - }); - } - } - - let mut extras = statuses - .iter() - .filter(|(thread_id, _)| !seen.contains_key(thread_id)) - .map(|(thread_id, status)| CollabAgentStatusEntry { - thread_id: *thread_id, - agent_nickname: None, - agent_role: None, - status: status.clone(), - }) - .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); - entries.extend(extras); - entries -} - -fn collab_spawn_error(err: CodexErr) -> FunctionCallError { - match err { - CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - } - CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), - err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), - } -} - -fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { - match err { - CodexErr::ThreadNotFound(id) => { - FunctionCallError::RespondToModel(format!("agent with id {id} not found")) - } - CodexErr::InternalAgentDied => { - FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) - } - CodexErr::UnsupportedOperation(_) => { - FunctionCallError::RespondToModel("collab manager unavailable".to_string()) - } - err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), - } -} - -fn thread_spawn_source( - parent_thread_id: ThreadId, - parent_session_source: &SessionSource, - depth: i32, - agent_role: Option<&str>, - task_name: Option, -) -> Result { - let agent_path = task_name - .as_deref() - .map(|task_name| { - parent_session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root) - .join(task_name) - .map_err(FunctionCallError::RespondToModel) - }) - .transpose()?; - Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_nickname: None, - agent_role: agent_role.map(str::to_string), - })) -} - -fn parse_collab_input( - message: Option, - items: Option>, -) -> Result, FunctionCallError> { - match (message, items) { - (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( - "Provide either message or items, but not both".to_string(), - )), - (None, None) => Err(FunctionCallError::RespondToModel( - "Provide one of: message or items".to_string(), - )), - (Some(message), None) => { - if message.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "Empty message can't be sent to an agent".to_string(), - )); - } - Ok(vec![UserInput::Text { - text: message, - text_elements: Vec::new(), - }]) - } - (None, Some(items)) => { - if items.is_empty() { - return Err(FunctionCallError::RespondToModel( - "Items can't be empty".to_string(), - )); - } - Ok(items) - } - } -} - -fn input_preview(items: &[UserInput]) -> String { - let parts: Vec = items - .iter() - .map(|item| match item { - UserInput::Text { text, .. } => text.clone(), - UserInput::Image { .. } => "[image]".to_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})"), - _ => "[input]".to_string(), - }) - .collect(); - - parts.join("\n") -} - -/// Builds the base config snapshot for a newly spawned sub-agent. -/// -/// The returned config starts from the parent's effective config and then refreshes the -/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, -/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; -/// skipping this helper and cloning stale config state directly can send the child agent out with -/// the wrong provider or runtime policy. -pub(crate) fn build_agent_spawn_config( - base_instructions: &BaseInstructions, - turn: &TurnContext, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - config.base_instructions = Some(base_instructions.text.clone()); - Ok(config) -} - -fn build_agent_resume_config( - turn: &TurnContext, - child_depth: i32, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - apply_spawn_agent_overrides(&mut config, child_depth); - // For resume, keep base instructions sourced from rollout/session metadata. - config.base_instructions = None; - Ok(config) -} - -fn build_agent_shared_config(turn: &TurnContext) -> Result { - let base_config = turn.config.clone(); - let mut config = (*base_config).clone(); - config.model = Some(turn.model_info.slug.clone()); - config.model_provider = turn.provider.clone(); - config.model_reasoning_effort = turn.reasoning_effort; - config.model_reasoning_summary = Some(turn.reasoning_summary); - config.developer_instructions = turn.developer_instructions.clone(); - config.compact_prompt = turn.compact_prompt.clone(); - apply_spawn_agent_runtime_overrides(&mut config, turn)?; - - Ok(config) -} - -/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. -/// -/// These values are chosen by the live turn rather than persisted config, so leaving them stale -/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. -fn apply_spawn_agent_runtime_overrides( - config: &mut Config, - turn: &TurnContext, -) -> Result<(), FunctionCallError> { - config - .permissions - .approval_policy - .set(turn.approval_policy.value()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) - })?; - config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); - config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - config.cwd = turn.cwd.clone(); - config - .permissions - .sandbox_policy - .set(turn.sandbox_policy.get().clone()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) - })?; - config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); - config.permissions.network_sandbox_policy = turn.network_sandbox_policy; - Ok(()) -} - -fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { - if child_depth >= config.agent_max_depth { - let _ = config.features.disable(Feature::SpawnCsv); - let _ = config.features.disable(Feature::Collab); - } -} - -async fn apply_requested_spawn_agent_model_overrides( - session: &Session, - turn: &TurnContext, - config: &mut Config, - requested_model: Option<&str>, - requested_reasoning_effort: Option, -) -> Result<(), FunctionCallError> { - if requested_model.is_none() && requested_reasoning_effort.is_none() { - return Ok(()); - } - - if let Some(requested_model) = requested_model { - let available_models = session - .services - .models_manager - .list_models(RefreshStrategy::Offline) - .await; - let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; - let selected_model_info = session - .services - .models_manager - .get_model_info(&selected_model_name, config) - .await; - - config.model = Some(selected_model_name.clone()); - if let Some(reasoning_effort) = requested_reasoning_effort { - validate_spawn_agent_reasoning_effort( - &selected_model_name, - &selected_model_info.supported_reasoning_levels, - reasoning_effort, - )?; - config.model_reasoning_effort = Some(reasoning_effort); - } else { - config.model_reasoning_effort = selected_model_info.default_reasoning_level; - } - - return Ok(()); - } - - if let Some(reasoning_effort) = requested_reasoning_effort { - validate_spawn_agent_reasoning_effort( - &turn.model_info.slug, - &turn.model_info.supported_reasoning_levels, - reasoning_effort, - )?; - config.model_reasoning_effort = Some(reasoning_effort); - } - - Ok(()) -} - -fn find_spawn_agent_model_name( - available_models: &[codex_protocol::openai_models::ModelPreset], - requested_model: &str, -) -> Result { - available_models - .iter() - .find(|model| model.model == requested_model) - .map(|model| model.model.clone()) - .ok_or_else(|| { - let available = available_models - .iter() - .map(|model| model.model.as_str()) - .collect::>() - .join(", "); - FunctionCallError::RespondToModel(format!( - "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" - )) - }) -} - -fn validate_spawn_agent_reasoning_effort( - model: &str, - supported_reasoning_levels: &[ReasoningEffortPreset], - requested_reasoning_effort: ReasoningEffort, -) -> Result<(), FunctionCallError> { - if supported_reasoning_levels - .iter() - .any(|preset| preset.effort == requested_reasoning_effort) - { - return Ok(()); - } - - let supported = supported_reasoning_levels - .iter() - .map(|preset| preset.effort.to_string()) - .collect::>() - .join(", "); - Err(FunctionCallError::RespondToModel(format!( - "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" - ))) -} - #[cfg(test)] #[path = "multi_agents_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 85e879c1bb..09526182f2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -1,5 +1,6 @@ use super::*; use crate::agent::next_thread_spawn_depth; +use std::sync::Arc; pub(crate) struct Handler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 25f70c7305..f649482a2b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,15 +1,7 @@ use super::*; -use crate::agent::inter_agent_instruction::InterAgentDelivery; -use crate::agent::inter_agent_instruction::InterAgentInstruction; pub(crate) struct Handler; -fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { - items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) -} - #[async_trait] impl ToolHandler for Handler { type Output = SendInputResult; @@ -61,39 +53,10 @@ impl ToolHandler for Handler { ) .await; let agent_control = session.services.agent_control.clone(); - let result = if turn.config.features.enabled(Feature::MultiAgentV2) - && can_use_v2_inter_agent_instruction(&input_items) - { - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel( - "target agent is missing an agent_path".to_string(), - ) - })?; - let instruction = InterAgentInstruction::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - ); - agent_control - .deliver_inter_agent_instruction( - receiver_thread_id, - instruction, - if args.interrupt { - InterAgentDelivery::NextTurn - } else { - InterAgentDelivery::CurrentTurn - }, - ) - .await - } else { - agent_control - .send_input(receiver_thread_id, input_items) - .await - } - .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let result = agent_control + .send_input(receiver_thread_id, input_items) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err)); let status = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index 8458402ce5..d203e6b398 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -1,9 +1,11 @@ use super::*; use crate::agent::status::is_final; +use crate::error::CodexErr; use futures::FutureExt; use futures::StreamExt; use futures::stream::FuturesUnordered; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::sync::watch::Receiver; use tokio::time::Instant; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs new file mode 100644 index 0000000000..106f34253d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -0,0 +1,381 @@ +use crate::agent::AgentStatus; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; +use crate::function_tool::FunctionCallError; +use crate::models_manager::manager::RefreshStrategy; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use codex_features::Feature; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentStatusEntry; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::user_input::UserInput; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; + +/// Minimum wait timeout to prevent tight polling loops from burning CPU. +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; + +pub(crate) fn function_arguments(payload: ToolPayload) -> Result { + match payload { + ToolPayload::Function { arguments } => Ok(arguments), + _ => Err(FunctionCallError::RespondToModel( + "collab handler received unsupported payload".to_string(), + )), + } +} + +pub(crate) fn tool_output_json_text(value: &T, tool_name: &str) -> String +where + T: Serialize, +{ + serde_json::to_string(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string() + }) +} + +pub(crate) fn tool_output_response_item( + call_id: &str, + payload: &ToolPayload, + value: &T, + success: Option, + tool_name: &str, +) -> ResponseInputItem +where + T: Serialize, +{ + FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success) + .to_response_item(call_id, payload) +} + +pub(crate) fn tool_output_code_mode_result(value: &T, tool_name: &str) -> JsonValue +where + T: Serialize, +{ + serde_json::to_value(value).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize {tool_name} result: {err}")) + }) +} + +pub(crate) fn build_wait_agent_statuses( + statuses: &HashMap, + receiver_agents: &[CollabAgentRef], +) -> Vec { + if statuses.is_empty() { + return Vec::new(); + } + + let mut entries = Vec::with_capacity(statuses.len()); + let mut seen = HashMap::with_capacity(receiver_agents.len()); + for receiver_agent in receiver_agents { + seen.insert(receiver_agent.thread_id, ()); + if let Some(status) = statuses.get(&receiver_agent.thread_id) { + entries.push(CollabAgentStatusEntry { + thread_id: receiver_agent.thread_id, + agent_nickname: receiver_agent.agent_nickname.clone(), + agent_role: receiver_agent.agent_role.clone(), + status: status.clone(), + }); + } + } + + let mut extras = statuses + .iter() + .filter(|(thread_id, _)| !seen.contains_key(thread_id)) + .map(|(thread_id, status)| CollabAgentStatusEntry { + thread_id: *thread_id, + agent_nickname: None, + agent_role: None, + status: status.clone(), + }) + .collect::>(); + extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + entries.extend(extras); + entries +} + +pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError { + match err { + CodexErr::UnsupportedOperation(message) if message == "thread manager dropped" => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + CodexErr::UnsupportedOperation(message) => FunctionCallError::RespondToModel(message), + err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), + } +} + +pub(crate) fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { + match err { + CodexErr::ThreadNotFound(id) => { + FunctionCallError::RespondToModel(format!("agent with id {id} not found")) + } + CodexErr::InternalAgentDied => { + FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) + } + CodexErr::UnsupportedOperation(_) => { + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + } + err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), + } +} + +pub(crate) fn thread_spawn_source( + parent_thread_id: ThreadId, + parent_session_source: &SessionSource, + depth: i32, + agent_role: Option<&str>, + task_name: Option, +) -> Result { + let agent_path = task_name + .as_deref() + .map(|task_name| { + parent_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .join(task_name) + .map_err(FunctionCallError::RespondToModel) + }) + .transpose()?; + Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: None, + agent_role: agent_role.map(str::to_string), + })) +} + +pub(crate) fn parse_collab_input( + message: Option, + items: Option>, +) -> Result, FunctionCallError> { + match (message, items) { + (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( + "Provide either message or items, but not both".to_string(), + )), + (None, None) => Err(FunctionCallError::RespondToModel( + "Provide one of: message or items".to_string(), + )), + (Some(message), None) => { + if message.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "Empty message can't be sent to an agent".to_string(), + )); + } + Ok(vec![UserInput::Text { + text: message, + text_elements: Vec::new(), + }]) + } + (None, Some(items)) => { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + Ok(items) + } + } +} + +pub(crate) fn input_preview(items: &[UserInput]) -> String { + let parts: Vec = items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_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})"), + _ => "[input]".to_string(), + }) + .collect(); + + parts.join("\n") +} + +/// Builds the base config snapshot for a newly spawned sub-agent. +/// +/// The returned config starts from the parent's effective config and then refreshes the +/// runtime-owned fields carried on `turn`, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered after this step; +/// skipping this helper and cloning stale config state directly can send the child agent out with +/// the wrong provider or runtime policy. +pub(crate) fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + config.base_instructions = Some(base_instructions.text.clone()); + Ok(config) +} + +pub(crate) fn build_agent_resume_config( + turn: &TurnContext, + child_depth: i32, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + apply_spawn_agent_overrides(&mut config, child_depth); + // For resume, keep base instructions sourced from rollout/session metadata. + config.base_instructions = None; + Ok(config) +} + +fn build_agent_shared_config(turn: &TurnContext) -> Result { + let base_config = turn.config.clone(); + let mut config = (*base_config).clone(); + config.model = Some(turn.model_info.slug.clone()); + config.model_provider = turn.provider.clone(); + config.model_reasoning_effort = turn.reasoning_effort; + config.model_reasoning_summary = Some(turn.reasoning_summary); + config.developer_instructions = turn.developer_instructions.clone(); + config.compact_prompt = turn.compact_prompt.clone(); + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + Ok(config) +} + +/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. +/// +/// These values are chosen by the live turn rather than persisted config, so leaving them stale +/// can make a child agent disagree with its parent about approval policy, cwd, or sandboxing. +pub(crate) fn apply_spawn_agent_runtime_overrides( + config: &mut Config, + turn: &TurnContext, +) -> Result<(), FunctionCallError> { + config + .permissions + .approval_policy + .set(turn.approval_policy.value()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) + })?; + config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); + config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); + config.cwd = turn.cwd.clone(); + config + .permissions + .sandbox_policy + .set(turn.sandbox_policy.get().clone()) + .map_err(|err| { + FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) + })?; + config.permissions.file_system_sandbox_policy = turn.file_system_sandbox_policy.clone(); + config.permissions.network_sandbox_policy = turn.network_sandbox_policy; + Ok(()) +} + +pub(crate) fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { + if child_depth >= config.agent_max_depth { + let _ = config.features.disable(Feature::SpawnCsv); + let _ = config.features.disable(Feature::Collab); + } +} + +pub(crate) async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), FunctionCallError> { + if requested_model.is_none() && requested_reasoning_effort.is_none() { + return Ok(()); + } + + if let Some(requested_model) = requested_model { + let available_models = session + .services + .models_manager + .list_models(RefreshStrategy::Offline) + .await; + let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, config) + .await; + + config.model = Some(selected_model_name.clone()); + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &selected_model_name, + &selected_model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } else { + config.model_reasoning_effort = selected_model_info.default_reasoning_level; + } + + return Ok(()); + } + + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &turn.model_info.slug, + &turn.model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } + + Ok(()) +} + +fn find_spawn_agent_model_name( + available_models: &[codex_protocol::openai_models::ModelPreset], + requested_model: &str, +) -> Result { + available_models + .iter() + .find(|model| model.model == requested_model) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_models + .iter() + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + FunctionCallError::RespondToModel(format!( + "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" + )) + }) +} + +fn validate_spawn_agent_reasoning_effort( + model: &str, + supported_reasoning_levels: &[ReasoningEffortPreset], + requested_reasoning_effort: ReasoningEffort, +) -> Result<(), FunctionCallError> { + if supported_reasoning_levels + .iter() + .any(|preset| preset.effort == requested_reasoning_effort) + { + return Ok(()); + } + + let supported = supported_reasoning_levels + .iter() + .map(|preset| preset.effort.to_string()) + .collect::>() + .join(", "); + Err(FunctionCallError::RespondToModel(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + ))) +} 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 b5ea255d89..77ed20dd8e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -9,26 +9,37 @@ use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; use crate::protocol::AgentStatus; use crate::protocol::AskForApproval; +use crate::protocol::EventMsg; use crate::protocol::FileSystemSandboxPolicy; use crate::protocol::NetworkSandboxPolicy; use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::protocol::SessionSource; use crate::protocol::SubAgentSource; +use crate::protocol::TurnCompleteEvent; use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; +use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; +use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; +use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; +use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; +use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use crate::turn_diff_tracker::TurnDiffTracker; use codex_features::Feature; +use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; @@ -74,6 +85,29 @@ fn thread_manager() -> ThreadManager { ) } +fn history_contains_inter_agent_communication( + history_items: &[ResponseItem], + expected: &InterAgentCommunication, +) -> bool { + history_items.iter().any(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "assistant" { + return false; + } + content.iter().any(|content_item| match content_item { + ContentItem::OutputText { text } => { + serde_json::from_str::(text) + .ok() + .as_ref() + == Some(expected) + } + ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false, + }) + }) +} + #[derive(Clone, Copy)] struct NeverEndingTask; @@ -125,6 +159,18 @@ where } } +#[derive(Debug, Deserialize)] +struct ListAgentsResult { + agents: Vec, +} + +#[derive(Debug, Deserialize)] +struct ListedAgentResult { + agent_name: String, + agent_status: serde_json::Value, + last_task_message: Option, +} + #[tokio::test] async fn handler_rejects_non_function_payloads() { let (session, turn) = make_session_and_context().await; @@ -293,7 +339,7 @@ async fn spawn_agent_errors_when_manager_dropped() { } #[tokio::test] -async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() { +async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_path() { #[derive(Debug, Deserialize)] struct SpawnAgentResult { task_name: String, @@ -317,7 +363,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( let session = Arc::new(session); let turn = Arc::new(turn); - let spawn_output = SpawnAgentHandler + let spawn_output = SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -356,68 +402,255 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( Some("/root/test_process") ); - SendInputHandler + SendMessageHandlerV2 .handle(invocation( session.clone(), turn.clone(), - "send_input", + "send_message", function_payload(json!({ "target": "test_process", - "message": "continue" + "items": [{"type": "text", "text": "continue"}] })), )) .await - .expect("send_input should accept v2 path"); + .expect("send_message should accept v2 path"); - let child_thread = manager - .get_thread(child_thread_id) - .await - .expect("child thread should exist"); - timeout(Duration::from_secs(2), async { - loop { - let history_items = child_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::OutputText { text } - if text - == "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue" - )) - ) - }); - let saw_user_message = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::InputText { text } if text == "continue" - )) - ) - }); - if recorded && !saw_user_message { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("v2 send_input should record assistant envelope"); + assert!(manager.captured_ops().iter().any(|(id, op)| { + *id == child_thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/test_process" + && communication.other_recipients.is_empty() + && communication.content == "continue" + && !communication.trigger_turn + ) + })); } #[tokio::test] -async fn multi_agent_v2_send_input_accepts_structured_items() { +async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandlerV2 + .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 _ = expect_text_output(spawn_output); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + let child_thread = manager + .get_thread(agent_id) + .await + .expect("child thread should exist"); + let child_turn = child_thread.codex.session.new_default_turn().await; + child_thread + .codex + .session + .send_event( + child_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: child_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + let output = ListAgentsHandlerV2 + .handle(invocation( + session, + turn, + "list_agents", + function_payload(json!({})), + )) + .await + .expect("list_agents should succeed"); + let (content, success) = expect_text_output(output); + let result: ListAgentsResult = + serde_json::from_str(&content).expect("list_agents result should be json"); + + assert_eq!(result.agents.len(), 1); + assert_eq!(result.agents[0].agent_name, "/root/worker"); + assert_eq!(result.agents[0].agent_status, json!({"completed": "done"})); + assert_eq!( + result.agents[0].last_task_message.as_deref(), + Some("inspect this repo") + ); + assert_eq!(success, Some(true)); +} + +#[tokio::test] +async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config.clone()); + + let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path"); + let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path"); + session + .services + .agent_control + .spawn_agent_with_metadata( + config.clone(), + vec![UserInput::Text { + text: "research".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(researcher_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("researcher agent should spawn"); + session + .services + .agent_control + .spawn_agent_with_metadata( + config, + vec![UserInput::Text { + text: "build".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 2, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker agent should spawn"); + + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(researcher_path), + agent_nickname: None, + agent_role: None, + }); + + let output = ListAgentsHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "list_agents", + function_payload(json!({ + "path_prefix": "worker" + })), + )) + .await + .expect("list_agents should succeed"); + let (content, _) = expect_text_output(output); + let result: ListAgentsResult = + serde_json::from_str(&content).expect("list_agents result should be json"); + + assert_eq!(result.agents.len(), 1); + assert_eq!(result.agents[0].agent_name, worker_path.as_str()); + assert_eq!(result.agents[0].last_task_message.as_deref(), Some("build")); +} + +#[tokio::test] +async fn multi_agent_v2_list_agents_omits_closed_agents() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandlerV2 + .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 _ = expect_text_output(spawn_output); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + session + .services + .agent_control + .close_agent(agent_id) + .await + .expect("close_agent should succeed"); + + let output = ListAgentsHandlerV2 + .handle(invocation( + session, + turn, + "list_agents", + function_payload(json!({})), + )) + .await + .expect("list_agents should succeed"); + let (content, _) = expect_text_output(output); + let result: ListAgentsResult = + serde_json::from_str(&content).expect("list_agents result should be json"); + + assert!(result.agents.is_empty()); +} + +#[tokio::test] +async fn multi_agent_v2_send_message_rejects_structured_items() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -432,7 +665,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { let session = Arc::new(session); let turn = Arc::new(turn); - SpawnAgentHandler + SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -450,14 +683,10 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") .await .expect("worker should resolve"); - let thread = manager - .get_thread(agent_id) - .await - .expect("worker thread should exist"); let invocation = invocation( session, turn, - "send_input", + "send_message", function_payload(json!({ "target": agent_id.to_string(), "items": [ @@ -467,82 +696,19 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { })), ); - SendInputHandler - .handle(invocation) - .await - .expect("structured items should be accepted in v2"); - - let expected = Op::UserInput { - items: vec![ - UserInput::Mention { - name: "drive".to_string(), - path: "app://google_drive".to_string(), - }, - UserInput::Text { - text: "read the folder".to_string(), - text_elements: Vec::new(), - }, - ], - final_output_json_schema: None, + let Err(err) = SendMessageHandlerV2.handle(invocation).await else { + panic!("structured items should be rejected in v2"); }; - let captured = manager - .captured_ops() - .into_iter() - .find(|(id, op)| *id == agent_id && *op == expected); - assert_eq!(captured, Some((agent_id, expected))); - - timeout(Duration::from_secs(2), async { - loop { - let history_items = thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - let recorded_assistant_envelope = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::OutputText { text } - if text - == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder" - )) - ) - }); - let saw_user_message = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "user" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::InputText { text } - if text == "read the folder" - || text == "[mention:$drive](app://google_drive)\nread the folder" - )) - ) - }); - if !recorded_assistant_envelope && saw_user_message { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("structured items should stay on the legacy user-input path"); - - let _ = thread - .submit(Op::Shutdown {}) - .await - .expect("shutdown should submit"); + assert_eq!( + err, + FunctionCallError::RespondToModel( + "send_message only supports text content in MultiAgentV2 for now".to_string() + ) + ); } #[tokio::test] -async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() { +async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_turn() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -557,7 +723,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( let session = Arc::new(session); let turn = Arc::new(turn); - SpawnAgentHandler + SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -594,19 +760,19 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( ) .await; - SendInputHandler + SendMessageHandlerV2 .handle(invocation( - session, - turn, - "send_input", + session.clone(), + turn.clone(), + "send_message", function_payload(json!({ "target": agent_id.to_string(), - "message": "continue", + "items": [{"type": "text", "text": "continue"}], "interrupt": true })), )) .await - .expect("interrupting v2 send_input should succeed"); + .expect("interrupting v2 send_message should succeed"); let ops = manager.captured_ops(); let ops_for_agent: Vec<&Op> = ops @@ -614,14 +780,158 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( .filter_map(|(id, op)| (*id == agent_id).then_some(op)) .collect(); assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); - assert!(!ops_for_agent.iter().any(|op| matches!( - op, - Op::UserInput { items, .. } - if items.iter().any(|item| matches!( - item, - UserInput::Text { text, .. } if text == "continue" - )) - ))); + assert!(ops_for_agent.iter().any(|op| { + matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/worker" + && communication.other_recipients.is_empty() + && communication.content == "continue" + && !communication.trigger_turn + ) + })); + + timeout(Duration::from_secs(5), async { + loop { + if !thread + .codex + .session + .has_queued_response_items_for_next_turn() + .await + { + tokio::time::sleep(Duration::from_millis(10)).await; + continue; + } + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let saw_envelope = history_contains_inter_agent_communication( + &history_items, + &InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "continue".to_string(), + false, + ), + ); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if saw_envelope && !saw_user_message { + panic!("send_message should not materialize the envelope into history"); + } + if !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("interrupting v2 send_message should queue the redirected message without a turn"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = turn.config.as_ref().clone(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "boot worker", + "task_name": "worker" + })), + )) + .await + .expect("spawn worker"); + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker should resolve"); + let thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + + let active_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .spawn_task( + Arc::clone(&active_turn), + vec![UserInput::Text { + text: "working".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask, + ) + .await; + + AssignTaskHandlerV2 + .handle(invocation( + session, + turn, + "assign_task", + function_payload(json!({ + "target": agent_id.to_string(), + "items": [{"type": "text", "text": "continue"}], + "interrupt": true + })), + )) + .await + .expect("interrupting v2 assign_task should succeed"); + + let ops = manager.captured_ops(); + let ops_for_agent: Vec<&Op> = ops + .iter() + .filter_map(|(id, op)| (*id == agent_id).then_some(op)) + .collect(); + assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(ops_for_agent.iter().any(|op| { + matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/worker" + && communication.other_recipients.is_empty() + && communication.content == "continue" + ) + })); timeout(Duration::from_secs(5), async { loop { @@ -632,19 +942,16 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( .await .raw_items() .to_vec(); - let saw_envelope = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::OutputText { text } - if text - == "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue" - )) - ) - }); + let saw_envelope = history_contains_inter_agent_communication( + &history_items, + &InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "continue".to_string(), + true, + ), + ); let saw_user_message = history_items.iter().any(|item| { matches!( item, @@ -663,7 +970,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( } }) .await - .expect("interrupting v2 send_input should preserve the redirected message"); + .expect("interrupting v2 assign_task should preserve the redirected message"); let _ = thread .submit(Op::Shutdown {}) @@ -688,7 +995,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() { .expect("test config should allow feature update"); turn.config = Arc::new(config); - let output = SpawnAgentHandler + let output = SpawnAgentHandlerV2 .handle(invocation( Arc::new(session), Arc::new(turn), @@ -736,7 +1043,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() { "task_name": "BadName" })), ); - let Err(err) = SpawnAgentHandler.handle(invocation).await else { + let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else { panic!("invalid agent name should be rejected"); }; assert_eq!( @@ -1355,17 +1662,17 @@ async fn multi_agent_v2_wait_agent_accepts_targets_argument() { "wait_agent", function_payload(json!({"targets": [target.clone()]})), ); - let output = WaitAgentHandler + let output = WaitAgentHandlerV2 .handle(invocation) .await .expect("targets should be accepted in v2 mode"); let (content, success) = expect_text_output(output); - let result: wait::WaitAgentResult = + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( result, - wait::WaitAgentResult { - status: HashMap::from([(target, AgentStatus::NotFound)]), + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), timed_out: false, } ); @@ -1533,12 +1840,7 @@ async fn wait_agent_returns_final_status_without_timeout() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { - #[derive(Debug, Deserialize)] - struct SpawnAgentResult { - task_name: String, - } - +async fn multi_agent_v2_wait_agent_returns_summary_for_named_targets() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -1556,7 +1858,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { let session = Arc::new(session); let turn = Arc::new(turn); - let spawn_output = SpawnAgentHandler + let spawn_output = SpawnAgentHandlerV2 .handle(invocation( session.clone(), turn.clone(), @@ -1568,9 +1870,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { )) .await .expect("spawn_agent should succeed"); - let (content, _) = expect_text_output(spawn_output); - let spawn_result: SpawnAgentResult = - serde_json::from_str(&content).expect("spawn result should parse"); + let _ = expect_text_output(spawn_output); let agent_id = session .services @@ -1600,7 +1900,7 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { .await .expect("shutdown status should arrive"); - let wait_output = WaitAgentHandler + let wait_output = WaitAgentHandlerV2 .handle(invocation( session, turn, @@ -1613,18 +1913,72 @@ async fn multi_agent_v2_wait_agent_returns_statuses_keyed_by_path() { .await .expect("wait_agent should succeed"); let (content, success) = expect_text_output(wait_output); - let result: wait::WaitAgentResult = + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = serde_json::from_str(&content).expect("wait_agent result should be json"); assert_eq!( result, - wait::WaitAgentResult { - status: HashMap::from([(spawn_result.task_name, AgentStatus::Shutdown)]), + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), timed_out: false, } ); assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config.clone()); + + let thread = manager.start_thread(config).await.expect("start thread"); + let agent_id = thread.thread_id; + let child_turn = thread.thread.codex.session.new_default_turn().await; + thread + .thread + .codex + .session + .send_event( + child_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: child_turn.sub_id.clone(), + last_agent_message: Some("sensitive child output".to_string()), + }), + ) + .await; + + let output = WaitAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({ + "targets": [agent_id.to_string()], + "timeout_ms": 1000 + })), + )) + .await + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait completed.".to_string(), + timed_out: false, + } + ); + assert!(!content.contains("sensitive child output")); + assert_eq!(success, None); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; @@ -1883,7 +2237,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.path().to_path_buf(); + turn.cwd = temp_dir.abs(); turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.sandbox_policy, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs new file mode 100644 index 0000000000..00ee579764 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -0,0 +1,44 @@ +//! Implements the MultiAgentV2 collaboration tool surface. + +use crate::agent::AgentStatus; +use crate::agent::agent_resolver::resolve_agent_target; +use crate::agent::agent_resolver::resolve_agent_targets; +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::codex::Session; +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::multi_agents_common::*; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use async_trait::async_trait; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::CollabAgentInteractionBeginEvent; +use codex_protocol::protocol::CollabAgentInteractionEndEvent; +use codex_protocol::protocol::CollabAgentRef; +use codex_protocol::protocol::CollabAgentSpawnBeginEvent; +use codex_protocol::protocol::CollabAgentSpawnEndEvent; +use codex_protocol::protocol::CollabWaitingBeginEvent; +use codex_protocol::protocol::CollabWaitingEndEvent; +use codex_protocol::user_input::UserInput; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +pub(crate) use assign_task::Handler as AssignTaskHandler; +pub(crate) use list_agents::Handler as ListAgentsHandler; +pub(crate) use send_message::Handler as SendMessageHandler; +pub(crate) use spawn::Handler as SpawnAgentHandler; +pub(crate) use wait::Handler as WaitAgentHandler; + +mod assign_task; +mod list_agents; +mod message_tool; +mod send_message; +mod spawn; +pub(crate) mod wait; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs new file mode 100644 index 0000000000..e55b65b311 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs @@ -0,0 +1,23 @@ +use super::message_tool::MessageDeliveryMode; +use super::message_tool::MessageToolResult; +use super::message_tool::handle_message_tool; +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = MessageToolResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_message_tool(invocation, MessageDeliveryMode::TriggerTurn).await + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs new file mode 100644 index 0000000000..e18547db92 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -0,0 +1,68 @@ +use super::*; +use crate::agent::control::ListedAgent; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = ListAgentsResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ListAgentsArgs = parse_arguments(&arguments)?; + session + .services + .agent_control + .register_session_root(session.conversation_id, &turn.session_source); + let agents = session + .services + .agent_control + .list_agents(&turn.session_source, args.path_prefix.as_deref()) + .await + .map_err(collab_spawn_error)?; + + Ok(ListAgentsResult { agents }) + } +} + +#[derive(Debug, Deserialize)] +struct ListAgentsArgs { + path_prefix: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ListAgentsResult { + agents: Vec, +} + +impl ToolOutput for ListAgentsResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "list_agents") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "list_agents") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "list_agents") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs new file mode 100644 index 0000000000..48c7615e70 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -0,0 +1,175 @@ +//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. +//! +//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in +//! whether the resulting `InterAgentCommunication` should wake the target immediately. + +use super::*; +use codex_protocol::protocol::InterAgentCommunication; + +#[derive(Clone, Copy)] +pub(crate) enum MessageDeliveryMode { + QueueOnly, + TriggerTurn, +} + +impl MessageDeliveryMode { + /// Returns the model-visible error message for non-text inputs. + fn unsupported_items_error(self) -> &'static str { + match self { + Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now", + Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now", + } + } + + /// Returns whether the produced communication should start a turn immediately. + fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication { + match self { + Self::QueueOnly => InterAgentCommunication { + trigger_turn: false, + ..communication + }, + Self::TriggerTurn => InterAgentCommunication { + trigger_turn: true, + ..communication + }, + } + } +} + +#[derive(Debug, Deserialize)] +/// Input shared by the MultiAgentV2 `send_message` and `assign_task` tools. +pub(crate) struct MessageToolArgs { + pub(crate) target: String, + pub(crate) items: Vec, + #[serde(default)] + pub(crate) interrupt: bool, +} + +#[derive(Debug, Serialize)] +/// Tool result shared by the MultiAgentV2 message-delivery tools. +pub(crate) struct MessageToolResult { + submission_id: String, +} + +impl ToolOutput for MessageToolResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "multi_agent_message") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "multi_agent_message") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "multi_agent_message") + } +} + +/// Validates that the tool input is non-empty text-only content and returns its preview string. +fn text_content( + items: &[UserInput], + mode: MessageDeliveryMode, +) -> Result { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) + { + return Ok(input_preview(items)); + } + Err(FunctionCallError::RespondToModel( + mode.unsupported_items_error().to_string(), + )) +} + +/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`. +pub(crate) async fn handle_message_tool( + invocation: ToolInvocation, + mode: MessageDeliveryMode, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: MessageToolArgs = parse_arguments(&arguments)?; + let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; + let prompt = text_content(&args.items, mode)?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + if args.interrupt { + session + .services + .agent_control + .interrupt_agent(receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + session + .send_event( + &turn, + CollabAgentInteractionBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + prompt: prompt.clone(), + } + .into(), + ) + .await; + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) + })?; + let communication = InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + receiver_agent_path, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ); + let result = session + .services + .agent_control + .send_inter_agent_communication(receiver_thread_id, mode.apply(communication)) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + session + .send_event( + &turn, + CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + prompt, + status, + } + .into(), + ) + .await; + let submission_id = result?; + + Ok(MessageToolResult { submission_id }) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs new file mode 100644 index 0000000000..ed10b124a6 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -0,0 +1,23 @@ +use super::message_tool::MessageDeliveryMode; +use super::message_tool::MessageToolResult; +use super::message_tool::handle_message_tool; +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = MessageToolResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_message_tool(invocation, MessageDeliveryMode::QueueOnly).await + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs new file mode 100644 index 0000000000..d67be936ec --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::agent::control::SpawnAgentOptions; +use crate::agent::next_thread_spawn_depth; +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::apply_role_to_config; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = SpawnAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let result = session + .services + .agent_control + .spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + args.task_name.clone(), + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + }, + ) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + let task_name = new_agent_path.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: task_name.is_none().then(|| new_thread_id.to_string()), + task_name, + nickname, + }) + } +} + +#[derive(Debug, Deserialize)] +struct SpawnAgentArgs { + message: Option, + items: Option>, + task_name: Option, + agent_type: Option, + model: Option, + reasoning_effort: Option, + #[serde(default)] + fork_context: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SpawnAgentResult { + agent_id: Option, + task_name: Option, + nickname: Option, +} + +impl ToolOutput for SpawnAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "spawn_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "spawn_agent") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs new file mode 100644 index 0000000000..1b1dc90e42 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::agent::status::is_final; +use futures::FutureExt; +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use std::collections::HashMap; +use std::time::Duration; +use tokio::sync::watch::Receiver; +use tokio::time::Instant; +use tokio::time::timeout_at; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = WaitAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: WaitArgs = parse_arguments(&arguments)?; + let receiver_thread_ids = resolve_agent_targets(&session, &turn, args.targets).await?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let agent_metadata = session + .services + .agent_control + .get_agent_metadata(*receiver_thread_id) + .unwrap_or_default(); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname: agent_metadata.agent_nickname, + agent_role: agent_metadata.agent_role, + }); + } + + let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); + let timeout_ms = match timeout_ms { + ms if ms <= 0 => { + return Err(FunctionCallError::RespondToModel( + "timeout_ms must be greater than zero".to_owned(), + )); + } + ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), + }; + + session + .send_event( + &turn, + CollabWaitingBeginEvent { + sender_thread_id: session.conversation_id, + receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), + call_id: call_id.clone(), + } + .into(), + ) + .await; + + let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); + let mut initial_final_statuses = Vec::new(); + for id in &receiver_thread_ids { + match session.services.agent_control.subscribe_status(*id).await { + Ok(rx) => { + let status = rx.borrow().clone(); + if is_final(&status) { + initial_final_statuses.push((*id, status)); + } + status_rxs.push((*id, rx)); + } + Err(crate::error::CodexErr::ThreadNotFound(_)) => { + initial_final_statuses.push((*id, AgentStatus::NotFound)); + } + Err(err) => { + let mut statuses = HashMap::with_capacity(1); + statuses.insert(*id, session.services.agent_control.get_status(*id).await); + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), + statuses, + } + .into(), + ) + .await; + return Err(collab_agent_error(*id, err)); + } + } + } + + let statuses = if !initial_final_statuses.is_empty() { + initial_final_statuses + } else { + let mut futures = FuturesUnordered::new(); + for (id, rx) in status_rxs { + let session = session.clone(); + futures.push(wait_for_final_status(session, id, rx)); + } + let mut results = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + match timeout_at(deadline, futures.next()).await { + Ok(Some(Some(result))) => { + results.push(result); + break; + } + Ok(Some(None)) => continue, + Ok(None) | Err(_) => break, + } + } + if !results.is_empty() { + loop { + match futures.next().now_or_never() { + Some(Some(Some(result))) => results.push(result), + Some(Some(None)) => continue, + Some(None) | None => break, + } + } + } + results + }; + + let timed_out = statuses.is_empty(); + let statuses_by_id = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_by_id, &receiver_agents); + let result = WaitAgentResult::from_timed_out(timed_out); + + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id, + agent_statuses, + statuses: statuses_by_id, + } + .into(), + ) + .await; + + Ok(result) + } +} + +#[derive(Debug, Deserialize)] +struct WaitArgs { + #[serde(default)] + targets: Vec, + timeout_ms: Option, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct WaitAgentResult { + pub(crate) message: String, + pub(crate) timed_out: bool, +} + +impl WaitAgentResult { + fn from_timed_out(timed_out: bool) -> Self { + let message = if timed_out { + "Wait timed out." + } else { + "Wait completed." + }; + Self { + message: message.to_string(), + timed_out, + } + } +} + +impl ToolOutput for WaitAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "wait_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, /*success*/ None, "wait_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "wait_agent") + } +} + +async fn wait_for_final_status( + session: std::sync::Arc, + thread_id: ThreadId, + mut status_rx: Receiver, +) -> Option<(ThreadId, AgentStatus)> { + let mut status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + + loop { + if status_rx.changed().await.is_err() { + let latest = session.services.agent_control.get_status(thread_id).await; + return is_final(&latest).then_some((thread_id, latest)); + } + status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + } +} diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs deleted file mode 100644 index b868edf5b9..0000000000 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ /dev/null @@ -1,489 +0,0 @@ -use std::collections::VecDeque; -use std::path::PathBuf; - -use async_trait::async_trait; -use codex_utils_string::take_bytes_at_char_boundary; -use serde::Deserialize; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct ReadFileHandler; - -const MAX_LINE_LENGTH: usize = 500; -const TAB_WIDTH: usize = 4; - -// TODO(jif) add support for block comments -const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"]; - -/// JSON arguments accepted by the `read_file` tool handler. -#[derive(Deserialize)] -struct ReadFileArgs { - /// Absolute path to the file that will be read. - file_path: String, - /// 1-indexed line number to start reading from; defaults to 1. - #[serde(default = "defaults::offset")] - offset: usize, - /// Maximum number of lines to return; defaults to 2000. - #[serde(default = "defaults::limit")] - limit: usize, - /// Determines whether the handler reads a simple slice or indentation-aware block. - #[serde(default)] - mode: ReadMode, - /// Optional indentation configuration used when `mode` is `Indentation`. - #[serde(default)] - indentation: Option, -} - -#[derive(Deserialize, Default)] -#[serde(rename_all = "snake_case")] -enum ReadMode { - #[default] - Slice, - Indentation, -} -/// Additional configuration for indentation-aware reads. -#[derive(Deserialize, Clone)] -struct IndentationArgs { - /// Optional explicit anchor line; defaults to `offset` when omitted. - #[serde(default)] - anchor_line: Option, - /// Maximum indentation depth to collect; `0` means unlimited. - #[serde(default = "defaults::max_levels")] - max_levels: usize, - /// Whether to include sibling blocks at the same indentation level. - #[serde(default = "defaults::include_siblings")] - include_siblings: bool, - /// Whether to include header lines above the anchor block. This made on a best effort basis. - #[serde(default = "defaults::include_header")] - include_header: bool, - /// Optional hard cap on returned lines; defaults to the global `limit`. - #[serde(default)] - max_lines: Option, -} - -#[derive(Clone, Debug)] -struct LineRecord { - number: usize, - raw: String, - display: String, - indent: usize, -} - -impl LineRecord { - fn trimmed(&self) -> &str { - self.raw.trim_start() - } - - fn is_blank(&self) -> bool { - self.trimmed().is_empty() - } - - fn is_comment(&self) -> bool { - COMMENT_PREFIXES - .iter() - .any(|prefix| self.raw.trim().starts_with(prefix)) - } -} - -#[async_trait] -impl ToolHandler for ReadFileHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "read_file handler received unsupported payload".to_string(), - )); - } - }; - - let args: ReadFileArgs = parse_arguments(&arguments)?; - - let ReadFileArgs { - file_path, - offset, - limit, - mode, - indentation, - } = args; - - if offset == 0 { - return Err(FunctionCallError::RespondToModel( - "offset must be a 1-indexed line number".to_string(), - )); - } - - if limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let path = PathBuf::from(&file_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "file_path must be an absolute path".to_string(), - )); - } - - let collected = match mode { - ReadMode::Slice => slice::read(&path, offset, limit).await?, - ReadMode::Indentation => { - let indentation = indentation.unwrap_or_default(); - indentation::read_block(&path, offset, limit, indentation).await? - } - }; - Ok(FunctionToolOutput::from_text( - collected.join("\n"), - Some(true), - )) - } -} - -mod slice { - use crate::function_tool::FunctionCallError; - use crate::tools::handlers::read_file::format_line; - use std::path::Path; - use tokio::fs::File; - use tokio::io::AsyncBufReadExt; - use tokio::io::BufReader; - - pub async fn read( - path: &Path, - offset: usize, - limit: usize, - ) -> Result, FunctionCallError> { - let file = File::open(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - let mut reader = BufReader::new(file); - let mut collected = Vec::new(); - let mut seen = 0usize; - let mut buffer = Vec::new(); - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - if bytes_read == 0 { - break; - } - - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); - } - } - - seen += 1; - - if seen < offset { - continue; - } - - if collected.len() == limit { - break; - } - - let formatted = format_line(&buffer); - collected.push(format!("L{seen}: {formatted}")); - - if collected.len() == limit { - break; - } - } - - if seen < offset { - return Err(FunctionCallError::RespondToModel( - "offset exceeds file length".to_string(), - )); - } - - Ok(collected) - } -} - -mod indentation { - use crate::function_tool::FunctionCallError; - use crate::tools::handlers::read_file::IndentationArgs; - use crate::tools::handlers::read_file::LineRecord; - use crate::tools::handlers::read_file::TAB_WIDTH; - use crate::tools::handlers::read_file::format_line; - use crate::tools::handlers::read_file::trim_empty_lines; - use std::collections::VecDeque; - use std::path::Path; - use tokio::fs::File; - use tokio::io::AsyncBufReadExt; - use tokio::io::BufReader; - - pub async fn read_block( - path: &Path, - offset: usize, - limit: usize, - options: IndentationArgs, - ) -> Result, FunctionCallError> { - let anchor_line = options.anchor_line.unwrap_or(offset); - if anchor_line == 0 { - return Err(FunctionCallError::RespondToModel( - "anchor_line must be a 1-indexed line number".to_string(), - )); - } - - let guard_limit = options.max_lines.unwrap_or(limit); - if guard_limit == 0 { - return Err(FunctionCallError::RespondToModel( - "max_lines must be greater than zero".to_string(), - )); - } - - let collected = collect_file_lines(path).await?; - if collected.is_empty() || anchor_line > collected.len() { - return Err(FunctionCallError::RespondToModel( - "anchor_line exceeds file length".to_string(), - )); - } - - let anchor_index = anchor_line - 1; - let effective_indents = compute_effective_indents(&collected); - let anchor_indent = effective_indents[anchor_index]; - - // Compute the min indent - let min_indent = if options.max_levels == 0 { - 0 - } else { - anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH) - }; - - // Cap requested lines by guard_limit and file length - let final_limit = limit.min(guard_limit).min(collected.len()); - - if final_limit == 1 { - return Ok(vec![format!( - "L{}: {}", - collected[anchor_index].number, collected[anchor_index].display - )]); - } - - // Cursors - let mut i: isize = anchor_index as isize - 1; // up (inclusive) - let mut j: usize = anchor_index + 1; // down (inclusive) - let mut i_counter_min_indent = 0; - let mut j_counter_min_indent = 0; - - let mut out = VecDeque::with_capacity(limit); - out.push_back(&collected[anchor_index]); - - while out.len() < final_limit { - let mut progressed = 0; - - // Up. - if i >= 0 { - let iu = i as usize; - if effective_indents[iu] >= min_indent { - out.push_front(&collected[iu]); - progressed += 1; - i -= 1; - - // We do not include the siblings (not applied to comments). - if effective_indents[iu] == min_indent && !options.include_siblings { - let allow_header_comment = - options.include_header && collected[iu].is_comment(); - let can_take_line = allow_header_comment || i_counter_min_indent == 0; - - if can_take_line { - i_counter_min_indent += 1; - } else { - // This line shouldn't have been taken. - out.pop_front(); - progressed -= 1; - i = -1; // consider using Option or a control flag instead of a sentinel - } - } - - // Short-cut. - if out.len() >= final_limit { - break; - } - } else { - // Stop moving up. - i = -1; - } - } - - // Down. - if j < collected.len() { - let ju = j; - if effective_indents[ju] >= min_indent { - out.push_back(&collected[ju]); - progressed += 1; - j += 1; - - // We do not include the siblings (applied to comments). - if effective_indents[ju] == min_indent && !options.include_siblings { - if j_counter_min_indent > 0 { - // This line shouldn't have been taken. - out.pop_back(); - progressed -= 1; - j = collected.len(); - } - j_counter_min_indent += 1; - } - } else { - // Stop moving down. - j = collected.len(); - } - } - - if progressed == 0 { - break; - } - } - - // Trim empty lines - trim_empty_lines(&mut out); - - Ok(out - .into_iter() - .map(|record| format!("L{}: {}", record.number, record.display)) - .collect()) - } - - async fn collect_file_lines(path: &Path) -> Result, FunctionCallError> { - let file = File::open(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut lines = Vec::new(); - let mut number = 0usize; - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - if bytes_read == 0 { - break; - } - - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); - } - } - - number += 1; - let raw = String::from_utf8_lossy(&buffer).into_owned(); - let indent = measure_indent(&raw); - let display = format_line(&buffer); - lines.push(LineRecord { - number, - raw, - display, - indent, - }); - } - - Ok(lines) - } - - fn compute_effective_indents(records: &[LineRecord]) -> Vec { - let mut effective = Vec::with_capacity(records.len()); - let mut previous_indent = 0usize; - for record in records { - if record.is_blank() { - effective.push(previous_indent); - } else { - previous_indent = record.indent; - effective.push(previous_indent); - } - } - effective - } - - fn measure_indent(line: &str) -> usize { - line.chars() - .take_while(|c| matches!(c, ' ' | '\t')) - .map(|c| if c == '\t' { TAB_WIDTH } else { 1 }) - .sum() - } -} - -fn format_line(bytes: &[u8]) -> String { - let decoded = String::from_utf8_lossy(bytes); - if decoded.len() > MAX_LINE_LENGTH { - take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string() - } else { - decoded.into_owned() - } -} - -fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) { - while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) { - out.pop_front(); - } - while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) { - out.pop_back(); - } -} - -mod defaults { - use super::*; - - impl Default for IndentationArgs { - fn default() -> Self { - Self { - anchor_line: None, - max_levels: max_levels(), - include_siblings: include_siblings(), - include_header: include_header(), - max_lines: None, - } - } - } - - pub fn offset() -> usize { - 1 - } - - pub fn limit() -> usize { - 2000 - } - - pub fn max_levels() -> usize { - 0 - } - - pub fn include_siblings() -> bool { - false - } - - pub fn include_header() -> bool { - true - } -} - -#[cfg(test)] -#[path = "read_file_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 2ba41ed9aa..4df8328e70 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -1,8 +1,8 @@ use async_trait::async_trait; use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_sandboxing::policy_transforms::normalize_additional_permissions; use crate::function_tool::FunctionCallError; -use crate::sandboxing::normalize_additional_permissions; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 533f12c4f1..8a98bd2b03 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -310,7 +310,7 @@ fn verified_plugin_suggestion_completed( .list_marketplaces_for_config(config, &[]) .ok() .into_iter() - .flatten() + .flat_map(|outcome| outcome.marketplaces) .flat_map(|marketplace| marketplace.plugins.into_iter()) .any(|plugin| plugin.id == tool_id && plugin.installed) } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 109ac713c4..54543277f5 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -25,6 +25,8 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_protocol::models::PermissionProfile; use serde::Deserialize; use std::path::PathBuf; @@ -260,6 +262,7 @@ impl ToolHandler for UnifiedExecHandler { }); } + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); manager .exec_command( ExecCommandRequest { @@ -323,6 +326,14 @@ impl ToolHandler for UnifiedExecHandler { } } +fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { + session_telemetry.counter( + TOOL_CALL_UNIFIED_EXEC_METRIC, + /*inc*/ 1, + &[("tty", if tty { "true" } else { "false" })], + ); +} + pub(crate) fn get_command( args: &ExecCommandArgs, session_shell: Arc, diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 4f7c3d7436..266aa2a1ce 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -39,14 +39,15 @@ use crate::exec::ExecExpiration; use crate::exec_env::create_env; use crate::function_tool::FunctionCallError; use crate::original_image_detail::normalize_output_image_detail; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxManager; -use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::ExecOptions; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::sandboxing::SandboxablePreference; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxablePreference; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:"; const KERNEL_SOURCE: &str = include_str!("kernel.js"); @@ -1029,21 +1030,6 @@ impl JsReplManager { ); } - let spec = CommandSpec { - program: node_path.to_string_lossy().to_string(), - args: vec![ - "--experimental-vm-modules".to_string(), - kernel_path.to_string_lossy().to_string(), - ], - cwd: turn.cwd.clone(), - env, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: SandboxPermissions::UseDefault, - additional_permissions: None, - justification: None, - }; - let sandbox = SandboxManager::new(); let has_managed_network_requirements = turn .config @@ -1058,9 +1044,23 @@ impl JsReplManager { turn.windows_sandbox_level, has_managed_network_requirements, ); + let command = SandboxCommand { + program: node_path.to_string_lossy().to_string(), + args: vec![ + "--experimental-vm-modules".to_string(), + kernel_path.to_string_lossy().to_string(), + ], + cwd: turn.cwd.to_path_buf(), + env, + additional_permissions: None, + }; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + }; let exec_env = sandbox - .transform(crate::sandboxing::SandboxTransformRequest { - spec, + .transform(SandboxTransformRequest { + command, policy: &turn.sandbox_policy, file_system_policy: &turn.file_system_sandbox_policy, network_policy: turn.network_sandbox_policy, @@ -1078,6 +1078,9 @@ impl JsReplManager { .permissions .windows_sandbox_private_desktop, }) + .map(|request| { + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options) + }) .map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?; let mut cmd = diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index 413e171d41..c759554009 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -14,6 +14,8 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -739,7 +741,7 @@ async fn interrupt_active_exec_stops_aborted_kernel_before_later_exec() -> anyho let dir = tempdir()?; let (session, mut turn) = make_session_and_context().await; - turn.cwd = dir.path().to_path_buf(); + turn.cwd = dir.abs(); set_danger_full_access(&mut turn); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1017,7 +1019,7 @@ async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::R let marker = turn .cwd - .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4()))?; let marker_json = serde_json::to_string(&marker.to_string_lossy().to_string())?; let result = manager .execute( @@ -1062,10 +1064,10 @@ async fn js_repl_persisted_tool_helpers_work_across_cells() -> anyhow::Result<() let global_marker = turn .cwd - .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4()))?; let lexical_marker = turn .cwd - .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4()))?; let global_marker_json = serde_json::to_string(&global_marker.to_string_lossy().to_string())?; let lexical_marker_json = serde_json::to_string(&lexical_marker.to_string_lossy().to_string())?; @@ -2101,7 +2103,7 @@ async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<() "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), env_base.path().to_string_lossy().to_string(), ); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![config_base.path().to_path_buf()], @@ -2145,7 +2147,7 @@ async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![ @@ -2189,7 +2191,7 @@ async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![config_base.path().to_path_buf()], @@ -2230,7 +2232,7 @@ async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![base_dir.path().join("node_modules")], @@ -2284,7 +2286,7 @@ async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2331,7 +2333,7 @@ async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2385,7 +2387,7 @@ async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Resul turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2429,7 +2431,7 @@ async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2485,7 +2487,7 @@ async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result< turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2563,7 +2565,7 @@ async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<() turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2648,7 +2650,7 @@ async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2693,7 +2695,7 @@ async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2795,7 +2797,7 @@ async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow:: turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2845,7 +2847,7 @@ async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow: turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.clone(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 4e495190ec..87a01c8e3b 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -15,9 +15,9 @@ pub mod sandboxing; pub mod spec; use crate::exec::ExecToolCallOutput; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text; -use crate::truncate::truncate_text; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::formatted_truncate_text; +use codex_utils_output_truncation::truncate_text; pub use router::ToolRouter; use serde::Serialize; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9e92a6b00f..02b6bbe601 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -379,7 +379,7 @@ impl NetworkApprovalService { approval_id, /*approval_id*/ None, prompt_command, - turn_context.cwd.clone(), + turn_context.cwd.to_path_buf(), Some(prompt_reason), Some(network_approval_context.clone()), /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4b53ac156f..09f0dc5959 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -12,7 +12,6 @@ use crate::exec::ExecToolCallOutput; use crate::guardian::GUARDIAN_REJECTION_MESSAGE; use crate::guardian::routes_approval_to_guardian; use crate::network_policy_decision::network_approval_context_from_payload; -use crate::sandboxing::SandboxManager; use crate::tools::network_approval::DeferredNetworkApproval; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::begin_network_approval; @@ -30,6 +29,8 @@ use codex_otel::ToolDecisionSource; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxType; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -178,7 +179,7 @@ impl ToolOrchestrator { .network .is_some(); let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) { - SandboxOverride::BypassSandboxFirstAttempt => crate::exec::SandboxType::None, + SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None, SandboxOverride::NoOverride => self.sandbox.select_initial( &turn_ctx.file_system_sandbox_policy, turn_ctx.network_sandbox_policy, @@ -188,8 +189,7 @@ impl ToolOrchestrator { ), }; - // Platform-specific flag gating is handled by SandboxManager::select_initial - // via crate::safety::get_platform_sandbox(..). + // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, @@ -323,7 +323,7 @@ impl ToolOrchestrator { } let escalated_attempt = SandboxAttempt { - sandbox: crate::exec::SandboxType::None, + sandbox: SandboxType::None, policy: &turn_ctx.sandbox_policy, file_system_policy: &turn_ctx.file_system_sandbox_policy, network_policy: turn_ctx.network_sandbox_policy, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index bcee62a044..df97d7128d 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,6 +5,7 @@ use std::time::Instant; use crate::client_common::tools::ToolSpec; use crate::function_tool::FunctionCallError; +use crate::hook_runtime::run_pre_tool_use_hooks; use crate::memories::usage::emit_metric_for_tool_read; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; @@ -20,7 +21,10 @@ use codex_hooks::HookToolInput; use codex_hooks::HookToolInputLocalShell; use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ShellCommandToolCallParams; +use codex_protocol::models::ShellToolCallParams; use codex_utils_readiness::Readiness; +use serde::Deserialize; use tracing::warn; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -243,6 +247,20 @@ impl ToolRegistry { return Err(FunctionCallError::Fatal(message)); } + if let Some(command) = pre_tool_use_command(tool_name.as_ref(), &invocation.payload) + && let Some(reason) = run_pre_tool_use_hooks( + &invocation.session, + &invocation.turn, + invocation.call_id.clone(), + command.clone(), + ) + .await + { + return Err(FunctionCallError::RespondToModel(format!( + "Bash command blocked by hook: {reason}. Command: {command}" + ))); + } + let is_mutating = handler.is_mutating(&invocation).await; let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); @@ -413,6 +431,35 @@ fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str { } } +#[derive(Deserialize)] +struct PreToolUseExecCommandArgs { + cmd: String, +} + +fn pre_tool_use_command(tool_name: &str, payload: &ToolPayload) -> Option { + match (tool_name, payload) { + ("shell" | "container.exec", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) + } + ("local_shell", ToolPayload::LocalShell { params }) => Some( + codex_shell_command::parse_command::shlex_join(¶ms.command), + ), + ("shell_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.command) + } + ("exec_command", ToolPayload::Function { arguments }) => { + serde_json::from_str::(arguments) + .ok() + .map(|params| params.cmd) + } + _ => None, + } +} + // Hooks use a separate wire-facing input type so hook payload JSON stays stable // and decoupled from core's internal tool runtime representation. impl From<&ToolPayload> for HookToolInput { @@ -483,7 +530,7 @@ async fn dispatch_after_tool_use_hook( .hooks() .dispatch(HookPayload { session_id: session.conversation_id, - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), client: turn.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterToolUse { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 5d9e98df35..46e7d0c3f6 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; use async_trait::async_trait; +use codex_protocol::models::ShellToolCallParams; use pretty_assertions::assert_eq; struct TestHandler; @@ -48,3 +50,63 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } + +#[test] +fn pre_tool_use_command_uses_raw_shell_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "command": "printf shell command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("shell_command", &payload), + Some("printf shell command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_shell_joins_vector_input() { + let payload = ToolPayload::LocalShell { + params: ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf hi".to_string(), + ], + workdir: None, + timeout_ms: None, + sandbox_permissions: None, + prefix_rule: None, + additional_permissions: None, + justification: None, + }, + }; + + assert_eq!( + pre_tool_use_command("local_shell", &payload), + Some("bash -lc 'printf hi'".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_uses_raw_exec_command_input() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ "cmd": "printf exec command" }).to_string(), + }; + + assert_eq!( + pre_tool_use_command("exec_command", &payload), + Some("printf exec command".to_string()) + ); +} + +#[test] +fn pre_tool_use_command_skips_non_shell_tools() { + let payload = ToolPayload::Function { + arguments: serde_json::json!({ + "plan": [{ "step": "watch the tide", "status": "pending" }] + }) + .to_string(), + }; + + assert_eq!(pre_tool_use_command("update_plan", &payload), None); +} diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index f1e9912bc5..7f5686e47d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -9,15 +9,13 @@ use crate::exec::ExecToolCallOutput; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::ExecOptions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; @@ -28,6 +26,8 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; @@ -39,7 +39,6 @@ pub struct ApplyPatchRequest { pub file_paths: Vec, pub changes: std::collections::HashMap, pub exec_approval_requirement: ExecApprovalRequirement, - pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, pub permissions_preapproved: bool, pub timeout_ms: Option, @@ -67,10 +66,10 @@ impl ApplyPatchRuntime { } } - fn build_command_spec( + fn build_sandbox_command( req: &ApplyPatchRequest, _codex_home: &std::path::Path, - ) -> Result { + ) -> Result { let exe = if let Some(path) = &req.codex_exe { path.clone() } else { @@ -85,21 +84,16 @@ impl ApplyPatchRuntime { })? } }; - let program = exe.to_string_lossy().to_string(); - Ok(CommandSpec { - program, + Ok(SandboxCommand { + program: exe.to_string_lossy().to_string(), args: vec![ CODEX_CORE_APPLY_PATCH_ARG1.to_string(), req.action.patch.clone(), ], cwd: req.action.cwd.clone(), - expiration: req.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), - justification: None, }) } @@ -206,9 +200,13 @@ impl ToolRuntime for ApplyPatchRuntime { attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, ) -> Result { - let spec = Self::build_command_spec(req, &ctx.turn.config.codex_home)?; + let command = Self::build_sandbox_command(req, &ctx.turn.config.codex_home)?; + let options = ExecOptions { + expiration: req.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + }; let env = attempt - .env_for(spec, /*network*/ None) + .env_for(command, options, /*network*/ None) .map_err(|err| ToolError::Codex(err.into()))?; let out = execute_env(env, Self::stdout_stream(ctx)) .await diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index d2812b5ecf..79e35a9c2d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -48,7 +48,6 @@ fn guardian_review_request_includes_patch_context() { reason: None, proposed_execpolicy_amendment: None, }, - sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, permissions_preapproved: false, timeout_ms: None, diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 2335a13ab7..bc2f56a430 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -4,15 +4,12 @@ Module: runtimes Concrete ToolRuntime implementations for specific tools. Each runtime stays small and focused and reuses the orchestrator for approvals + sandbox + retry. */ -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecExpiration; use crate::path_utils; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::skills::SkillMetadata; use crate::tools::sandboxing::ToolError; use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxCommand; use std::collections::HashMap; use std::path::Path; @@ -28,30 +25,23 @@ pub(crate) struct ExecveSessionApproval { pub skill: Option, } -/// Shared helper to construct a CommandSpec from a tokenized command line. +/// Shared helper to construct sandbox transform inputs from a tokenized command line. /// Validates that at least a program is present. -pub(crate) fn build_command_spec( +pub(crate) fn build_sandbox_command( command: &[String], cwd: &Path, env: &HashMap, - expiration: ExecExpiration, - sandbox_permissions: SandboxPermissions, additional_permissions: Option, - justification: Option, -) -> Result { +) -> Result { let (program, args) = command .split_first() .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; - Ok(CommandSpec { + Ok(SandboxCommand { program: program.clone(), args: args.to_vec(), cwd: cwd.to_path_buf(), env: env.clone(), - expiration, - capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions, additional_permissions, - justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6ff8349b53..ecb5822124 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -2,24 +2,26 @@ Runtime: shell Executes shell requests under the orchestrator: asks for approval when needed, -builds a CommandSpec, and runs it under the current SandboxAttempt. +builds sandbox transform inputs, and runs them under the current SandboxAttempt. */ #[cfg(unix)] pub(crate) mod unix_escalation; pub(crate) mod zsh_fork_backend; use crate::command_canonicalization::canonicalize_command_for_approval; +use crate::exec::ExecCapturePolicy; use crate::exec::ExecToolCallOutput; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; -use crate::tools::runtimes::build_command_spec; +use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -27,7 +29,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; @@ -36,6 +37,7 @@ use crate::tools::sandboxing::with_cached_approval; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxablePreference; use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -243,17 +245,18 @@ impl ToolRuntime for ShellRuntime { } } - let spec = build_command_spec( + let command = build_sandbox_command( &command, &req.cwd, &req.env, - req.timeout_ms.into(), - req.sandbox_permissions, req.additional_permissions.clone(), - req.justification.clone(), )?; + let options = ExecOptions { + expiration: req.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + }; let env = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; let out = execute_env(env, Self::stdout_stream(ctx)) .await 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 948018dae6..9df94e828f 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -4,19 +4,18 @@ use crate::error::SandboxErr; use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::is_likely_sandbox_denied; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; +use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::skills::SkillMetadata; use crate::tools::runtimes::ExecveSessionApproval; -use crate::tools::runtimes::build_command_spec; +use crate::tools::runtimes::build_sandbox_command; use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use codex_execpolicy::Decision; @@ -35,6 +34,11 @@ use codex_protocol::protocol::ExecApprovalRequestSkillMetadata; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_command::bash::parse_shell_lc_single_command_prefix; use codex_shell_escalation::EscalateServer; @@ -107,17 +111,18 @@ pub(super) async fn try_run_zsh_fork( return Ok(None); } - let spec = build_command_spec( + let command = build_sandbox_command( command, &req.cwd, &req.env, - req.timeout_ms.into(), - req.sandbox_permissions, req.additional_permissions.clone(), - req.justification.clone(), )?; + let options = ExecOptions { + expiration: req.timeout_ms.into(), + capture_policy: ExecCapturePolicy::ShellTool, + }; let sandbox_exec_request = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; let crate::sandboxing::ExecRequest { command, @@ -129,11 +134,10 @@ pub(super) async fn try_run_zsh_fork( sandbox, windows_sandbox_level, windows_sandbox_private_desktop: _windows_sandbox_private_desktop, - sandbox_permissions, sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - justification, + windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay, arg0, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; @@ -154,10 +158,8 @@ pub(super) async fn try_run_zsh_fork( env: sandbox_env, network: sandbox_network, windows_sandbox_level, - sandbox_permissions, - justification, arg0, - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), macos_seatbelt_profile_extensions: ctx .turn .config @@ -260,10 +262,8 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( env: exec_request.env.clone(), network: exec_request.network.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, - sandbox_permissions: exec_request.sandbox_permissions, - justification: exec_request.justification.clone(), arg0: exec_request.arg0.clone(), - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), macos_seatbelt_profile_extensions: ctx .turn .config @@ -857,8 +857,6 @@ struct CoreShellCommandExecutor { env: HashMap, network: Option, windows_sandbox_level: WindowsSandboxLevel, - sandbox_permissions: SandboxPermissions, - justification: Option, arg0: Option, sandbox_policy_cwd: PathBuf, #[cfg_attr(not(target_os = "macos"), allow(dead_code))] @@ -892,7 +890,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { let mut exec_env = self.env.clone(); // `env_overlay` comes from `EscalationSession::env()`, so merge only the // wrapper/socket variables into the base shell environment. - for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER", "BASH_EXEC_WRAPPER"] { + for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER"] { if let Some(value) = env_overlay.get(var) { exec_env.insert(var.to_string(), value.clone()); } @@ -909,11 +907,10 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { sandbox: self.sandbox, windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, - sandbox_permissions: self.sandbox_permissions, sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, - justification: self.justification.clone(), + windows_restricted_token_filesystem_overlay: None, arg0: self.arg0.clone(), }, /*stdout_stream*/ None, @@ -1029,7 +1026,7 @@ impl CoreShellCommandExecutor { let (program, args) = command .split_first() .ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?; - let sandbox_manager = crate::sandboxing::SandboxManager::new(); + let sandbox_manager = SandboxManager::new(); let sandbox = sandbox_manager.select_initial( file_system_sandbox_policy, network_sandbox_policy, @@ -1037,37 +1034,35 @@ impl CoreShellCommandExecutor { self.windows_sandbox_level, self.network.is_some(), ); + let command = SandboxCommand { + program: program.clone(), + args: args.to_vec(), + cwd: workdir.to_path_buf(), + env, + additional_permissions, + }; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + }; + let exec_request = sandbox_manager.transform(SandboxTransformRequest { + command, + policy: sandbox_policy, + file_system_policy: file_system_sandbox_policy, + network_policy: network_sandbox_policy, + sandbox, + enforce_managed_network: self.network.is_some(), + network: self.network.as_ref(), + sandbox_policy_cwd: &self.sandbox_policy_cwd, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(), + use_legacy_landlock: self.use_legacy_landlock, + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: false, + })?; let mut exec_request = - sandbox_manager.transform(crate::sandboxing::SandboxTransformRequest { - spec: crate::sandboxing::CommandSpec { - program: program.clone(), - args: args.to_vec(), - cwd: workdir.to_path_buf(), - env, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: if additional_permissions.is_some() { - SandboxPermissions::WithAdditionalPermissions - } else { - SandboxPermissions::UseDefault - }, - additional_permissions, - justification: self.justification.clone(), - }, - policy: sandbox_policy, - file_system_policy: file_system_sandbox_policy, - network_policy: network_sandbox_policy, - sandbox, - enforce_managed_network: self.network.is_some(), - network: self.network.as_ref(), - sandbox_policy_cwd: &self.sandbox_policy_cwd, - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(), - use_legacy_landlock: self.use_legacy_landlock, - windows_sandbox_level: self.windows_sandbox_level, - windows_sandbox_private_desktop: false, - })?; + crate::sandboxing::ExecRequest::from_sandbox_exec_request(exec_request, options); if let Some(network) = exec_request.network.as_ref() { network.apply_to_env(&mut exec_request.env); } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 37c1b53a13..25cbb71c02 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -14,14 +14,11 @@ use crate::config::Constrained; use crate::config::Permissions; #[cfg(target_os = "macos")] use crate::config::types::ShellEnvironmentPolicy; -use crate::exec::SandboxType; use crate::protocol::AskForApproval; use crate::protocol::GranularApprovalConfig; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::sandboxing::SandboxPermissions; -#[cfg(target_os = "macos")] -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use crate::skills::SkillMetadata; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; @@ -40,6 +37,9 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SkillScope; +use codex_sandboxing::SandboxType; +#[cfg(target_os = "macos")] +use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; @@ -660,8 +660,6 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::Restricted, windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, arg0: None, sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { @@ -712,8 +710,6 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::Enabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, arg0: None, sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: None, @@ -787,8 +783,6 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, arg0: None, sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 49ef0e6385..3f8ab5f6ce 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,16 +7,18 @@ the process manager to spawn PTYs once an ExecRequest is prepared. use crate::command_canonicalization::canonicalize_command_for_approval; use crate::error::CodexErr; use crate::error::SandboxErr; +use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::powershell::prefix_powershell_script_with_utf8; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; -use crate::tools::runtimes::build_command_spec; +use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::shell::zsh_fork_backend; use crate::tools::sandboxing::Approvable; @@ -25,7 +27,6 @@ use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; @@ -39,6 +40,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxablePreference; use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -210,18 +212,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt network.apply_to_env(&mut env); } if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let spec = build_command_spec( - &command, - &req.cwd, - &env, - ExecExpiration::DefaultTimeout, - req.sandbox_permissions, - req.additional_permissions.clone(), - req.justification.clone(), - ) - .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let command = + build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + }; let exec_env = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; match zsh_fork_backend::maybe_prepare_unified_exec( req, @@ -258,18 +257,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt } } } - let spec = build_command_spec( - &command, - &req.cwd, - &env, - ExecExpiration::DefaultTimeout, - req.sandbox_permissions, - req.additional_permissions.clone(), - req.justification.clone(), - ) - .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let command = + build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) + .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; + let options = ExecOptions { + expiration: ExecExpiration::DefaultTimeout, + capture_policy: ExecCapturePolicy::ShellTool, + }; let exec_env = attempt - .env_for(spec, req.network.as_ref()) + .env_for(command, options, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; self.manager .open_session_with_exec_env(&exec_env, req.tty, Box::new(NoopSpawnLifecycle)) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index bf386a12d0..6cad5a3293 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -9,10 +9,8 @@ use crate::codex::TurnContext; use crate::error::CodexErr; #[cfg(test)] use crate::protocol::SandboxPolicy; -use crate::sandboxing::CommandSpec; -use crate::sandboxing::SandboxManager; +use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; -use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; use crate::tools::network_approval::NetworkApprovalSpec; use codex_network_proxy::NetworkProxy; @@ -23,6 +21,12 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformError; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; use futures::Future; use futures::future::BoxFuture; use serde::Serialize; @@ -280,15 +284,6 @@ pub(crate) trait Approvable { ) -> BoxFuture<'a, ReviewDecision>; } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum SandboxablePreference { - Auto, - #[allow(dead_code)] // Will be used by later tools. - Require, - #[allow(dead_code)] // Will be used by later tools. - Forbid, -} - pub(crate) trait Sandboxable { fn sandbox_preference(&self) -> SandboxablePreference; fn escalate_on_failure(&self) -> bool { @@ -323,7 +318,7 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { } pub(crate) struct SandboxAttempt<'a> { - pub sandbox: crate::exec::SandboxType, + pub sandbox: SandboxType, pub policy: &'a crate::protocol::SandboxPolicy, pub file_system_policy: &'a FileSystemSandboxPolicy, pub network_policy: NetworkSandboxPolicy, @@ -339,12 +334,13 @@ pub(crate) struct SandboxAttempt<'a> { impl<'a> SandboxAttempt<'a> { pub fn env_for( &self, - spec: CommandSpec, + command: SandboxCommand, + options: ExecOptions, network: Option<&NetworkProxy>, ) -> Result { self.manager - .transform(crate::sandboxing::SandboxTransformRequest { - spec, + .transform(SandboxTransformRequest { + command, policy: self.policy, file_system_policy: self.file_system_policy, network_policy: self.network_policy, @@ -359,6 +355,9 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) + .map(|request| { + crate::sandboxing::ExecRequest::from_sandbox_exec_request(request, options) + }) } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2e0a413a6f..120493dcbb 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -23,9 +23,9 @@ use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; -use crate::tools::handlers::multi_agents::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; @@ -167,6 +167,39 @@ fn send_input_output_schema() -> JsonValue { }) } +fn list_agents_output_schema() -> JsonValue { + json!({ + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "description": "Canonical task name for the agent when available, otherwise the agent id." + }, + "agent_status": { + "description": "Last known status of the agent.", + "allOf": [agent_status_output_schema()] + }, + "last_task_message": { + "type": ["string", "null"], + "description": "Most recent user or inter-agent instruction received by the agent, when available." + } + }, + "required": ["agent_name", "agent_status", "last_task_message"], + "additionalProperties": false + }, + "description": "Live agents visible in the current root thread tree." + } + }, + "required": ["agents"], + "additionalProperties": false + }) +} + fn resume_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -178,7 +211,7 @@ fn resume_agent_output_schema() -> JsonValue { }) } -fn wait_output_schema() -> JsonValue { +fn wait_output_schema_v1() -> JsonValue { json!({ "type": "object", "properties": { @@ -197,6 +230,24 @@ fn wait_output_schema() -> JsonValue { }) } +fn wait_output_schema_v2() -> JsonValue { + json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Brief wait summary without the agent's final content." + }, + "timed_out": { + "type": "boolean", + "description": "Whether the wait call returned due to timeout before any agent reached a final status." + } + }, + "required": ["message", "timed_out"], + "additionalProperties": false + }) +} + fn close_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -337,8 +388,12 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = model_info.supports_search_tool; - let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); + let include_search_tool = + model_info.supports_search_tool && features.enabled(Feature::ToolSearch); + let include_tool_suggest = include_search_tool + && features.enabled(Feature::ToolSuggest) + && features.enabled(Feature::Apps) + && features.enabled(Feature::Plugins); let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); @@ -1397,6 +1452,80 @@ fn create_send_input_tool() -> ToolSpec { }) } +fn create_send_message_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "target".to_string(), + JsonSchema::String { + description: Some( + "Agent id or canonical task name to message (from spawn_agent).".to_string(), + ), + }, + ), + ("items".to_string(), create_collab_input_items_schema()), + ( + "interrupt".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." + .to_string(), + ), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "send_message".to_string(), + description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["target".to_string(), "items".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(send_input_output_schema()), + }) +} + +fn create_assign_task_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "target".to_string(), + JsonSchema::String { + description: Some( + "Agent id or canonical task name to message (from spawn_agent).".to_string(), + ), + }, + ), + ("items".to_string(), create_collab_input_items_schema()), + ( + "interrupt".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." + .to_string(), + ), + }, + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "assign_task".to_string(), + description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["target".to_string(), "items".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(send_input_output_schema()), + }) +} + fn create_resume_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -1422,7 +1551,7 @@ fn create_resume_agent_tool() -> ToolSpec { }) } -fn create_wait_agent_tool() -> ToolSpec { +fn wait_agent_tool_parameters() -> JsonSchema { let mut properties = BTreeMap::new(); properties.insert( "targets".to_string(), @@ -1443,18 +1572,60 @@ fn create_wait_agent_tool() -> ToolSpec { }, ); + JsonSchema::Object { + properties, + required: Some(vec!["targets".to_string()]), + additional_properties: Some(false.into()), + } +} + +fn create_wait_agent_tool_v1() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: "wait_agent".to_string(), description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status." .to_string(), strict: false, defer_loading: None, + parameters: wait_agent_tool_parameters(), + output_schema: Some(wait_output_schema_v1()), + }) +} + +fn create_wait_agent_tool_v2() -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: "wait_agent".to_string(), + description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline." + .to_string(), + strict: false, + defer_loading: None, + parameters: wait_agent_tool_parameters(), + output_schema: Some(wait_output_schema_v2()), + }) +} + +fn create_list_agents_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "path_prefix".to_string(), + JsonSchema::String { + description: Some( + "Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets." + .to_string(), + ), + }, + )]); + + ToolSpec::Function(ResponsesApiTool { + name: "list_agents".to_string(), + description: "List live agents in the current root thread tree. Optionally filter by task-path prefix." + .to_string(), + strict: false, + defer_loading: None, parameters: JsonSchema::Object { properties, - required: Some(vec!["targets".to_string()]), + required: None, additional_properties: Some(false.into()), }, - output_schema: Some(wait_output_schema()), + output_schema: Some(list_agents_output_schema()), }) } @@ -1668,59 +1839,6 @@ fn create_test_sync_tool() -> ToolSpec { }) } -fn create_grep_files_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "pattern".to_string(), - JsonSchema::String { - description: Some("Regular expression pattern to search for.".to_string()), - }, - ), - ( - "include".to_string(), - JsonSchema::String { - description: Some( - "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ - \"*.{ts,tsx}\")." - .to_string(), - ), - }, - ), - ( - "path".to_string(), - JsonSchema::String { - description: Some( - "Directory or file path to search. Defaults to the session's working directory." - .to_string(), - ), - }, - ), - ( - "limit".to_string(), - JsonSchema::Number { - description: Some( - "Maximum number of file paths to return (defaults to 100).".to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "grep_files".to_string(), - description: "Finds files whose contents match the pattern and lists them by modification \ - time." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["pattern".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( @@ -1928,111 +2046,6 @@ fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { } } -fn create_read_file_tool() -> ToolSpec { - let indentation_properties = BTreeMap::from([ - ( - "anchor_line".to_string(), - JsonSchema::Number { - description: Some( - "Anchor line to center the indentation lookup on (defaults to offset)." - .to_string(), - ), - }, - ), - ( - "max_levels".to_string(), - JsonSchema::Number { - description: Some( - "How many parent indentation levels (smaller indents) to include.".to_string(), - ), - }, - ), - ( - "include_siblings".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, include additional blocks that share the anchor indentation." - .to_string(), - ), - }, - ), - ( - "include_header".to_string(), - JsonSchema::Boolean { - description: Some( - "Include doc comments or attributes directly above the selected block." - .to_string(), - ), - }, - ), - ( - "max_lines".to_string(), - JsonSchema::Number { - description: Some( - "Hard cap on the number of lines returned when using indentation mode." - .to_string(), - ), - }, - ), - ]); - - let properties = BTreeMap::from([ - ( - "file_path".to_string(), - JsonSchema::String { - description: Some("Absolute path to the file".to_string()), - }, - ), - ( - "offset".to_string(), - JsonSchema::Number { - description: Some( - "The line number to start reading from. Must be 1 or greater.".to_string(), - ), - }, - ), - ( - "limit".to_string(), - JsonSchema::Number { - description: Some("The maximum number of lines to return.".to_string()), - }, - ), - ( - "mode".to_string(), - JsonSchema::String { - description: Some( - "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ - to expand around an anchor line." - .to_string(), - ), - }, - ), - ( - "indentation".to_string(), - JsonSchema::Object { - properties: indentation_properties, - required: None, - additional_properties: Some(false.into()), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "read_file".to_string(), - description: - "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["file_path".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - fn create_list_dir_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2580,14 +2593,12 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; - use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; use crate::tools::handlers::JsReplResetHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; - use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -2602,6 +2613,11 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; + use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; + use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; + use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; + use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; + use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; let mut builder = ToolRegistryBuilder::new(); @@ -2853,34 +2869,6 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } - if config - .experimental_supported_tools - .contains(&"grep_files".to_string()) - { - let grep_files_handler = Arc::new(GrepFilesHandler); - push_tool_spec( - &mut builder, - create_grep_files_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - builder.register_handler("grep_files", grep_files_handler); - } - - if config - .experimental_supported_tools - .contains(&"read_file".to_string()) - { - let read_file_handler = Arc::new(ReadFileHandler); - push_tool_spec( - &mut builder, - create_read_file_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - builder.register_handler("read_file", read_file_handler); - } - if config .experimental_supported_tools .iter() @@ -2988,10 +2976,22 @@ pub(crate) fn build_specs_with_discoverable_tools( ); push_tool_spec( &mut builder, - create_send_input_tool(), + if config.multi_agent_v2 { + create_send_message_tool() + } else { + create_send_input_tool() + }, /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); + if config.multi_agent_v2 { + push_tool_spec( + &mut builder, + create_assign_task_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + } if !config.multi_agent_v2 { push_tool_spec( &mut builder, @@ -3003,7 +3003,11 @@ pub(crate) fn build_specs_with_discoverable_tools( } push_tool_spec( &mut builder, - create_wait_agent_tool(), + if config.multi_agent_v2 { + create_wait_agent_tool_v2() + } else { + create_wait_agent_tool_v1() + }, /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); @@ -3013,9 +3017,23 @@ pub(crate) fn build_specs_with_discoverable_tools( /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); - builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); - builder.register_handler("send_input", Arc::new(SendInputHandler)); - builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); + if config.multi_agent_v2 { + push_tool_spec( + &mut builder, + create_list_agents_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); + builder.register_handler("send_message", Arc::new(SendMessageHandlerV2)); + builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); + builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2)); + } else { + builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); + builder.register_handler("send_input", Arc::new(SendInputHandler)); + builder.register_handler("wait_agent", Arc::new(WaitAgentHandler)); + } builder.register_handler("close_agent", Arc::new(CloseAgentHandler)); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 1cb6bb1664..f12aee6fa2 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -469,7 +469,11 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { create_view_image_tool(config.can_request_original_image_detail), create_spawn_agent_tool(&config), create_send_input_tool(), - create_wait_agent_tool(), + if config.multi_agent_v2 { + create_wait_agent_tool_v2() + } else { + create_wait_agent_tool_v1() + }, create_close_agent_tool(), ] { expected.insert(tool_name(&spec).to_string(), spec); @@ -521,6 +525,7 @@ fn test_build_specs_collab_tools_enabled() { &["spawn_agent", "send_input", "wait_agent", "close_agent"], ); assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); + assert_lacks_tool_name(&tools, "list_agents"); } #[test] @@ -541,6 +546,17 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names( + &tools, + &[ + "spawn_agent", + "send_message", + "assign_task", + "wait_agent", + "close_agent", + "list_agents", + ], + ); let spawn_agent = find_tool(&tools, "spawn_agent"); let ToolSpec::Function(ResponsesApiTool { @@ -569,9 +585,9 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { json!(["agent_id", "task_name", "nickname"]) ); - let send_input = find_tool(&tools, "send_input"); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_input.spec else { - panic!("send_input should be a function tool"); + let send_message = find_tool(&tools, "send_message"); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else { + panic!("send_message should be a function tool"); }; let JsonSchema::Object { properties, @@ -579,10 +595,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { .. } = parameters else { - panic!("send_input should use object params"); + panic!("send_message should use object params"); }; assert!(properties.contains_key("target")); - assert_eq!(required.as_ref(), Some(&vec!["target".to_string()])); + assert!(!properties.contains_key("message")); + assert_eq!( + required.as_ref(), + Some(&vec!["target".to_string(), "items".to_string()]) + ); + + let assign_task = find_tool(&tools, "assign_task"); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec else { + panic!("assign_task should be a function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("assign_task should use object params"); + }; + assert!(properties.contains_key("target")); + assert!(!properties.contains_key("message")); + assert_eq!( + required.as_ref(), + Some(&vec!["target".to_string(), "items".to_string()]) + ); let wait_agent = find_tool(&tools, "wait_agent"); let ToolSpec::Function(ResponsesApiTool { @@ -607,9 +646,37 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { .as_ref() .expect("wait_agent should define output schema"); assert_eq!( - output_schema["properties"]["status"]["description"], - json!("Final statuses keyed by canonical task name when available, otherwise by agent id.") + output_schema["properties"]["message"]["description"], + json!("Brief wait summary without the agent's final content.") ); + + let list_agents = find_tool(&tools, "list_agents"); + let ToolSpec::Function(ResponsesApiTool { + parameters, + output_schema, + .. + }) = &list_agents.spec + else { + panic!("list_agents should be a function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("list_agents should use object params"); + }; + assert!(properties.contains_key("path_prefix")); + assert_eq!(required.as_ref(), None); + let output_schema = output_schema + .as_ref() + .expect("list_agents should define output schema"); + assert_eq!( + output_schema["properties"]["agents"]["items"]["required"], + json!(["agent_name", "agent_status", "last_task_message"]) + ); + assert_lacks_tool_name(&tools, "send_input"); assert_lacks_tool_name(&tools, "resume_agent"); } @@ -1598,21 +1665,13 @@ fn test_parallel_support_flags() { assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls); assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); - assert!(find_tool(&tools, "grep_files").supports_parallel_tool_calls); - assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls); - assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls); } #[test] fn test_test_model_info_includes_sync_tool() { let _config = test_config(); let mut model_info = model_info_from_models_json("gpt-5-codex"); - model_info.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; + model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()]; let features = Features::with_defaults(); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -1631,17 +1690,6 @@ fn test_test_model_info_includes_sync_tool() { .iter() .any(|tool| tool_name(&tool.spec) == "test_sync_tool") ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "read_file") - ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "grep_files") - ); - assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir")); } #[test] @@ -1791,6 +1839,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1909,7 +1958,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { } #[test] -fn search_tool_requires_model_capability_only() { +fn search_tool_requires_model_capability_and_feature_flag() { let model_info = search_capable_model_info(); let app_tools = Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), @@ -1945,6 +1994,22 @@ fn search_tool_requires_model_capability_only() { }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1963,7 +2028,8 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); - features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); + features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1994,11 +2060,57 @@ fn tool_suggest_is_not_registered_without_feature_flag() { ); } +#[test] +fn tool_suggest_requires_apps_and_plugins_features() { + let model_info = search_capable_model_info(); + let discoverable_tools = Some(vec![discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + )]); + let available_models = Vec::new(); + + for disabled_feature in [Feature::Apps, Feature::Plugins] { + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); + features.enable(Feature::ToolSuggest); + features.enable(Feature::Apps); + features.enable(Feature::Plugins); + features.disable(disabled_feature); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + discoverable_tools.clone(), + &[], + ) + .build(); + + assert!( + !tools + .iter() + .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME), + "tool_suggest should be absent when {disabled_feature:?} is disabled" + ); + } +} + #[test] fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2025,6 +2137,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2073,6 +2186,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2138,6 +2252,8 @@ fn tool_suggest_description_lists_discoverable_tools() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs deleted file mode 100644 index 707fbe22ef..0000000000 --- a/codex-rs/core/src/truncate.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Utilities for truncating large chunks of output while preserving a prefix -//! and suffix on UTF-8 boundaries, and helpers for line/token‑based truncation -//! used across the core crate. - -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::openai_models::TruncationMode; -use codex_protocol::openai_models::TruncationPolicyConfig; -use codex_protocol::protocol::TruncationPolicy as ProtocolTruncationPolicy; - -const APPROX_BYTES_PER_TOKEN: usize = 4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TruncationPolicy { - Bytes(usize), - Tokens(usize), -} - -impl From for ProtocolTruncationPolicy { - fn from(value: TruncationPolicy) -> Self { - match value { - TruncationPolicy::Bytes(bytes) => Self::Bytes(bytes), - TruncationPolicy::Tokens(tokens) => Self::Tokens(tokens), - } - } -} - -impl From for TruncationPolicy { - fn from(config: TruncationPolicyConfig) -> Self { - match config.mode { - TruncationMode::Bytes => Self::Bytes(config.limit as usize), - TruncationMode::Tokens => Self::Tokens(config.limit as usize), - } - } -} - -impl TruncationPolicy { - /// Returns a token budget derived from this policy. - /// - /// - For `Tokens`, this is the explicit token limit. - /// - For `Bytes`, this is an approximate token budget using the global - /// bytes-per-token heuristic. - pub fn token_budget(&self) -> usize { - match self { - TruncationPolicy::Bytes(bytes) => { - usize::try_from(approx_tokens_from_byte_count(*bytes)).unwrap_or(usize::MAX) - } - TruncationPolicy::Tokens(tokens) => *tokens, - } - } - - /// Returns a byte budget derived from this policy. - /// - /// - For `Bytes`, this is the explicit byte limit. - /// - For `Tokens`, this is an approximate byte budget using the global - /// bytes-per-token heuristic. - pub fn byte_budget(&self) -> usize { - match self { - TruncationPolicy::Bytes(bytes) => *bytes, - TruncationPolicy::Tokens(tokens) => approx_bytes_for_tokens(*tokens), - } - } -} - -impl std::ops::Mul for TruncationPolicy { - type Output = Self; - - fn mul(self, multiplier: f64) -> Self::Output { - match self { - TruncationPolicy::Bytes(bytes) => { - TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) - } - TruncationPolicy::Tokens(tokens) => { - TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) - } - } - } -} - -pub(crate) fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String { - if content.len() <= policy.byte_budget() { - return content.to_string(); - } - let total_lines = content.lines().count(); - let result = truncate_text(content, policy); - format!("Total output lines: {total_lines}\n\n{result}") -} - -pub(crate) fn truncate_text(content: &str, policy: TruncationPolicy) -> String { - match policy { - TruncationPolicy::Bytes(_) => truncate_with_byte_estimate(content, policy), - TruncationPolicy::Tokens(_) => { - let (truncated, _) = truncate_with_token_budget(content, policy); - truncated - } - } -} - -pub(crate) fn formatted_truncate_text_content_items_with_policy( - items: &[FunctionCallOutputContentItem], - policy: TruncationPolicy, -) -> (Vec, Option) { - let text_segments = items - .iter() - .filter_map(|item| match item { - FunctionCallOutputContentItem::InputText { text } => Some(text.as_str()), - FunctionCallOutputContentItem::InputImage { .. } => None, - }) - .collect::>(); - - if text_segments.is_empty() { - return (items.to_vec(), None); - } - - let mut combined = String::new(); - for text in &text_segments { - if !combined.is_empty() { - combined.push('\n'); - } - combined.push_str(text); - } - - if combined.len() <= policy.byte_budget() { - return (items.to_vec(), None); - } - - let mut out = vec![FunctionCallOutputContentItem::InputText { - text: formatted_truncate_text(&combined, policy), - }]; - out.extend(items.iter().filter_map(|item| match item { - FunctionCallOutputContentItem::InputImage { image_url, detail } => { - Some(FunctionCallOutputContentItem::InputImage { - image_url: image_url.clone(), - detail: *detail, - }) - } - FunctionCallOutputContentItem::InputText { .. } => None, - })); - - (out, Some(approx_token_count(&combined))) -} - -/// Globally truncate function output items to fit within the given -/// truncation policy's budget, preserving as many text/image items as -/// possible and appending a summary for any omitted text items. -pub(crate) fn truncate_function_output_items_with_policy( - items: &[FunctionCallOutputContentItem], - policy: TruncationPolicy, -) -> Vec { - let mut out: Vec = Vec::with_capacity(items.len()); - let mut remaining_budget = match policy { - TruncationPolicy::Bytes(_) => policy.byte_budget(), - TruncationPolicy::Tokens(_) => policy.token_budget(), - }; - let mut omitted_text_items = 0usize; - - for it in items { - match it { - FunctionCallOutputContentItem::InputText { text } => { - if remaining_budget == 0 { - omitted_text_items += 1; - continue; - } - - let cost = match policy { - TruncationPolicy::Bytes(_) => text.len(), - TruncationPolicy::Tokens(_) => approx_token_count(text), - }; - - if cost <= remaining_budget { - out.push(FunctionCallOutputContentItem::InputText { text: text.clone() }); - remaining_budget = remaining_budget.saturating_sub(cost); - } else { - let snippet_policy = match policy { - TruncationPolicy::Bytes(_) => TruncationPolicy::Bytes(remaining_budget), - TruncationPolicy::Tokens(_) => TruncationPolicy::Tokens(remaining_budget), - }; - let snippet = truncate_text(text, snippet_policy); - if snippet.is_empty() { - omitted_text_items += 1; - } else { - out.push(FunctionCallOutputContentItem::InputText { text: snippet }); - } - remaining_budget = 0; - } - } - FunctionCallOutputContentItem::InputImage { image_url, detail } => { - out.push(FunctionCallOutputContentItem::InputImage { - image_url: image_url.clone(), - detail: *detail, - }); - } - } - } - - if omitted_text_items > 0 { - out.push(FunctionCallOutputContentItem::InputText { - text: format!("[omitted {omitted_text_items} text items ...]"), - }); - } - - out -} - -/// Truncate the middle of a UTF-8 string to at most `max_tokens` tokens, -/// preserving the beginning and the end. Returns the possibly truncated string -/// and `Some(original_token_count)` if truncation occurred; otherwise returns -/// the original string and `None`. -fn truncate_with_token_budget(s: &str, policy: TruncationPolicy) -> (String, Option) { - if s.is_empty() { - return (String::new(), None); - } - let max_tokens = policy.token_budget(); - - let byte_len = s.len(); - if max_tokens > 0 && byte_len <= approx_bytes_for_tokens(max_tokens) { - return (s.to_string(), None); - } - - let truncated = truncate_with_byte_estimate(s, policy); - let approx_total_usize = approx_token_count(s); - let approx_total = u64::try_from(approx_total_usize).unwrap_or(u64::MAX); - if truncated == s { - (truncated, None) - } else { - (truncated, Some(approx_total)) - } -} - -/// Truncate a string using a byte budget derived from the token budget, without -/// performing any real tokenization. This keeps the logic purely byte-based and -/// uses a bytes placeholder in the truncated output. -fn truncate_with_byte_estimate(s: &str, policy: TruncationPolicy) -> String { - if s.is_empty() { - return String::new(); - } - - let total_chars = s.chars().count(); - let max_bytes = policy.byte_budget(); - - if max_bytes == 0 { - // No budget to show content; just report that everything was truncated. - let marker = format_truncation_marker( - policy, - removed_units_for_source(policy, s.len(), total_chars), - ); - return marker; - } - - if s.len() <= max_bytes { - return s.to_string(); - } - - let total_bytes = s.len(); - - let (left_budget, right_budget) = split_budget(max_bytes); - - let (removed_chars, left, right) = split_string(s, left_budget, right_budget); - - let marker = format_truncation_marker( - policy, - removed_units_for_source(policy, total_bytes.saturating_sub(max_bytes), removed_chars), - ); - - assemble_truncated_output(left, right, &marker) -} - -fn split_string(s: &str, beginning_bytes: usize, end_bytes: usize) -> (usize, &str, &str) { - if s.is_empty() { - return (0, "", ""); - } - - let len = s.len(); - let tail_start_target = len.saturating_sub(end_bytes); - let mut prefix_end = 0usize; - let mut suffix_start = len; - let mut removed_chars = 0usize; - let mut suffix_started = false; - - for (idx, ch) in s.char_indices() { - let char_end = idx + ch.len_utf8(); - if char_end <= beginning_bytes { - prefix_end = char_end; - continue; - } - - if idx >= tail_start_target { - if !suffix_started { - suffix_start = idx; - suffix_started = true; - } - continue; - } - - removed_chars = removed_chars.saturating_add(1); - } - - if suffix_start < prefix_end { - suffix_start = prefix_end; - } - - let before = &s[..prefix_end]; - let after = &s[suffix_start..]; - - (removed_chars, before, after) -} - -fn format_truncation_marker(policy: TruncationPolicy, removed_count: u64) -> String { - match policy { - TruncationPolicy::Tokens(_) => format!("…{removed_count} tokens truncated…"), - TruncationPolicy::Bytes(_) => format!("…{removed_count} chars truncated…"), - } -} - -fn split_budget(budget: usize) -> (usize, usize) { - let left = budget / 2; - (left, budget - left) -} - -fn removed_units_for_source( - policy: TruncationPolicy, - removed_bytes: usize, - removed_chars: usize, -) -> u64 { - match policy { - TruncationPolicy::Tokens(_) => approx_tokens_from_byte_count(removed_bytes), - TruncationPolicy::Bytes(_) => u64::try_from(removed_chars).unwrap_or(u64::MAX), - } -} - -fn assemble_truncated_output(prefix: &str, suffix: &str, marker: &str) -> String { - let mut out = String::with_capacity(prefix.len() + marker.len() + suffix.len() + 1); - out.push_str(prefix); - out.push_str(marker); - out.push_str(suffix); - out -} - -pub(crate) fn approx_token_count(text: &str) -> usize { - let len = text.len(); - len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN -} - -pub(crate) fn approx_bytes_for_tokens(tokens: usize) -> usize { - tokens.saturating_mul(APPROX_BYTES_PER_TOKEN) -} - -pub(crate) fn approx_tokens_from_byte_count(bytes: usize) -> u64 { - let bytes_u64 = bytes as u64; - bytes_u64.saturating_add((APPROX_BYTES_PER_TOKEN as u64).saturating_sub(1)) - / (APPROX_BYTES_PER_TOKEN as u64) -} - -pub(crate) fn approx_tokens_from_byte_count_i64(bytes: i64) -> i64 { - if bytes <= 0 { - return 0; - } - let bytes = usize::try_from(bytes).unwrap_or(usize::MAX); - i64::try_from(approx_tokens_from_byte_count(bytes)).unwrap_or(i64::MAX) -} - -#[cfg(test)] -#[path = "truncate_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 3a4bac011d..b3794d3254 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -8,11 +8,11 @@ use std::sync::RwLock; use serde::Serialize; use tokio::task::JoinHandle; -use crate::git_info::get_git_remote_urls_assume_git_repo; -use crate::git_info::get_git_repo_root; -use crate::git_info::get_has_changes; -use crate::git_info::get_head_commit_hash; use crate::sandbox_tags::sandbox_tag; +use codex_git_utils::get_git_remote_urls_assume_git_repo; +use codex_git_utils::get_git_repo_root; +use codex_git_utils::get_has_changes; +use codex_git_utils::get_head_commit_hash; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::protocol::SandboxPolicy; @@ -94,11 +94,12 @@ fn build_turn_metadata_bag( pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Option { let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned()); - let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!( + let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!( get_head_commit_hash(cwd), get_git_remote_urls_assume_git_repo(cwd), 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() @@ -231,11 +232,12 @@ impl TurnMetadataState { } async fn fetch_workspace_git_metadata(&self) -> WorkspaceGitMetadata { - let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!( + let (head_commit_hash, associated_remote_urls, has_changes) = tokio::join!( get_head_commit_hash(&self.cwd), get_git_remote_urls_assume_git_repo(&self.cwd), get_has_changes(&self.cwd), ); + let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0); WorkspaceGitMetadata { associated_remote_urls, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 3e69a71eea..44e926dbe6 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -12,7 +12,7 @@ //! Flow at a glance (open process) //! 1) Build a small request `{ command, cwd }`. //! 2) Orchestrator: approval (bypass/cache/prompt) β†’ select sandbox β†’ run. -//! 3) Runtime: transform `CommandSpec` -> `ExecRequest` -> spawn PTY. +//! 3) Runtime: transform `SandboxTransformRequest` -> `ExecRequest` -> spawn PTY. //! 4) If denial, orchestrator retries with `SandboxType::None`. //! 5) Process handle is returned with streaming output + metadata. //! diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 6da7c739ec..68cf18bce1 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -13,11 +13,11 @@ use tokio::time::Duration; use tokio_util::sync::CancellationToken; use crate::exec::ExecToolCallOutput; -use crate::exec::SandboxType; use crate::exec::StreamOutput; use crate::exec::is_likely_sandbox_denied; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text; +use codex_sandboxing::SandboxType; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::SpawnedPty; diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 52d668c000..8631a20955 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -27,7 +27,6 @@ use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::sandboxing::ToolCtx; -use crate::truncate::approx_token_count; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES; use crate::unified_exec::MAX_YIELD_TIME_MS; @@ -50,6 +49,7 @@ use crate::unified_exec::process::OutputBuffer; use crate::unified_exec::process::OutputHandles; use crate::unified_exec::process::SpawnLifecycleHandle; use crate::unified_exec::process::UnifiedExecProcess; +use codex_utils_output_truncation::approx_token_count; const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ ("NO_COLOR", "1"), @@ -160,7 +160,7 @@ impl UnifiedExecProcessManager { let cwd = request .workdir .clone() - .unwrap_or_else(|| context.turn.cwd.clone()); + .unwrap_or_else(|| context.turn.cwd.to_path_buf()); let process = self .open_session_with_sandbox(&request, cwd.clone(), context) .await; diff --git a/codex-rs/core/src/utils/mod.rs b/codex-rs/core/src/utils/mod.rs new file mode 100644 index 0000000000..adbdcef12b --- /dev/null +++ b/codex-rs/core/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod path_utils; diff --git a/codex-rs/core/src/utils/path_utils.rs b/codex-rs/core/src/utils/path_utils.rs new file mode 100644 index 0000000000..3715857d1a --- /dev/null +++ b/codex-rs/core/src/utils/path_utils.rs @@ -0,0 +1 @@ +pub use codex_utils_path::*; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 450a170b2a..da5499c573 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -185,6 +185,11 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_create_event", "description": "Create a calendar event.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, "inputSchema": { "type": "object", "properties": { @@ -209,6 +214,9 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_list_events", "description": "List calendar events.", + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { @@ -241,6 +249,9 @@ impl Respond for CodexAppsJsonRpcResponder { tools.push(json!({ "name": format!("calendar_timezone_option_{index}"), "description": format!("Read timezone option {index}."), + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0b31793402..484bd189ef 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -14,6 +14,7 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_utils_absolute_path::AbsolutePathBuf; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -104,6 +105,36 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +pub trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()).expect("path should already be absolute") + } +} + +pub trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub trait TempDirExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl TempDirExt for TempDir { + fn abs(&self) -> AbsolutePathBuf { + self.path().abs() + } +} + pub fn test_tmp_path() -> AbsolutePathBuf { test_absolute_path_with_windows("/tmp", Some(r"C:\Users\codex\AppData\Local\Temp")) } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 6df93bcd85..7b0d412786 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -37,7 +37,10 @@ use serde_json::Value; use tempfile::TempDir; use wiremock::MockServer; +use crate::PathBufExt; +use crate::PathExt; use crate::RemoteEnvConfig; +use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::responses::WebSocketTestServer; @@ -297,8 +300,7 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result Result { - AbsolutePathBuf::try_from(path.to_path_buf()) - .map_err(|err| anyhow!("invalid absolute path {}: {err}", path.display())) + Ok(path.abs()) } /// A collection of different ways the model can output an apply_patch call @@ -393,7 +395,7 @@ impl TestCodexBuilder { let cwd = test_env.cwd.to_path_buf(); self.config_mutators.push(Box::new(move |config| { config.experimental_exec_server_url = experimental_exec_server_url; - config.cwd = cwd; + config.cwd = cwd.abs(); })); let mut test = self.build(server).await?; @@ -556,7 +558,7 @@ impl TestCodexBuilder { }; let cwd = Arc::new(TempDir::new()?); let mut config = load_default_config_for_test(home).await; - config.cwd = cwd.path().to_path_buf(); + config.cwd = cwd.abs(); config.model_provider = model_provider; for hook in self.pre_build_hooks.drain(..) { hook(home.path()); @@ -616,12 +618,7 @@ fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { .unwrap_or_else(|| panic!("missing bundled model gpt-5.1-codex")); model.slug = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); model.display_name = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); - model.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; + model.experimental_supported_tools = vec!["test_sync_tool".to_string()]; config.model_catalog = Some(ModelsResponse { models: vec![model], }); @@ -721,8 +718,9 @@ impl TestCodex { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b113fc465c..d0cefcb616 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -310,6 +310,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -919,6 +920,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1000,6 +1002,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1151,6 +1154,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1215,6 +1219,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1287,6 +1292,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -1359,6 +1365,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f77697d017..6ec05bedc2 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -558,6 +558,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/auth_refresh.rs b/codex-rs/core/tests/suite/auth_refresh.rs index 278124c63a..3390a93197 100644 --- a/codex-rs/core/tests/suite/auth_refresh.rs +++ b/codex-rs/core/tests/suite/auth_refresh.rs @@ -294,13 +294,14 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { .await; let ctx = RefreshTokenTestContext::new(&server)?; - let initial_last_refresh = Utc::now() - Duration::days(1); - let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let stale_refresh = Utc::now() - Duration::days(9); + let fresh_access_token = access_token_with_expiration(Utc::now() + Duration::hours(1)); + 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), + last_refresh: Some(stale_refresh), }; ctx.write_auth(&initial_auth)?; @@ -325,7 +326,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { #[serial_test::serial(auth_refresh)] #[tokio::test] -async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { +async fn refreshes_token_when_access_token_is_expired() -> Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; @@ -340,13 +341,14 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { .await; let ctx = RefreshTokenTestContext::new(&server)?; - let stale_refresh = Utc::now() - Duration::days(9); - let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); + let fresh_refresh = Utc::now() - Duration::days(1); + let expired_access_token = access_token_with_expiration(Utc::now() - Duration::hours(1)); + let initial_tokens = build_tokens(&expired_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(stale_refresh), + last_refresh: Some(fresh_refresh), }; ctx.write_auth(&initial_auth)?; @@ -373,7 +375,7 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> { .as_ref() .context("last_refresh should be recorded")?; assert!( - *refreshed_at >= stale_refresh, + *refreshed_at >= fresh_refresh, "last_refresh should advance" ); @@ -543,6 +545,153 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re Ok(()) } +#[serial_test::serial(auth_refresh)] +#[tokio::test] +async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + 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), + }; + ctx.write_auth(&initial_auth)?; + + 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<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { + "code": "refresh_token_reused" + } + }))) + .expect(1) + .mount(&server) + .await; + + let ctx = RefreshTokenTestContext::new(&server)?; + 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), + }; + ctx.write_auth(&initial_auth)?; + + 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 fresh_refresh = Utc::now() - Duration::hours(1); + let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token"); + let disk_auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(disk_tokens.clone()), + last_refresh: Some(fresh_refresh), + }; + save_auth( + ctx.codex_home.path(), + &disk_auth, + AuthCredentialsStoreMode::File, + )?; + + ctx.auth_manager + .refresh_token() + .await + .context("refresh should reload changed auth without retrying")?; + + let stored = ctx.load_auth()?; + assert_eq!(stored, disk_auth); + + let cached_auth = ctx + .auth_manager + .auth_cached() + .context("auth should be cached")?; + let cached = cached_auth + .get_token_data() + .context("token data should reload from disk")?; + assert_eq!(cached, disk_tokens); + + let requests = server.received_requests().await.unwrap_or_default(); + assert_eq!( + requests.len(), + 1, + "expected only the initial refresh request" + ); + + server.verify().await; + Ok(()) +} + #[serial_test::serial(auth_refresh)] #[tokio::test] async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> { @@ -867,7 +1016,7 @@ impl Drop for EnvGuard { } } -fn minimal_jwt() -> String { +fn jwt_with_payload(payload: serde_json::Value) -> String { #[derive(Serialize)] struct Header { alg: &'static str, @@ -878,7 +1027,6 @@ fn minimal_jwt() -> String { alg: "none", typ: "JWT", }; - let payload = json!({ "sub": "user-123" }); fn b64(data: &[u8]) -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) @@ -898,6 +1046,14 @@ fn minimal_jwt() -> String { format!("{header_b64}.{payload_b64}.{signature_b64}") } +fn minimal_jwt() -> String { + jwt_with_payload(json!({ "sub": "user-123" })) +} + +fn access_token_with_expiration(expires_at: chrono::DateTime) -> String { + jwt_with_payload(json!({ "sub": "user-123", "exp": expires_at.timestamp() })) +} + fn build_tokens(access_token: &str, refresh_token: &str) -> TokenData { let id_token = IdTokenInfo { raw_jwt: minimal_jwt(), diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 767f805002..cc8a0103aa 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,5 +1,6 @@ use assert_cmd::Command as AssertCommand; use codex_core::auth::CODEX_API_KEY_ENV_VAR; +use codex_git_utils::collect_git_info; use codex_protocol::protocol::GitInfo; use codex_utils_cargo_bin::find_resource; use core_test_support::fs_wait; @@ -596,7 +597,7 @@ async fn integration_git_info_unit_test() { .unwrap(); // 3. Test git info collection directly - let git_info = codex_core::git_info::collect_git_info(&git_repo).await; + let git_info = collect_git_info(&git_repo).await; // 4. Verify git info is present and contains expected data assert!(git_info.is_some(), "Git info should be collected"); @@ -608,7 +609,7 @@ async fn integration_git_info_unit_test() { git_info.commit_hash.is_some(), "Git info should contain commit_hash" ); - let commit_hash = git_info.commit_hash.as_ref().unwrap(); + let commit_hash = &git_info.commit_hash.as_ref().unwrap().0; assert_eq!(commit_hash.len(), 40, "Commit hash should be 40 characters"); assert!( commit_hash.chars().all(|c| c.is_ascii_hexdigit()), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3ea30c5967..869acc49f1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -42,6 +42,7 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_completed; @@ -1105,7 +1106,7 @@ async fn skills_append_to_developer_message() { .with_home(codex_home.clone()) .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(move |config| { - config.cwd = codex_home_path; + config.cwd = codex_home_path.abs(); }); let codex = builder .build(&server) @@ -1308,8 +1309,9 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model.clone(), effort: Some(ReasoningEffort::Low), @@ -1425,8 +1427,9 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: session_configured.model, effort: None, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index b9e4f05b36..fa8229ecc3 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2319,6 +2319,7 @@ text( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 81d0678cad..df8d6e4fe4 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -176,8 +176,9 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.clone(), + cwd: test.config.cwd.to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, @@ -291,8 +292,9 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.clone(), + cwd: test.config.cwd.to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 2f4365a959..a54d740111 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1658,6 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { final_output_json_schema: None, cwd: resumed.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: resumed.session_configured.model.clone(), effort: None, @@ -1748,6 +1749,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -1772,6 +1774,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, @@ -1882,6 +1885,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { final_output_json_schema: None, cwd: initial.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -1930,6 +1934,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() { final_output_json_schema: None, cwd: resumed.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, @@ -3132,6 +3137,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: previous_model.to_string(), effort: None, @@ -3156,6 +3162,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: next_model.to_string(), effort: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index fafbced0b7..5f01e10d58 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -16,6 +16,9 @@ use codex_core::ThreadManager; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::WarningEvent; @@ -307,10 +310,10 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() return Ok(()); } - // 1. Arrange mocked SSE responses for the initial flow plus the second compact. + // 1. Arrange mocked SSE responses as a single ordered stream so assertions + // observe the real request sequence instead of per-mock duplicate captures. let server = MockServer::start().await; - let mut request_log = mount_initial_flow(&server).await; - request_log.extend(mount_second_compact_flow(&server).await); + let request_log = mount_second_compact_sequence(&server).await; // 2. Drive the conversation through compact -> resume -> fork -> compact -> resume. let (_home, config, manager, base) = start_test_conversation(&server, None).await; @@ -346,7 +349,12 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() let resumed_again = resume_conversation(&manager, &config, forked_path).await; user_turn(&resumed_again, AFTER_SECOND_RESUME).await; - let mut requests = gather_request_bodies(&request_log); + let mut requests = request_log + .requests() + .into_iter() + .map(|request| request.body_json()) + .collect::>(); + requests.iter_mut().for_each(normalize_line_endings); normalize_compact_prompts(&mut requests); let input_after_compact = json!(requests[requests.len() - 2]["input"]); let input_after_resume = json!(requests[requests.len() - 1]["input"]); @@ -379,21 +387,39 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() ); let seeded_user_prefix = &first_request_user_texts[..first_turn_user_index]; let summary_after_second_compact = - extract_summary_user_text(&requests[requests.len() - 3], SUMMARY_TEXT); - let mut expected_after_second_compact_user_texts = - vec!["AFTER_FORK".to_string(), summary_after_second_compact]; + extract_summary_user_text(&requests[requests.len() - 2], SUMMARY_TEXT); + let mut expected_after_second_compact_user_texts = vec![ + "hello world".to_string(), + "AFTER_COMPACT".to_string(), + "AFTER_RESUME".to_string(), + "AFTER_FORK".to_string(), + summary_after_second_compact.clone(), + ]; expected_after_second_compact_user_texts.extend_from_slice(seeded_user_prefix); expected_after_second_compact_user_texts.push("AFTER_COMPACT_2".to_string()); + let mut expected_fork_local_user_texts = + vec!["AFTER_FORK".to_string(), summary_after_second_compact]; + expected_fork_local_user_texts.extend_from_slice(seeded_user_prefix); + expected_fork_local_user_texts.push("AFTER_COMPACT_2".to_string()); let final_user_texts = json_message_input_texts(&requests[requests.len() - 1], "user"); let (final_last, final_prefix) = final_user_texts .split_last() .unwrap_or_else(|| panic!("after-second-resume request missing user messages")); assert_eq!(final_last, AFTER_SECOND_RESUME); - assert!( - final_prefix.starts_with(&expected_after_second_compact_user_texts), - "after-second-resume user texts should preserve post-compact user history prefix" - ); - let final_seeded_suffix = &final_prefix[expected_after_second_compact_user_texts.len()..]; + let matched_prefix_len = if let Some(start) = final_prefix + .windows(expected_after_second_compact_user_texts.len()) + .position(|window| window == expected_after_second_compact_user_texts) + { + start + expected_after_second_compact_user_texts.len() + } else if let Some(start) = final_prefix + .windows(expected_fork_local_user_texts.len()) + .position(|window| window == expected_fork_local_user_texts) + { + start + expected_fork_local_user_texts.len() + } else { + panic!("after-second-resume user texts should preserve post-compact user history prefix"); + }; + let final_seeded_suffix = &final_prefix[matched_prefix_len..]; if seeded_user_prefix.is_empty() { assert!( final_seeded_suffix.is_empty(), @@ -502,6 +528,141 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +/// Scenario: rolling back a turn that introduced persistent pre-turn context +/// diffs should trim those context updates so the next request includes them +/// only once. +async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { + if network_disabled() { + println!("Skipping test because network is disabled in this sandbox"); + return Ok(()); + } + + const MODEL: &str = "gpt-5.1-codex"; + const TURN_ONE_USER: &str = "turn 1 user"; + const TURN_TWO_USER: &str = "turn 2 user"; + const FOLLOWUP_USER: &str = "follow-up user"; + const ROLLED_BACK_DEV_INSTRUCTIONS: &str = "ROLLED_BACK_DEV_INSTRUCTIONS"; + const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD"; + + let server = MockServer::start().await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "turn 1 assistant"), + ev_completed("r1"), + ]), + sse(vec![ + ev_assistant_message("m2", "turn 2 assistant"), + ev_completed("r2"), + ]), + sse(vec![ev_completed("r3")]), + ], + ) + .await; + + let (_home, config, _manager, conversation) = + start_test_conversation(&server, Some(MODEL)).await; + + user_turn(&conversation, TURN_ONE_USER).await; + + let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD)?; + std::fs::create_dir_all(&override_cwd)?; + conversation + .submit(Op::OverrideTurnContext { + cwd: Some(override_cwd.to_path_buf()), + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: MODEL.to_string(), + reasoning_effort: None, + developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()), + }, + }), + personality: None, + }) + .await?; + + user_turn(&conversation, TURN_TWO_USER).await; + + conversation + .submit(Op::ThreadRollback { num_turns: 1 }) + .await?; + let rollback_event = wait_for_event(&conversation, |ev| { + matches!(ev, EventMsg::ThreadRolledBack(_)) + }) + .await; + let EventMsg::ThreadRolledBack(rollback_event) = rollback_event else { + panic!("expected thread rolled back event"); + }; + assert_eq!(rollback_event.num_turns, 1); + + user_turn(&conversation, FOLLOWUP_USER).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 3); + + let before_rollback_developer_count = requests[1] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) + .count(); + assert_eq!(before_rollback_developer_count, 1); + assert_eq!( + requests[1] + .message_input_texts("user") + .iter() + .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) + .count(), + 1 + ); + + let after_rollback_developer_count = requests[2] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) + .count(); + assert_eq!(after_rollback_developer_count, 1); + + let after_rollback_user_texts = requests[2].message_input_texts("user"); + assert_eq!( + after_rollback_user_texts + .iter() + .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) + .count(), + 1 + ); + assert_eq!( + after_rollback_user_texts.last().map(String::as_str), + Some(FOLLOWUP_USER) + ); + + insta::assert_snapshot!( + "rollback_followup_turn_trims_context_updates", + context_snapshot::format_labeled_requests_snapshot( + "rollback trims pre-turn override context updates before the follow-up request", + &[ + ("rolled-back turn request", &requests[1]), + ("follow-up request after rollback", &requests[2]), + ], + &ContextSnapshotOptions::default() + .strip_capability_instructions() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }), + ) + ); + + Ok(()) +} + fn normalize_line_endings(value: &mut Value) { match value { Value::String(text) => { @@ -594,33 +755,29 @@ async fn mount_initial_flow(server: &MockServer) -> Vec { vec![first, compact, after_compact, after_resume, after_fork] } -async fn mount_second_compact_flow(server: &MockServer) -> Vec { +async fn mount_second_compact_sequence(server: &MockServer) -> ResponseMock { + let sse1 = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed("r1"), + ]); + let sse2 = sse(vec![ + ev_assistant_message("m2", SUMMARY_TEXT), + ev_completed("r2"), + ]); + let sse3 = sse(vec![ + ev_assistant_message("m3", "AFTER_COMPACT_REPLY"), + ev_completed("r3"), + ]); + let sse4 = sse(vec![ev_completed("r4")]); + let sse5 = sse(vec![ev_completed("r5")]); let sse6 = sse(vec![ ev_assistant_message("m4", SUMMARY_TEXT), ev_completed("r6"), ]); let sse7 = sse(vec![ev_completed("r7")]); + let sse8 = sse(vec![ev_completed("r8")]); - // Keep this matcher broad enough to survive prompt-shape differences across - // platforms/config (history may include either marker text or compact prompt - // fragments), but explicitly exclude the final resume turn so these two - // one-shot mocks cannot race for the same request. - let match_second_compact = |req: &wiremock::Request| { - let body = std::str::from_utf8(&req.body).unwrap_or(""); - (body.contains("AFTER_FORK") - || body_contains_text(body, SUMMARIZATION_PROMPT) - || body.contains(&json_fragment(FIRST_REPLY))) - && !body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\"")) - }; - let second_compact = mount_sse_once_match(server, match_second_compact, sse6).await; - - let match_after_second_resume = |req: &wiremock::Request| { - let body = std::str::from_utf8(&req.body).unwrap_or(""); - body.contains(&format!("\"text\":\"{AFTER_SECOND_RESUME}\"")) - }; - let after_second_resume = mount_sse_once_match(server, match_after_second_resume, sse7).await; - - vec![second_compact, after_second_resume] + mount_sse_sequence(server, vec![sse1, sse2, sse3, sse4, sse5, sse6, sse7, sse8]).await } async fn start_test_conversation( diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 069e824ee5..f297312827 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -6,7 +6,6 @@ use std::string::ToString; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecParams; use codex_core::exec::ExecToolCallOutput; -use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; @@ -14,12 +13,12 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxType; +use codex_sandboxing::get_platform_sandbox; use tempfile::TempDir; use codex_core::error::Result; -use codex_core::get_platform_sandbox; - fn skip_test() -> bool { if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) { eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test."); diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 18be468020..fb055c970f 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -51,6 +51,7 @@ async fn submit_user_turn( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, @@ -131,6 +132,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 96fef440c8..e24cb74d7c 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,3 +1,4 @@ +use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::parse_turn_item; use codex_protocol::items::TurnItem; @@ -110,7 +111,13 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork1, .. } = thread_manager - .fork_thread(1, config_for_fork.clone(), base_path.clone(), false, None) + .fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(1), + config_for_fork.clone(), + base_path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await .expect("fork 1"); @@ -118,9 +125,8 @@ async fn fork_thread_twice_drops_to_first_message() { // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); - assert!(fork1_items.len() > expected_after_first.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(), + serde_json::to_value(&fork1_items).unwrap(), serde_json::to_value(&expected_after_first).unwrap() ); @@ -129,7 +135,13 @@ async fn fork_thread_twice_drops_to_first_message() { thread: codex_fork2, .. } = thread_manager - .fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false, None) + .fork_thread( + ForkSnapshot::TruncateBeforeNthUserMessage(0), + config_for_fork.clone(), + fork1_path.clone(), + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await .expect("fork 2"); @@ -143,9 +155,8 @@ async fn fork_thread_twice_drops_to_first_message() { .unwrap_or(0); let expected_after_second: Vec = fork1_items[..cut_last_on_fork1].to_vec(); let fork2_items = read_items(&fork2_path); - assert!(fork2_items.len() > expected_after_second.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(), + serde_json::to_value(&fork2_items).unwrap(), serde_json::to_value(&expected_after_second).unwrap() ); } diff --git a/codex-rs/core/tests/suite/grep_files.rs b/codex-rs/core/tests/suite/grep_files.rs deleted file mode 100644 index 1b8cc110ef..0000000000 --- a/codex-rs/core/tests/suite/grep_files.rs +++ /dev/null @@ -1,145 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use anyhow::Result; -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; -use std::collections::HashSet; -use std::path::Path; -use std::process::Command as StdCommand; - -const MODEL_WITH_TOOL: &str = "test-gpt-5.1-codex"; - -fn ripgrep_available() -> bool { - StdCommand::new("rg") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -macro_rules! skip_if_ripgrep_missing { - ($ret:expr $(,)?) => {{ - if !ripgrep_available() { - eprintln!("rg not available in PATH; skipping test"); - return $ret; - } - }}; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn grep_files_tool_collects_matches() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_ripgrep_missing!(Ok(())); - - let server = start_mock_server().await; - let test = build_test_codex(&server).await?; - - let search_dir = test.cwd.path().join("src"); - std::fs::create_dir_all(&search_dir)?; - let alpha = search_dir.join("alpha.rs"); - let beta = search_dir.join("beta.rs"); - let gamma = search_dir.join("gamma.txt"); - std::fs::write(&alpha, "alpha needle\n")?; - std::fs::write(&beta, "beta needle\n")?; - std::fs::write(&gamma, "needle in text but excluded\n")?; - - let call_id = "grep-files-collect"; - let arguments = serde_json::json!({ - "pattern": "needle", - "path": search_dir.to_string_lossy(), - "include": "*.rs", - }) - .to_string(); - - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await; - test.submit_turn("please find uses of needle").await?; - - let req = mocks.completion.single_request(); - let (content_opt, success_opt) = req - .function_call_output_content_and_success(call_id) - .expect("tool output present"); - let content = content_opt.expect("content present"); - let success = success_opt.unwrap_or(true); - assert!( - success, - "expected success for matches, got content={content}" - ); - - let entries = collect_file_names(&content); - assert_eq!(entries.len(), 2, "content: {content}"); - assert!( - entries.contains("alpha.rs"), - "missing alpha.rs in {entries:?}" - ); - assert!( - entries.contains("beta.rs"), - "missing beta.rs in {entries:?}" - ); - assert!( - !entries.contains("gamma.txt"), - "txt file should be filtered out: {entries:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn grep_files_tool_reports_empty_results() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_ripgrep_missing!(Ok(())); - - let server = start_mock_server().await; - let test = build_test_codex(&server).await?; - - let search_dir = test.cwd.path().join("logs"); - std::fs::create_dir_all(&search_dir)?; - std::fs::write(search_dir.join("output.txt"), "no hits here")?; - - let call_id = "grep-files-empty"; - let arguments = serde_json::json!({ - "pattern": "needle", - "path": search_dir.to_string_lossy(), - "limit": 5, - }) - .to_string(); - - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await; - test.submit_turn("search again").await?; - - let req = mocks.completion.single_request(); - let (content_opt, success_opt) = req - .function_call_output_content_and_success(call_id) - .expect("tool output present"); - let content = content_opt.expect("content present"); - if let Some(success) = success_opt { - assert!(!success, "expected success=false content={content}"); - } - assert_eq!(content, "No matches found."); - - Ok(()) -} - -#[allow(clippy::expect_used)] -async fn build_test_codex(server: &wiremock::MockServer) -> Result { - let mut builder = test_codex().with_model(MODEL_WITH_TOOL); - builder.build(server).await -} - -fn collect_file_names(content: &str) -> HashSet { - content - .lines() - .filter_map(|line| { - if line.trim().is_empty() { - return None; - } - Path::new(line) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - }) - .collect() -} diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs index e1c45d6418..8a3fadfbb0 100644 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -23,7 +23,14 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { .features .enable(Feature::ChildAgentsMd) .expect("test config should allow feature update"); - std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md"); + std::fs::write( + config + .cwd + .join("AGENTS.md") + .expect("absolute AGENTS.md path"), + "be nice", + ) + .expect("write AGENTS.md"); }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c7042dd251..c7de5dcb26 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -174,6 +174,69 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_pre_tool_use_hook( + home: &Path, + matcher: Option<&str>, + mode: &str, + reason: &str, +) -> Result<()> { + let script_path = home.join("pre_tool_use_hook.py"); + let log_path = home.join("pre_tool_use_hook_log.jsonl"); + let mode_json = serde_json::to_string(mode).context("serialize pre tool use mode")?; + let reason_json = serde_json::to_string(reason).context("serialize pre tool use reason")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{log_path}") +mode = {mode_json} +reason = {reason_json} + +payload = json.load(sys.stdin) + +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +if mode == "json_deny": + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason + }} + }})) +elif mode == "exit_2": + sys.stderr.write(reason + "\n") + raise SystemExit(2) +"#, + log_path = log_path.display(), + mode_json = mode_json, + reason_json = reason_json, + ); + + let mut group = serde_json::json!({ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running pre tool use hook", + }] + }); + if let Some(matcher) = matcher { + group["matcher"] = Value::String(matcher.to_string()); + } + + let hooks = serde_json::json!({ + "hooks": { + "PreToolUse": [group] + } + }); + + fs::write(&script_path, script).context("write pre tool use hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> { let script_path = home.join("session_start_hook.py"); let log_path = home.join("session_start_hook_log.jsonl"); @@ -253,6 +316,15 @@ fn read_stop_hook_inputs(home: &Path) -> Result> { .collect() } +fn read_pre_tool_use_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("pre_tool_use_hook_log.jsonl")) + .context("read pre tool use hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse pre tool use hook log line")) + .collect() +} + fn read_session_start_hook_inputs(home: &Path) -> Result> { fs::read_to_string(home.join("session_start_hook_log.jsonl")) .context("read session start hook log")? @@ -849,3 +921,357 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-shell-command"; + let marker = std::env::temp_dir().join("pretooluse-shell-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "command": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "shell_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "hook blocked it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked by pre hook") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover pre tool use marker")?; + } + + test.submit_turn_with_policy( + "run the blocked shell command", + codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + ) + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked by pre hook"), + "blocked tool output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked tool output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked command should not create marker file" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse"); + assert_eq!(hook_inputs[0]["tool_name"], "Bash"); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + let transcript_path = hook_inputs[0]["transcript_path"] + .as_str() + .expect("pre tool use hook transcript_path"); + assert!( + !transcript_path.is_empty(), + "pre tool use hook should receive a non-empty transcript_path", + ); + assert!( + Path::new(transcript_path).exists(), + "pre tool use hook transcript_path should be materialized on disk", + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-local-shell"; + let marker = std::env::temp_dir().join("pretooluse-local-shell-marker"); + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("printf blocked > {}", marker.display()), + ]; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_local_shell_call( + call_id, + "completed", + command.iter().map(String::as_str).collect(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "local shell blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover local shell marker")?; + } + + test.submit_turn("run the blocked local shell command") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("local shell output string"); + assert!( + output.contains("Bash command blocked by hook: blocked local shell"), + "blocked local shell output should surface the hook reason", + ); + assert!( + output.contains(&format!( + "Command: {}", + codex_shell_command::parse_command::shlex_join(&command) + )), + "blocked local shell output should surface the blocked command", + ); + assert!( + !marker.exists(), + "blocked local shell command should not execute" + ); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0]["tool_input"]["command"], + codex_shell_command::parse_command::shlex_join(&command), + ); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-exec-command"; + let marker = std::env::temp_dir().join("pretooluse-exec-command-marker"); + let command = format!("printf blocked > {}", marker.display()); + let args = serde_json::json!({ "cmd": command }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "exec_command", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "exec command blocked"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = + write_pre_tool_use_hook(home, Some("^Bash$"), "exit_2", "blocked exec command") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + if marker.exists() { + fs::remove_file(&marker).context("remove leftover exec marker")?; + } + + test.submit_turn("run the blocked exec command").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("exec command output string"); + assert!( + output.contains("Bash command blocked by hook: blocked exec command"), + "blocked exec command output should surface the hook reason", + ); + assert!( + output.contains(&format!("Command: {command}")), + "blocked exec command output should surface the blocked command", + ); + assert!(!marker.exists(), "blocked exec command should not execute"); + + let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!(hook_inputs[0]["tool_use_id"], call_id); + assert_eq!(hook_inputs[0]["tool_input"]["command"], command); + assert!( + hook_inputs[0]["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_does_not_fire_for_non_shell_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-update-plan"; + let args = serde_json::json!({ + "plan": [{ + "step": "watch the tide", + "status": "pending", + }] + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + core_test_support::responses::ev_function_call( + call_id, + "update_plan", + &serde_json::to_string(&args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "plan updated"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_pre_tool_use_hook(home, None, "json_deny", "should not fire") + { + panic!("failed to write pre tool use hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("update the plan").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("update plan output string"); + assert!( + !output.contains("should not fire"), + "non-shell tool output should not be blocked by PreToolUse", + ); + + let hook_log_path = test.codex_home_path().join("pre_tool_use_hook_log.jsonl"); + assert!( + !hook_log_path.exists(), + "non-shell tools should not trigger pre tool use hooks", + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 07b51c7629..1526fe11a2 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -122,6 +122,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -205,6 +206,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index d860545553..f949cd4b97 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -522,6 +522,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -598,6 +599,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> { final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -706,6 +708,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -892,6 +895,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, @@ -1005,6 +1009,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { final_output_json_schema: None, cwd: std::env::current_dir()?, approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index f7dfd02daf..3b6f3e3f9c 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -80,6 +80,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, diff --git a/codex-rs/core/tests/suite/list_dir.rs b/codex-rs/core/tests/suite/list_dir.rs deleted file mode 100644 index 802080d846..0000000000 --- a/codex-rs/core/tests/suite/list_dir.rs +++ /dev/null @@ -1,167 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_returns_entries() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("sample_dir"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "first file")?; - std::fs::create_dir(dir_path.join("nested"))?; - let dir_path = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-call"; - let arguments = json!({ - "dir_path": dir_path, - "offset": 1, - "limit": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents").await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_one"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - std::fs::create_dir(dir_path.join("nested"))?; - std::fs::write(dir_path.join("nested").join("beta.txt"), "beta")?; - let dir_path = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth1"; - let arguments = json!({ - "dir_path": dir_path, - "offset": 1, - "limit": 10, - "depth": 1, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth one") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_two"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - let nested = dir_path.join("nested"); - std::fs::create_dir(&nested)?; - std::fs::write(nested.join("beta.txt"), "beta")?; - let deeper = nested.join("grand"); - std::fs::create_dir(&deeper)?; - std::fs::write(deeper.join("gamma.txt"), "gamma")?; - let dir_path_string = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth2"; - let arguments = json!({ - "dir_path": dir_path_string, - "offset": 1, - "limit": 10, - "depth": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth two") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!( - output, - "E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_three"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - let nested = dir_path.join("nested"); - std::fs::create_dir(&nested)?; - std::fs::write(nested.join("beta.txt"), "beta")?; - let deeper = nested.join("grand"); - std::fs::create_dir(&deeper)?; - std::fs::write(deeper.join("gamma.txt"), "gamma")?; - let dir_path_string = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth3"; - let arguments = json!({ - "dir_path": dir_path_string, - "offset": 1, - "limit": 10, - "depth": 3, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth three") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!( - output, - "E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand\nE5: [file] nested/grand/gamma.txt" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs index 2192bdffd9..663cf47488 100644 --- a/codex-rs/core/tests/suite/live_reload.rs +++ b/codex-rs/core/tests/suite/live_reload.rs @@ -61,6 +61,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..f4891d58c5 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -75,7 +75,6 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; -mod grep_files; mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; @@ -83,7 +82,6 @@ mod image_rollout; mod items; mod js_repl; mod json_result; -mod list_dir; mod live_cli; mod live_reload; mod memories; @@ -101,7 +99,6 @@ mod personality_migration; mod plugins; mod prompt_caching; mod quota_exceeded; -mod read_file; mod realtime_conversation; mod remote_env; mod remote_models; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 9902f0ee6c..a7c5ceb0d2 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -126,6 +126,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -162,6 +163,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, @@ -221,6 +223,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -257,6 +260,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: next_model.to_string(), effort: test.config.model_reasoning_effort, @@ -398,6 +402,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -418,6 +423,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -525,6 +531,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -545,6 +552,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -655,6 +663,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -675,6 +684,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -787,6 +797,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -815,6 +826,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: image_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -969,6 +981,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: large_model_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -1027,6 +1040,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: smaller_model_slug.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index a10fa7c262..49f635432f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -121,6 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -144,6 +145,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { final_output_json_schema: None, cwd: preturn_context_diff_cwd, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -222,6 +224,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R final_output_json_schema: None, cwd: cwd_one.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -245,6 +248,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R final_output_json_schema: None, cwd: cwd_two, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -354,6 +358,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul final_output_json_schema: None, cwd: resume_override_cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: resumed.session_configured.model.clone(), effort: resumed.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 7cb7573347..3d5904bd7a 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -96,6 +96,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: codex_protocol::protocol::AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index a479ebb80c..272c820caf 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -102,6 +102,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 08b90c5bf1..b66d34f8b9 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1024,6 +1024,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .features .disable(Feature::GhostCommit) .expect("test config should allow feature update"); + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); }) .build(&server) .await @@ -1040,7 +1041,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { .await .unwrap(); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; logs_assert(|lines: &[&str]| { let line = lines diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 2838233e7f..e71a158797 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_core::ForkSnapshot; use codex_core::config::Constrained; use codex_execpolicy::Policy; use codex_protocol::models::DeveloperInstructions; @@ -419,7 +420,13 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted); let forked = initial .thread_manager - .fork_thread(usize::MAX, fork_config, rollout_path, false, None) + .fork_thread( + ForkSnapshot::Interrupted, + fork_config, + rollout_path, + /*persist_extended_history*/ false, + /*parent_trace*/ None, + ) .await?; forked .thread @@ -436,14 +443,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { let body4 = req4.single_request().body_json(); let input4 = body4["input"].as_array().expect("input array"); let permissions_fork = permissions_texts(input4); - assert_eq!(permissions_fork.len(), permissions_base.len() + 2); + assert_eq!(permissions_fork.len(), permissions_base.len() + 1); assert_eq!( &permissions_fork[..permissions_base.len()], permissions_base.as_slice() ); let new_permissions = &permissions_fork[permissions_base.len()..]; - assert_eq!(new_permissions.len(), 2); - assert_eq!(new_permissions[0], new_permissions[1]); + assert_eq!(new_permissions.len(), 1); + assert_eq!(permissions_fork, permissions_resume); assert!(!permissions_base.contains(&new_permissions[0])); Ok(()) diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9a495e7af4..ed4da07168 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -103,6 +103,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -153,6 +154,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -210,6 +212,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -273,6 +276,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -324,6 +328,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -361,6 +366,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -427,6 +433,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -464,6 +471,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -543,6 +551,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -580,6 +589,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: test.config.model_reasoning_effort, @@ -696,6 +706,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -814,6 +825,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, @@ -851,6 +863,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: remote_slug.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 14caaf8f0b..1bcd068614 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -16,7 +16,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::TempDirExt; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -689,7 +689,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()], + writable_roots: vec![writable.abs()], read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, @@ -703,6 +703,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], cwd: new_cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: new_policy.clone(), model: "o3".to_string(), effort: Some(ReasoningEffort::High), @@ -813,8 +814,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, @@ -833,8 +835,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model.clone(), effort: default_effort, @@ -937,8 +940,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, + approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), model: default_model, effort: default_effort, @@ -957,8 +961,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: "o3".to_string(), effort: Some(ReasoningEffort::High), diff --git a/codex-rs/core/tests/suite/read_file.rs b/codex-rs/core/tests/suite/read_file.rs deleted file mode 100644 index 57be21f9f7..0000000000 --- a/codex-rs/core/tests/suite/read_file.rs +++ /dev/null @@ -1,42 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable read_file tool"] -async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let file_path = test.cwd.path().join("sample.txt"); - std::fs::write(&file_path, "first\nsecond\nthird\nfourth\n")?; - let file_path = file_path.to_string_lossy().to_string(); - - let call_id = "read-file-call"; - let arguments = json!({ - "file_path": file_path, - "offset": 2, - "limit": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "read_file").await; - - test.submit_turn("please inspect sample.txt").await?; - - let req = mocks.completion.single_request(); - let (output_text_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("output present"); - let output_text = output_text_opt.expect("output text present"); - assert_eq!(output_text, "L2: second\nL3: third"); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 860d83fe9a..83d911533d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -173,6 +173,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, @@ -231,6 +232,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), model: requested_model.to_string(), effort: None, @@ -394,6 +396,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REMOTE_MODEL_SLUG.to_string(), effort: None, @@ -612,6 +615,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model.to_string(), effort: None, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index b1aaac65b4..7c16599bf0 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -193,6 +193,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a01d6e0ab7..14506f4a41 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -145,6 +145,7 @@ async fn submit_turn( final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 1bf759d270..8e30b37c21 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -138,6 +138,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -255,6 +256,7 @@ where final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index b1e6a694ca..e609005436 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,7 +28,7 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), trace_id: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 81d3d1e6da..7aa05f9720 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; @@ -817,7 +818,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { let codex_home = Arc::new(TempDir::new().unwrap()); let initial_cwd_path = initial_cwd.path().to_path_buf(); let codex = new_conversation_for_server(&server, codex_home.clone(), move |config| { - config.cwd = initial_cwd_path; + config.cwd = initial_cwd_path.abs(); }) .await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 0f31222923..39f8f9db3c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -131,6 +131,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -325,6 +326,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -528,6 +530,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: text_only_model_slug.to_string(), effort: None, @@ -642,6 +645,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -803,6 +807,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -1049,6 +1054,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index eabbdc9d03..51a88ef16a 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -45,6 +45,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -143,6 +144,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -228,6 +230,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, @@ -277,6 +280,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: REQUESTED_MODEL.to_string(), effort: test.config.model_reasoning_effort, diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 0eee2f1d3d..423c57a8c6 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -86,7 +86,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -169,6 +177,45 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "list tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 68228a412e..0e044f3922 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -72,7 +72,7 @@ async fn wait_for_snapshot(codex_home: &Path) -> Result { } async fn wait_for_file_contents(path: &Path) -> Result { - let deadline = Instant::now() + Duration::from_secs(5); + let deadline = Instant::now() + Duration::from_secs(15); loop { match fs::read_to_string(path).await { Ok(contents) => return Ok(contents), @@ -164,6 +164,7 @@ async fn run_snapshot_command_with_options( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -254,6 +255,7 @@ async fn run_shell_command_snapshot_with_options( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -324,6 +326,7 @@ async fn run_tool_turn_on_harness( final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -528,7 +531,9 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: snapshot-apply.txt\n+hello from snapshot\n*** End Patch\nEOF\n"; let args = json!({ "command": script, - "timeout_ms": 1_000, + // The intercepted apply_patch path self-invokes codex, which can take + // longer than a second in Bazel macOS test environments. + "timeout_ms": 5_000, }); let call_id = "shell-snapshot-apply-patch"; let responses = vec![ @@ -555,6 +560,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { final_output_json_schema: None, cwd: cwd.clone(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model, effort: None, @@ -569,7 +575,32 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let snapshot_content = fs::read_to_string(&snapshot_path).await?; assert_posix_snapshot_sections(&snapshot_content); - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + let mut saw_patch_begin = false; + let mut patch_end = None; + wait_for_event(&codex, |ev| match ev { + EventMsg::PatchApplyBegin(begin) if begin.call_id == call_id => { + saw_patch_begin = true; + false + } + EventMsg::PatchApplyEnd(end) if end.call_id == call_id => { + patch_end = Some(end.clone()); + false + } + EventMsg::TurnComplete(_) => true, + _ => false, + }) + .await; + + assert!( + saw_patch_begin, + "expected apply_patch to emit PatchApplyBegin" + ); + let patch_end = patch_end.expect("expected apply_patch to emit PatchApplyEnd"); + assert!( + patch_end.success, + "expected apply_patch to finish successfully: stdout={:?} stderr={:?}", + patch_end.stdout, patch_end.stderr, + ); assert_eq!( wait_for_file_contents(&target).await?, diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index b5fda12ae0..5a50e09cee 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -65,6 +65,7 @@ async fn submit_turn_with_policies( final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy, + approvals_reviewer: None, sandbox_policy, model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 801e0dd6bb..388618b56a 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -74,6 +74,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_trims_context_updates.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_trims_context_updates.snap new file mode 100644 index 0000000000..925a810a43 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_trims_context_updates.snap @@ -0,0 +1,23 @@ +--- +source: core/tests/suite/compact_resume_fork.rs +expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback trims pre-turn override context updates before the follow-up request\",\n&[(\"rolled-back turn request\", &requests[1]),\n(\"follow-up request after rollback\", &requests[2]),],\n&ContextSnapshotOptions::default().strip_capability_instructions().render_mode(ContextSnapshotRenderMode::KindWithTextPrefix\n{ max_chars: 96 }),)" +--- +Scenario: rollback trims pre-turn override context updates before the follow-up request + +## rolled-back turn request +00:message/developer: +01:message/user:> +02:message/user:turn 1 user +03:message/assistant:turn 1 assistant +04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +05:message/user: +06:message/user:turn 2 user + +## follow-up request after rollback +00:message/developer: +01:message/user:> +02:message/user:turn 1 user +03:message/assistant:turn 1 assistant +04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS +05:message/user: +06:message/user:follow-up user diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_past_compaction_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_past_compaction_shapes.snap index 04e45c3a68..bb694af693 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_past_compaction_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_past_compaction_shapes.snap @@ -23,6 +23,4 @@ Scenario: rollback past compaction replay after rollback 01:message/user:\nSUMMARY_ONLY_CONTEXT 02:message/developer: 03:message/user:> -04:message/developer: -05:message/user:> -06:message/user:AFTER_ROLLBACK +04:message/user:AFTER_ROLLBACK diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 248ada02cc..2df92dbf95 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -395,6 +395,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< final_output_json_schema: None, cwd: test.cwd_path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: test.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index bb1da9e8b1..9594195a5b 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -85,6 +85,7 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -155,6 +156,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -235,6 +237,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -330,6 +333,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -433,6 +437,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index a177a6ee37..faff0b2e09 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -42,6 +42,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -358,6 +359,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an final_output_json_schema: None, cwd: test.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index e7b25799ef..f10567de24 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -486,6 +486,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7252d9a6b6..c07fd20e5e 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -192,6 +192,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { final_output_json_schema: None, cwd, approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -325,6 +326,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -407,6 +409,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -492,6 +495,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -589,6 +593,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -668,6 +673,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -748,6 +754,7 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -882,6 +889,7 @@ async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1023,6 +1031,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1187,6 +1196,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1289,6 +1299,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1411,6 +1422,7 @@ async fn unified_exec_defaults_to_pipe() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1505,6 +1517,7 @@ async fn unified_exec_can_enable_tty() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1590,6 +1603,7 @@ async fn unified_exec_respects_early_exit_notifications() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1725,6 +1739,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1897,6 +1912,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1978,6 +1994,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2070,6 +2087,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2178,6 +2196,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2317,6 +2336,7 @@ PY final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2435,6 +2455,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2535,6 +2556,7 @@ PY final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2634,6 +2656,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, // Important! sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, @@ -2743,6 +2766,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), model: session_model, effort: None, @@ -2842,6 +2866,7 @@ async fn unified_exec_runs_on_all_platforms() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -2981,6 +3006,7 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> { final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index eb593c6fe3..c0fe8b5352 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -46,7 +47,7 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let server = start_mock_server().await; let cwd_path = cwd.path().to_path_buf(); let mut builder = test_codex().with_config(move |config| { - config.cwd = cwd_path; + config.cwd = cwd_path.abs(); }); let codex = builder .build(&server) @@ -176,6 +177,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: fixture.session_configured.model.clone(), effort: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index efc2e53324..4041ca1d19 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -41,7 +41,6 @@ use image::load_from_memory; use pretty_assertions::assert_eq; use serde_json::Value; use std::io::Cursor; -use std::path::Path; use std::path::PathBuf; use tokio::time::Duration; use wiremock::BodyPrintLimit; @@ -78,11 +77,6 @@ fn find_image_message(body: &Value) -> Option<&Value> { image_messages(body).into_iter().next() } -fn absolute_path(path: &Path) -> anyhow::Result { - codex_utils_absolute_path::AbsolutePathBuf::try_from(path.to_path_buf()) - .map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display())) -} - fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> { let image = ImageBuffer::from_pixel(width, height, Rgba(rgba)); let mut cursor = Cursor::new(Vec::new()); @@ -91,14 +85,11 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> } async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result { - let abs_path = test.config.cwd.join(rel_path); + let abs_path = test.config.cwd.join(rel_path)?; test.fs() - .create_directory( - &absolute_path(&abs_path)?, - CreateDirectoryOptions { recursive: true }, - ) + .create_directory(&abs_path, CreateDirectoryOptions { recursive: true }) .await?; - Ok(abs_path) + Ok(abs_path.into_path_buf()) } async fn write_workspace_file( @@ -106,19 +97,14 @@ async fn write_workspace_file( rel_path: &str, contents: Vec, ) -> anyhow::Result { - let abs_path = test.config.cwd.join(rel_path); + let abs_path = test.config.cwd.join(rel_path)?; if let Some(parent) = abs_path.parent() { test.fs() - .create_directory( - &absolute_path(parent)?, - CreateDirectoryOptions { recursive: true }, - ) + .create_directory(&parent, CreateDirectoryOptions { recursive: true }) .await?; } - test.fs() - .write_file(&absolute_path(&abs_path)?, contents) - .await?; - Ok(abs_path) + test.fs().write_file(&abs_path, contents).await?; + Ok(abs_path.into_path_buf()) } async fn write_workspace_png( @@ -168,8 +154,9 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { path: abs_path.clone(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -239,7 +226,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let cwd = config.cwd.clone(); let rel_path = "assets/example.png"; - let abs_path = cwd.join(rel_path); + let abs_path = cwd.join(rel_path)?; let original_width = 2304; let original_height = 864; write_workspace_png( @@ -276,8 +263,9 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -310,7 +298,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { _ => unreachable!("stored event must be ViewImageToolCall"), }; assert_eq!(tool_event.call_id, call_id); - assert_eq!(tool_event.path, abs_path); + assert_eq!(tool_event.path, abs_path.to_path_buf()); let req = mock.single_request(); let body = req.body_json(); @@ -416,8 +404,9 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -514,8 +503,9 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -605,8 +595,9 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -704,8 +695,9 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -814,8 +806,9 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -917,6 +910,7 @@ await codex.emitImage(out); final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1036,6 +1030,7 @@ console.log(out.type); final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1126,8 +1121,9 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1201,8 +1197,9 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1254,7 +1251,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { } = &test; let rel_path = "missing/example.png"; - let abs_path = config.cwd.join(rel_path); + let abs_path = config.cwd.join(rel_path)?; let call_id = "view-image-missing"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1281,8 +1278,9 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, @@ -1403,8 +1401,9 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: model_slug.to_string(), effort: None, @@ -1477,8 +1476,9 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> path: abs_path.clone(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_model, effort: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 0090093c77..e7f33df148 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -156,6 +156,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result final_output_json_schema: None, cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, + approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, model: session_configured.model.clone(), effort: None, diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 4d4e5c147c..9f238b40ee 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -65,7 +65,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t For complete documentation of the `Op` and `EventMsg` variants, refer to [protocol.rs](../protocol/src/protocol.rs). Some example payload types: - `Op` - - `Op::UserTurn` – Any input from the user to kick off a `Turn` + - `Op::UserTurn` – Any input from the user to kick off a `Turn`, including full per-turn context such as cwd, model, sandbox, approval policy, and optional `approvals_reviewer` - `Op::UserInput` – Legacy form of user input - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 3785235606..be9cf5d750 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -3,6 +3,7 @@ name = "codex-exec" version.workspace = true edition.workspace = true license.workspace = true +autotests = false [[bin]] name = "codex-exec" @@ -12,6 +13,10 @@ path = "src/main.rs" name = "codex_exec" path = "src/lib.rs" +[[test]] +name = "all" +path = "tests/all.rs" + [lints] workspace = true @@ -24,17 +29,15 @@ codex-app-server-protocol = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } codex-feedback = { workspace = true } +codex-git-utils = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } -codex-utils-elapsed = { workspace = true } codex-utils-oss = { workspace = true } -codex-utils-sandbox-summary = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -shlex = { workspace = true } supports-color = { workspace = true } tokio = { workspace = true, features = [ "io-std", @@ -63,7 +66,6 @@ opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } -rmcp = { workspace = true } tempfile = { workspace = true } tracing-opentelemetry = { workspace = true } uuid = { workspace = true } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 6cda7e4082..c402371607 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -86,10 +86,6 @@ pub struct Cli { #[arg(long = "color", value_enum, default_value_t = Color::Auto)] pub color: Color, - /// Force cursor-based progress updates in exec mode. - #[arg(long = "progress-cursor", default_value_t = false)] - pub progress_cursor: bool, - /// Print events to stdout as JSONL. #[arg( long = "json", diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index fc9d123e0d..4d1da09001 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,13 +1,13 @@ use std::path::Path; +use codex_app_server_protocol::ServerNotification; use codex_core::config::Config; -use codex_protocol::protocol::Event; use codex_protocol::protocol::SessionConfiguredEvent; -pub(crate) enum CodexStatus { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexStatus { Running, InitiateShutdown, - Shutdown, } pub(crate) trait EventProcessor { @@ -19,8 +19,11 @@ pub(crate) trait EventProcessor { session_configured: &SessionConfiguredEvent, ); - /// Handle a single event emitted by the agent. - fn process_event(&mut self, event: Event) -> CodexStatus; + /// Handle a single typed app-server notification emitted by the agent. + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus; + + /// Handle a local exec warning that is not represented as an app-server notification. + fn process_warning(&mut self, message: String) -> CodexStatus; fn print_final_output(&mut self) {} } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 0e49166b81..8851233e56 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,183 +1,214 @@ +use std::io::IsTerminal; +use std::path::PathBuf; + +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; +use codex_core::WireApi; use codex_core::config::Config; -use codex_core::web_search::web_search_detail; -use codex_protocol::items::TurnItem; use codex_protocol::num_format::format_with_separators; -use codex_protocol::protocol::AgentMessageEvent; -use codex_protocol::protocol::AgentReasoningRawContentEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::BackgroundEventEvent; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::DeprecationNoticeEvent; -use codex_protocol::protocol::ErrorEvent; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::HookCompletedEvent; -use codex_protocol::protocol::HookEventName; -use codex_protocol::protocol::HookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus; -use codex_protocol::protocol::HookStartedEvent; -use codex_protocol::protocol::ItemCompletedEvent; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::PatchApplyEndEvent; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::StreamErrorEvent; -use codex_protocol::protocol::TurnAbortReason; -use codex_protocol::protocol::TurnCompleteEvent; -use codex_protocol::protocol::TurnDiffEvent; -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchEndEvent; -use codex_utils_elapsed::format_duration; -use codex_utils_elapsed::format_elapsed; use owo_colors::OwoColorize; use owo_colors::Style; -use serde::Deserialize; -use shlex::try_join; -use std::collections::HashMap; -use std::io::IsTerminal; -use std::io::Write; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_utils_sandbox_summary::create_config_summary_entries; -/// This should be configurable. When used in CI, users may not want to impose -/// a limit so they can see the full transcript. -const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20; pub(crate) struct EventProcessorWithHumanOutput { - call_id_to_patch: HashMap, - - // To ensure that --color=never is respected, ANSI escapes _must_ be added - // using .style() with one of these fields. If you need a new style, add a - // new field here. bold: Style, - italic: Style, + cyan: Style, dimmed: Style, - + green: Style, + italic: Style, magenta: Style, red: Style, - green: Style, - cyan: Style, yellow: Style, - - /// Whether to include `AgentReasoning` events in the output. show_agent_reasoning: bool, show_raw_agent_reasoning: bool, last_message_path: Option, - last_total_token_usage: Option, final_message: Option, - last_proposed_plan: Option, - progress_active: bool, - progress_last_len: usize, - use_ansi_cursor: bool, - progress_anchor: bool, - progress_done: bool, + final_message_rendered: bool, + emit_final_message_on_shutdown: bool, + last_total_token_usage: Option, } impl EventProcessorWithHumanOutput { pub(crate) fn create_with_ansi( with_ansi: bool, - cursor_ansi: bool, config: &Config, last_message_path: Option, ) -> Self { - let call_id_to_patch = HashMap::new(); + let style = |styled: Style, plain: Style| if with_ansi { styled } else { plain }; + Self { + bold: style(Style::new().bold(), Style::new()), + cyan: style(Style::new().cyan(), Style::new()), + dimmed: style(Style::new().dimmed(), Style::new()), + green: style(Style::new().green(), Style::new()), + italic: style(Style::new().italic(), Style::new()), + magenta: style(Style::new().magenta(), Style::new()), + red: style(Style::new().red(), Style::new()), + yellow: style(Style::new().yellow(), Style::new()), + show_agent_reasoning: !config.hide_agent_reasoning, + show_raw_agent_reasoning: config.show_raw_agent_reasoning, + last_message_path, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + } + } - if with_ansi { - Self { - call_id_to_patch, - bold: Style::new().bold(), - italic: Style::new().italic(), - dimmed: Style::new().dimmed(), - magenta: Style::new().magenta(), - red: Style::new().red(), - green: Style::new().green(), - cyan: Style::new().cyan(), - yellow: Style::new().yellow(), - show_agent_reasoning: !config.hide_agent_reasoning, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - last_message_path, - last_total_token_usage: None, - final_message: None, - last_proposed_plan: None, - progress_active: false, - progress_last_len: 0, - use_ansi_cursor: cursor_ansi, - progress_anchor: false, - progress_done: false, + fn render_item_started(&self, item: &ThreadItem) { + match item { + ThreadItem::CommandExecution { command, cwd, .. } => { + eprintln!( + "{}\n{} in {}", + "exec".style(self.italic).style(self.magenta), + command.style(self.bold), + cwd.display() + ); } - } else { - Self { - call_id_to_patch, - bold: Style::new(), - italic: Style::new(), - dimmed: Style::new(), - magenta: Style::new(), - red: Style::new(), - green: Style::new(), - cyan: Style::new(), - yellow: Style::new(), - show_agent_reasoning: !config.hide_agent_reasoning, - show_raw_agent_reasoning: config.show_raw_agent_reasoning, - last_message_path, - last_total_token_usage: None, - final_message: None, - last_proposed_plan: None, - progress_active: false, - progress_last_len: 0, - use_ansi_cursor: cursor_ansi, - progress_anchor: false, - progress_done: false, + ThreadItem::McpToolCall { server, tool, .. } => { + eprintln!( + "{} {} {}", + "mcp:".style(self.bold), + format!("{server}/{tool}").style(self.cyan), + "started".style(self.dimmed) + ); } + ThreadItem::WebSearch { query, .. } => { + eprintln!("{} {}", "web search:".style(self.bold), query); + } + ThreadItem::FileChange { .. } => { + eprintln!("{}", "apply patch".style(self.bold)); + } + ThreadItem::CollabAgentToolCall { tool, .. } => { + eprintln!("{} {:?}", "collab:".style(self.bold), tool); + } + _ => {} + } + } + + fn render_item_completed(&mut self, item: ThreadItem) { + match item { + ThreadItem::AgentMessage { text, .. } => { + eprintln!( + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + text + ); + self.final_message = Some(text); + self.final_message_rendered = true; + } + ThreadItem::Reasoning { + summary, content, .. + } => { + if self.show_agent_reasoning + && let Some(text) = + reasoning_text(&summary, &content, self.show_raw_agent_reasoning) + && !text.trim().is_empty() + { + eprintln!("{}", text.style(self.dimmed)); + } + } + ThreadItem::CommandExecution { + command: _, + aggregated_output, + exit_code, + status, + duration_ms, + .. + } => { + let duration_suffix = duration_ms + .map(|duration_ms| format!(" in {duration_ms}ms")) + .unwrap_or_default(); + match status { + CommandExecutionStatus::Completed => { + eprintln!( + "{}", + format!(" succeeded{duration_suffix}:").style(self.green) + ); + } + CommandExecutionStatus::Failed => { + let exit_code = exit_code.unwrap_or(1); + eprintln!( + "{}", + format!(" exited {exit_code}{duration_suffix}:").style(self.red) + ); + } + CommandExecutionStatus::Declined => { + eprintln!( + "{}", + format!(" declined{duration_suffix}:").style(self.yellow) + ); + } + CommandExecutionStatus::InProgress => { + eprintln!( + "{}", + format!(" in progress{duration_suffix}:").style(self.dimmed) + ); + } + } + if let Some(output) = aggregated_output + && !output.trim().is_empty() + { + eprintln!("{output}"); + } + } + ThreadItem::FileChange { + changes, status, .. + } => { + let status_text = match status { + PatchApplyStatus::Completed => "completed", + PatchApplyStatus::Failed => "failed", + PatchApplyStatus::Declined => "declined", + PatchApplyStatus::InProgress => "in_progress", + }; + eprintln!("{} {}", "patch:".style(self.bold), status_text); + for change in changes { + eprintln!("{}", change.path.style(self.dimmed)); + } + } + ThreadItem::McpToolCall { + server, + tool, + status, + error, + .. + } => { + let status_text = match status { + McpToolCallStatus::Completed => "completed".style(self.green), + McpToolCallStatus::Failed => "failed".style(self.red), + McpToolCallStatus::InProgress => "in_progress".style(self.dimmed), + }; + eprintln!( + "{} {} {}", + "mcp:".style(self.bold), + format!("{server}/{tool}").style(self.cyan), + format!("({status_text})").style(self.dimmed) + ); + if let Some(error) = error { + eprintln!("{}", error.message.style(self.red)); + } + } + ThreadItem::WebSearch { query, .. } => { + eprintln!("{} {}", "web search:".style(self.bold), query); + } + ThreadItem::ContextCompaction { .. } => { + eprintln!("{}", "context compacted".style(self.dimmed)); + } + _ => {} } } } -#[derive(Debug, Deserialize)] -struct AgentJobProgressMessage { - job_id: String, - total_items: usize, - pending_items: usize, - running_items: usize, - completed_items: usize, - failed_items: usize, - eta_seconds: Option, -} - -struct PatchApplyBegin { - start_time: Instant, - auto_approved: bool, -} - -/// Timestamped helper. The timestamp is styled with self.dimmed. -macro_rules! ts_msg { - ($self:ident, $($arg:tt)*) => {{ - eprintln!($($arg)*); - }}; -} - impl EventProcessor for EventProcessorWithHumanOutput { - /// Print a concise summary of the effective configuration that will be used - /// for the session. This mirrors the information shown in the TUI welcome - /// screen. fn print_config_summary( &mut self, config: &Config, @@ -185,978 +216,336 @@ impl EventProcessor for EventProcessorWithHumanOutput { session_configured_event: &SessionConfiguredEvent, ) { const VERSION: &str = env!("CARGO_PKG_VERSION"); - ts_msg!( - self, - "OpenAI Codex v{} (research preview)\n--------", - VERSION - ); - - let mut entries = - create_config_summary_entries(config, session_configured_event.model.as_str()); - entries.push(( - "session id", - session_configured_event.session_id.to_string(), - )); - - for (key, value) in entries { + eprintln!("OpenAI Codex v{VERSION} (research preview)\n--------"); + for (key, value) in config_summary_entries(config, session_configured_event) { eprintln!("{} {}", format!("{key}:").style(self.bold), value); } - eprintln!("--------"); - - // Echo the prompt that will be sent to the agent so it is visible in the - // transcript/logs before any events come in. Note the prompt may have been - // read from stdin, so it may not be visible in the terminal otherwise. - ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt); + eprintln!("{}\n{}", "user".style(self.cyan), prompt); } - fn process_event(&mut self, event: Event) -> CodexStatus { - let Event { id: _, msg } = event; - if let EventMsg::BackgroundEvent(BackgroundEventEvent { message }) = &msg - && let Some(update) = Self::parse_agent_job_progress(message) - { - self.render_agent_job_progress(update); - return CodexStatus::Running; - } - if self.progress_active && !Self::should_interrupt_progress(&msg) { - return CodexStatus::Running; - } - if !Self::is_silent_event(&msg) { - self.finish_progress_line(); - } - match msg { - EventMsg::Error(ErrorEvent { message, .. }) => { - let prefix = "ERROR:".style(self.red); - ts_msg!(self, "{prefix} {message}"); - } - EventMsg::Warning(WarningEvent { message }) => { - ts_msg!( - self, - "{} {message}", - "warning:".style(self.yellow).style(self.bold) + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus { + match notification { + ServerNotification::ConfigWarning(notification) => { + let details = notification + .details + .map(|details| format!(" ({details})")) + .unwrap_or_default(); + eprintln!( + "{} {}{}", + "warning:".style(self.yellow).style(self.bold), + notification.summary, + details ); + CodexStatus::Running } - EventMsg::GuardianAssessment(_) => {} - EventMsg::ModelReroute(_) => {} - EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }) => { - ts_msg!( - self, - "{} {summary}", - "deprecated:".style(self.magenta).style(self.bold) - ); - if let Some(details) = details { - ts_msg!(self, " {}", details.style(self.dimmed)); - } - } - EventMsg::McpStartupUpdate(update) => { - let status_text = match update.status { - codex_protocol::protocol::McpStartupStatus::Starting => "starting".to_string(), - codex_protocol::protocol::McpStartupStatus::Ready => "ready".to_string(), - codex_protocol::protocol::McpStartupStatus::Cancelled => { - "cancelled".to_string() - } - codex_protocol::protocol::McpStartupStatus::Failed { ref error } => { - format!("failed: {error}") - } - }; - ts_msg!( - self, - "{} {} {}", - "mcp:".style(self.cyan), - update.server, - status_text - ); - } - EventMsg::McpStartupComplete(summary) => { - let mut parts = Vec::new(); - if !summary.ready.is_empty() { - parts.push(format!("ready: {}", summary.ready.join(", "))); - } - if !summary.failed.is_empty() { - let servers: Vec<_> = summary.failed.iter().map(|f| f.server.clone()).collect(); - parts.push(format!("failed: {}", servers.join(", "))); - } - if !summary.cancelled.is_empty() { - parts.push(format!("cancelled: {}", summary.cancelled.join(", "))); - } - let joined = if parts.is_empty() { - "no servers".to_string() - } else { - parts.join("; ") - }; - ts_msg!(self, "{} {}", "mcp startup:".style(self.cyan), joined); - } - EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { - ts_msg!(self, "{}", message.style(self.dimmed)); - } - EventMsg::StreamError(StreamErrorEvent { - message, - additional_details, - .. - }) => { - let message = match additional_details { - Some(details) if !details.trim().is_empty() => format!("{message} ({details})"), - _ => message, - }; - ts_msg!(self, "{}", message.style(self.dimmed)); - } - EventMsg::TurnStarted(_) => { - // Ignore. - } - EventMsg::ElicitationRequest(ev) => { - ts_msg!( - self, + ServerNotification::Error(notification) => { + eprintln!( "{} {}", - "elicitation request".style(self.magenta), - ev.server_name.style(self.dimmed) - ); - ts_msg!( - self, - "{}", - "auto-cancelling (not supported in exec mode)".style(self.dimmed) + "ERROR:".style(self.red).style(self.bold), + notification.error ); + CodexStatus::Running } - EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message, .. - }) => { - let last_message = last_agent_message - .as_deref() - .or(self.last_proposed_plan.as_deref()); - if let Some(output_file) = self.last_message_path.as_deref() { - handle_last_message(last_message, output_file); - } - - self.final_message = last_agent_message.or_else(|| self.last_proposed_plan.clone()); - - return CodexStatus::InitiateShutdown; - } - EventMsg::TokenCount(ev) => { - self.last_total_token_usage = ev.info; - } - - EventMsg::AgentReasoningSectionBreak(_) => { - if !self.show_agent_reasoning { - return CodexStatus::Running; - } - eprintln!(); - } - EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - if self.show_raw_agent_reasoning { - ts_msg!( - self, - "{}\n{}", - "thinking".style(self.italic).style(self.magenta), - text, - ); - } - } - EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => { - ts_msg!( - self, - "{}\n{}", - "codex".style(self.italic).style(self.magenta), - message, - ); - } - EventMsg::ItemCompleted(ItemCompletedEvent { - item: TurnItem::Plan(item), - .. - }) => { - self.last_proposed_plan = Some(item.text); - } - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => { - eprint!( - "{}\n{} in {}", - "exec".style(self.italic).style(self.magenta), - escape_command(&command).style(self.bold), - cwd.to_string_lossy(), - ); - } - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - aggregated_output, - duration, - exit_code, - .. - }) => { - let duration = format!(" in {}", format_duration(duration)); - - let truncated_output = aggregated_output - .lines() - .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) - .collect::>() - .join("\n"); - match exit_code { - 0 => { - let title = format!(" succeeded{duration}:"); - ts_msg!(self, "{}", title.style(self.green)); - } - _ => { - let title = format!(" exited {exit_code}{duration}:"); - ts_msg!(self, "{}", title.style(self.red)); - } - } - eprintln!("{}", truncated_output.style(self.dimmed)); - } - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: _, - invocation, - }) => { - ts_msg!( - self, + ServerNotification::DeprecationNotice(notification) => { + eprintln!( "{} {}", - "tool".style(self.magenta), - format_mcp_invocation(&invocation).style(self.bold), + "deprecated:".style(self.yellow).style(self.bold), + notification.summary ); - } - EventMsg::McpToolCallEnd(tool_call_end_event) => { - let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { - call_id: _, - result, - invocation, - duration, - } = tool_call_end_event; - - let duration = format!(" in {}", format_duration(duration)); - - let status_str = if is_success { "success" } else { "failed" }; - let title_style = if is_success { self.green } else { self.red }; - let title = format!( - "{} {status_str}{duration}:", - format_mcp_invocation(&invocation) - ); - - ts_msg!(self, "{}", title.style(title_style)); - - if let Ok(res) = result { - let val = serde_json::to_value(res) - .unwrap_or_else(|_| serde_json::Value::String("".to_string())); - let pretty = - serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); - - for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { - eprintln!("{}", line.style(self.dimmed)); - } + if let Some(details) = notification.details { + eprintln!("{}", details.style(self.dimmed)); } + CodexStatus::Running } - EventMsg::WebSearchBegin(_) => { - ts_msg!(self, "🌐 Searching the web..."); - } - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: _, - query, - action, - }) => { - let detail = web_search_detail(Some(&action), &query); - if detail.is_empty() { - ts_msg!(self, "🌐 Searched the web"); - } else { - ts_msg!(self, "🌐 Searched: {detail}"); - } - } - EventMsg::ImageGenerationBegin(generated) => { - ts_msg!( - self, + ServerNotification::HookStarted(notification) => { + eprintln!( "{} {}", - "image generation started".style(self.magenta), - generated.call_id + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed) ); + CodexStatus::Running } - EventMsg::ImageGenerationEnd(generated) => { - if !generated.result.is_empty() - && !generated.result.starts_with("data:") - && !generated.result.starts_with("http://") - && !generated.result.starts_with("https://") - && !generated.result.starts_with("file://") - { - ts_msg!( - self, - "{} {} {}", - "generated image".style(self.magenta), - generated.call_id, - generated.result.style(self.dimmed) - ); - } else { - ts_msg!( - self, - "{} {}", - "generated image".style(self.magenta), - generated.call_id - ); - } + ServerNotification::HookCompleted(notification) => { + eprintln!( + "{} {} {:?}", + "hook:".style(self.bold), + format!("{:?}", notification.run.event_name).style(self.dimmed), + notification.run.status + ); + CodexStatus::Running } - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved, - changes, - .. - }) => { - // Store metadata so we can calculate duration later when we - // receive the corresponding PatchApplyEnd event. - self.call_id_to_patch.insert( - call_id, - PatchApplyBegin { - start_time: Instant::now(), - auto_approved, - }, + ServerNotification::ItemStarted(notification) => { + self.render_item_started(¬ification.item); + CodexStatus::Running + } + ServerNotification::ItemCompleted(notification) => { + self.render_item_completed(notification.item); + CodexStatus::Running + } + ServerNotification::ModelRerouted(notification) => { + eprintln!( + "{} {} -> {}", + "model rerouted:".style(self.yellow).style(self.bold), + notification.from_model, + notification.to_model ); - - ts_msg!( - self, - "{}", - "file update".style(self.magenta).style(self.italic), - ); - - // Pretty-print the patch summary with colored diff markers so - // it's easy to scan in the terminal output. - for (path, change) in changes.iter() { - match change { - FileChange::Add { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - eprintln!("{}", header.style(self.magenta)); - for line in content.lines() { - eprintln!("{}", line.style(self.green)); - } - } - FileChange::Delete { content } => { - let header = format!( - "{} {}", - format_file_change(change), - path.to_string_lossy() - ); - eprintln!("{}", header.style(self.magenta)); - for line in content.lines() { - eprintln!("{}", line.style(self.red)); - } - } - FileChange::Update { - unified_diff, - move_path, - } => { - let header = if let Some(dest) = move_path { - format!( - "{} {} -> {}", - format_file_change(change), - path.to_string_lossy(), - dest.to_string_lossy() - ) - } else { - format!("{} {}", format_file_change(change), path.to_string_lossy()) - }; - eprintln!("{}", header.style(self.magenta)); - - // Colorize diff lines. We keep file header lines - // (--- / +++) without extra coloring so they are - // still readable. - for diff_line in unified_diff.lines() { - if diff_line.starts_with('+') && !diff_line.starts_with("+++") { - eprintln!("{}", diff_line.style(self.green)); - } else if diff_line.starts_with('-') - && !diff_line.starts_with("---") - { - eprintln!("{}", diff_line.style(self.red)); - } else { - eprintln!("{diff_line}"); - } - } - } + CodexStatus::Running + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + ServerNotification::TurnCompleted(notification) => match notification.turn.status { + TurnStatus::Completed => { + let rendered_message = self + .final_message_rendered + .then(|| self.final_message.clone()) + .flatten(); + if let Some(final_message) = + final_message_from_turn_items(notification.turn.items.as_slice()) + { + self.final_message_rendered = + rendered_message.as_deref() == Some(final_message.as_str()); + self.final_message = Some(final_message); } + self.emit_final_message_on_shutdown = true; + CodexStatus::InitiateShutdown } - } - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id, - stdout, - stderr, - success, - .. - }) => { - let patch_begin = self.call_id_to_patch.remove(&call_id); - - // Compute duration and summary label similar to exec commands. - let (duration, label) = if let Some(PatchApplyBegin { - start_time, - auto_approved, - }) = patch_begin - { - ( - format!(" in {}", format_elapsed(start_time)), - format!("apply_patch(auto_approved={auto_approved})"), - ) - } else { - (String::new(), format!("apply_patch('{call_id}')")) - }; - - let (exit_code, output, title_style) = if success { - (0, stdout, self.green) - } else { - (1, stderr, self.red) - }; - - let title = format!("{label} exited {exit_code}{duration}:"); - ts_msg!(self, "{}", title.style(title_style)); - for line in output.lines() { - eprintln!("{}", line.style(self.dimmed)); + TurnStatus::Failed => { + self.final_message = None; + self.final_message_rendered = false; + self.emit_final_message_on_shutdown = false; + if let Some(error) = notification.turn.error { + eprintln!("{} {}", "ERROR:".style(self.red).style(self.bold), error); + } + CodexStatus::InitiateShutdown } - } - EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => { - ts_msg!( - self, - "{}", - "file update:".style(self.magenta).style(self.italic) - ); - eprintln!("{unified_diff}"); - } - EventMsg::AgentReasoning(agent_reasoning_event) => { - if self.show_agent_reasoning { - ts_msg!( - self, - "{}\n{}", - "thinking".style(self.italic).style(self.magenta), - agent_reasoning_event.text, - ); + TurnStatus::Interrupted => { + self.final_message = None; + self.final_message_rendered = false; + self.emit_final_message_on_shutdown = false; + eprintln!("{}", "turn interrupted".style(self.dimmed)); + CodexStatus::InitiateShutdown } - } - EventMsg::SessionConfigured(session_configured_event) => { - let SessionConfiguredEvent { - session_id: conversation_id, - model, - .. - } = session_configured_event; - - ts_msg!( - self, - "{} {}", - "codex session".style(self.magenta).style(self.bold), - conversation_id.to_string().style(self.dimmed) - ); - - ts_msg!(self, "model: {}", model); - eprintln!(); - } - EventMsg::PlanUpdate(plan_update_event) => { - let UpdatePlanArgs { explanation, plan } = plan_update_event; - - // Header - ts_msg!(self, "{}", "Plan update".style(self.magenta)); - - // Optional explanation - if let Some(explanation) = explanation - && !explanation.trim().is_empty() - { - ts_msg!(self, "{}", explanation.style(self.italic)); + TurnStatus::InProgress => CodexStatus::Running, + }, + ServerNotification::TurnDiffUpdated(notification) => { + if !notification.diff.trim().is_empty() { + eprintln!("{}", notification.diff); } - - // Pretty-print the plan items with simple status markers. - for item in plan { - match item.status { - StepStatus::Completed => { - ts_msg!(self, " {} {}", "βœ“".style(self.green), item.step); + CodexStatus::Running + } + ServerNotification::TurnPlanUpdated(notification) => { + if let Some(explanation) = notification.explanation { + eprintln!("{}", explanation.style(self.italic)); + } + for step in notification.plan { + match step.status { + codex_app_server_protocol::TurnPlanStepStatus::Completed => { + eprintln!(" {} {}", "βœ“".style(self.green), step.step); } - StepStatus::InProgress => { - ts_msg!(self, " {} {}", "β†’".style(self.cyan), item.step); + codex_app_server_protocol::TurnPlanStepStatus::InProgress => { + eprintln!(" {} {}", "β†’".style(self.cyan), step.step); } - StepStatus::Pending => { - ts_msg!( - self, + codex_app_server_protocol::TurnPlanStepStatus::Pending => { + eprintln!( " {} {}", "β€’".style(self.dimmed), - item.step.style(self.dimmed) + step.step.style(self.dimmed) ); } } } + CodexStatus::Running } - EventMsg::ViewImageToolCall(view) => { - ts_msg!( - self, - "{} {}", - "viewed image".style(self.magenta), - view.path.display() - ); - } - EventMsg::TurnAborted(abort_reason) => { - match abort_reason.reason { - TurnAbortReason::Interrupted => { - ts_msg!(self, "task interrupted"); - } - TurnAbortReason::Replaced => { - ts_msg!(self, "task aborted: replaced by a new task"); - } - TurnAbortReason::ReviewEnded => { - ts_msg!(self, "task aborted: review ended"); - } - } - return CodexStatus::InitiateShutdown; - } - EventMsg::ContextCompacted(_) => { - ts_msg!(self, "context compacted"); - } - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id, - sender_thread_id: _, - prompt, - .. - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("spawn_agent", &call_id, Some(&prompt)) - .style(self.bold) - ); - } - EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id, - sender_thread_id: _, - new_thread_id, - prompt, - status, - .. - }) => { - let success = new_thread_id.is_some() && !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("spawn_agent", &call_id, Some(&prompt)), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - if let Some(new_thread_id) = new_thread_id { - eprintln!(" agent: {}", new_thread_id.to_string().style(self.dimmed)); - } - } - EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - prompt, - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("send_input", &call_id, Some(&prompt)) - .style(self.bold) - ); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - prompt, - status, - .. - }) => { - let success = !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("send_input", &call_id, Some(&prompt)), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent { - sender_thread_id: _, - receiver_thread_ids, - call_id, - .. - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("wait", &call_id, /*prompt*/ None).style(self.bold) - ); - eprintln!( - " receivers: {}", - format_receiver_list(&receiver_thread_ids).style(self.dimmed) - ); - } - EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { - sender_thread_id: _, - call_id, - statuses, - .. - }) => { - if statuses.is_empty() { - ts_msg!( - self, - "{} {}:", - format_collab_invocation("wait", &call_id, /*prompt*/ None), - "timed out".style(self.yellow) - ); - return CodexStatus::Running; - } - let success = !statuses.values().any(is_collab_status_failure); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {} agents complete:", - format_collab_invocation("wait", &call_id, /*prompt*/ None), - statuses.len() - ); - ts_msg!(self, "{}", title.style(title_style)); - let mut sorted = statuses - .into_iter() - .map(|(thread_id, status)| (thread_id.to_string(), status)) - .collect::>(); - sorted.sort_by(|(left, _), (right, _)| left.cmp(right)); - for (thread_id, status) in sorted { - eprintln!( - " {} {}", - thread_id.style(self.dimmed), - format_collab_status(&status).style(style_for_agent_status(&status, self)) - ); - } - } - EventMsg::CollabCloseBegin(CollabCloseBeginEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - }) => { - ts_msg!( - self, - "{} {}", - "collab".style(self.magenta), - format_collab_invocation("close_agent", &call_id, /*prompt*/ None) - .style(self.bold) - ); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::CollabCloseEnd(CollabCloseEndEvent { - call_id, - sender_thread_id: _, - receiver_thread_id, - status, - .. - }) => { - let success = !is_collab_status_failure(&status); - let title_style = if success { self.green } else { self.red }; - let title = format!( - "{} {}:", - format_collab_invocation("close_agent", &call_id, /*prompt*/ None), - format_collab_status(&status) - ); - ts_msg!(self, "{}", title.style(title_style)); - eprintln!( - " receiver: {}", - receiver_thread_id.to_string().style(self.dimmed) - ); - } - EventMsg::HookStarted(event) => self.render_hook_started(event), - EventMsg::HookCompleted(event) => self.render_hook_completed(event), - EventMsg::ShutdownComplete => return CodexStatus::Shutdown, - EventMsg::ThreadNameUpdated(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::CollabResumeBegin(_) - | EventMsg::CollabResumeEnd(_) - | EventMsg::RealtimeConversationStarted(_) - | EventMsg::RealtimeConversationRealtime(_) - | EventMsg::RealtimeConversationClosed(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) => {} + ServerNotification::TurnStarted(_) => CodexStatus::Running, + _ => CodexStatus::Running, } + } + + fn process_warning(&mut self, message: String) -> CodexStatus { + eprintln!( + "{} {message}", + "warning:".style(self.yellow).style(self.bold) + ); CodexStatus::Running } fn print_final_output(&mut self) { - self.finish_progress_line(); - if let Some(usage_info) = &self.last_total_token_usage { + if self.emit_final_message_on_shutdown + && let Some(path) = self.last_message_path.as_deref() + { + handle_last_message(self.final_message.as_deref(), path); + } + + if let Some(usage) = &self.last_total_token_usage { eprintln!( "{}\n{}", - "tokens used".style(self.magenta).style(self.italic), - format_with_separators(usage_info.total_token_usage.blended_total()) + "tokens used".style(self.dimmed), + format_with_separators(blended_total(usage)) ); } - // In interactive terminals we already emitted the final assistant - // message on stderr during event processing. Preserve stdout emission - // only for non-interactive use so pipes and scripts still receive the - // final message. #[allow(clippy::print_stdout)] if should_print_final_message_to_stdout( - self.final_message.as_deref(), + self.emit_final_message_on_shutdown + .then_some(self.final_message.as_deref()) + .flatten(), std::io::stdout().is_terminal(), std::io::stderr().is_terminal(), - ) && let Some(message) = &self.final_message + ) && let Some(message) = self.final_message.as_deref() { - if message.ends_with('\n') { - print!("{message}"); - } else { - println!("{message}"); - } + println!("{message}"); + } else if should_print_final_message_to_tty( + self.emit_final_message_on_shutdown + .then_some(self.final_message.as_deref()) + .flatten(), + self.final_message_rendered, + std::io::stdout().is_terminal(), + std::io::stderr().is_terminal(), + ) && let Some(message) = self.final_message.as_deref() + { + eprintln!( + "{}\n{}", + "codex".style(self.italic).style(self.magenta), + message + ); } } } -impl EventProcessorWithHumanOutput { - fn parse_agent_job_progress(message: &str) -> Option { - let payload = message.strip_prefix("agent_job_progress:")?; - serde_json::from_str::(payload).ok() +fn config_summary_entries( + config: &Config, + session_configured_event: &SessionConfiguredEvent, +) -> Vec<(&'static str, String)> { + let mut entries = vec![ + ("workdir", config.cwd.display().to_string()), + ("model", session_configured_event.model.clone()), + ( + "provider", + session_configured_event.model_provider_id.clone(), + ), + ( + "approval", + config.permissions.approval_policy.value().to_string(), + ), + ( + "sandbox", + summarize_sandbox_policy(config.permissions.sandbox_policy.get()), + ), + ]; + if config.model_provider.wire_api == WireApi::Responses { + entries.push(( + "reasoning effort", + config + .model_reasoning_effort + .map(|effort| effort.to_string()) + .unwrap_or_else(|| "none".to_string()), + )); + entries.push(( + "reasoning summaries", + config + .model_reasoning_summary + .map(|summary| summary.to_string()) + .unwrap_or_else(|| "none".to_string()), + )); } + entries.push(( + "session id", + session_configured_event.session_id.to_string(), + )); + entries +} - fn render_hook_started(&self, event: HookStartedEvent) { - if !Self::should_print_hook_started(&event) { - return; +fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly { network_access, .. } => { + let mut summary = "read-only".to_string(); + if *network_access { + summary.push_str(" (network access enabled)"); + } + summary } - let event_name = Self::hook_event_name(event.run.event_name); - if let Some(status_message) = event.run.status_message - && !status_message.trim().is_empty() - { - ts_msg!( - self, - "{} {}: {}", - "hook".style(self.magenta), - event_name, - status_message + SandboxPolicy::ExternalSandbox { network_access } => { + let mut summary = "external-sandbox".to_string(); + if matches!( + network_access, + codex_protocol::protocol::NetworkAccess::Enabled + ) { + summary.push_str(" (network access enabled)"); + } + summary + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + read_only_access: _, + } => { + let mut summary = "workspace-write".to_string(); + let mut writable_entries = vec!["workdir".to_string()]; + if !*exclude_slash_tmp { + writable_entries.push("/tmp".to_string()); + } + if !*exclude_tmpdir_env_var { + writable_entries.push("$TMPDIR".to_string()); + } + writable_entries.extend( + writable_roots + .iter() + .map(|path| path.to_string_lossy().to_string()), ); - } - } - - fn render_hook_completed(&self, event: HookCompletedEvent) { - if !Self::should_print_hook_completed(&event) { - return; - } - - let event_name = Self::hook_event_name(event.run.event_name); - let status = Self::hook_status_name(event.run.status); - ts_msg!( - self, - "{} {} ({status})", - "hook".style(self.magenta), - event_name - ); - - for entry in event.run.entries { - let prefix = Self::hook_entry_prefix(entry.kind); - eprintln!(" {prefix} {}", entry.text); - } - } - - fn should_print_hook_started(event: &HookStartedEvent) -> bool { - event - .run - .status_message - .as_deref() - .is_some_and(|status_message| !status_message.trim().is_empty()) - } - - fn should_print_hook_completed(event: &HookCompletedEvent) -> bool { - event.run.status != HookRunStatus::Completed || !event.run.entries.is_empty() - } - - fn hook_event_name(event_name: HookEventName) -> &'static str { - match event_name { - HookEventName::SessionStart => "SessionStart", - HookEventName::UserPromptSubmit => "UserPromptSubmit", - HookEventName::Stop => "Stop", - } - } - - fn hook_status_name(status: HookRunStatus) -> &'static str { - match status { - HookRunStatus::Running => "running", - HookRunStatus::Completed => "completed", - HookRunStatus::Failed => "failed", - HookRunStatus::Blocked => "blocked", - HookRunStatus::Stopped => "stopped", - } - } - - fn hook_entry_prefix(kind: HookOutputEntryKind) -> &'static str { - match kind { - HookOutputEntryKind::Warning => "warning:", - HookOutputEntryKind::Stop => "stop:", - HookOutputEntryKind::Feedback => "feedback:", - HookOutputEntryKind::Context => "context:", - HookOutputEntryKind::Error => "error:", - } - } - - fn is_silent_event(msg: &EventMsg) -> bool { - match msg { - EventMsg::HookStarted(event) => !Self::should_print_hook_started(event), - EventMsg::HookCompleted(event) => !Self::should_print_hook_completed(event), - _ => matches!( - msg, - EventMsg::ThreadNameUpdated(_) - | EventMsg::TokenCount(_) - | EventMsg::TurnStarted(_) - | EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::TerminalInteraction(_) - | EventMsg::ExecCommandOutputDelta(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) - | EventMsg::ListCustomPromptsResponse(_) - | EventMsg::ListSkillsResponse(_) - | EventMsg::RawResponseItem(_) - | EventMsg::UserMessage(_) - | EventMsg::EnteredReviewMode(_) - | EventMsg::ExitedReviewMode(_) - | EventMsg::AgentMessageDelta(_) - | EventMsg::AgentReasoningDelta(_) - | EventMsg::AgentReasoningRawContentDelta(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::AgentMessageContentDelta(_) - | EventMsg::PlanDelta(_) - | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable - | EventMsg::UndoCompleted(_) - | EventMsg::UndoStarted(_) - | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) - | EventMsg::RequestPermissions(_) - | EventMsg::DynamicToolCallRequest(_) - | EventMsg::DynamicToolCallResponse(_) - | EventMsg::GuardianAssessment(_) - ), - } - } - - fn should_interrupt_progress(msg: &EventMsg) -> bool { - if let EventMsg::HookCompleted(event) = msg { - return Self::should_print_hook_completed(event); - } - matches!( - msg, - EventMsg::Error(_) - | EventMsg::Warning(_) - | EventMsg::GuardianAssessment(_) - | EventMsg::DeprecationNotice(_) - | EventMsg::StreamError(_) - | EventMsg::TurnComplete(_) - | EventMsg::ShutdownComplete - ) - } - - fn finish_progress_line(&mut self) { - if self.progress_active { - self.progress_active = false; - self.progress_last_len = 0; - self.progress_done = false; - if self.use_ansi_cursor { - if self.progress_anchor { - eprintln!("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); - } else { - eprintln!("\u{1b}[1G\u{1b}[2K"); - } - } else { - eprintln!(); + summary.push_str(&format!(" [{}]", writable_entries.join(", "))); + if *network_access { + summary.push_str(" (network access enabled)"); } - self.progress_anchor = false; + summary } } +} - fn render_agent_job_progress(&mut self, update: AgentJobProgressMessage) { - let total = update.total_items.max(1); - let processed = update.completed_items + update.failed_items; - let percent = (processed as f64 / total as f64 * 100.0).round() as i64; - let job_label = update.job_id.chars().take(8).collect::(); - let eta = update - .eta_seconds - .map(|secs| format_duration(Duration::from_secs(secs))) - .unwrap_or_else(|| "--".to_string()); - let columns = std::env::var("COLUMNS") - .ok() - .and_then(|value| value.parse::().ok()) - .filter(|value| *value > 0); - let line = format_agent_job_progress_line( - columns, - job_label.as_str(), - AgentJobProgressStats { - processed, - total, - percent, - failed: update.failed_items, - running: update.running_items, - pending: update.pending_items, - }, - eta.as_str(), - ); - let done = processed >= update.total_items; - if !self.use_ansi_cursor { - eprintln!("{line}"); - if done { - self.progress_active = false; - self.progress_last_len = 0; - } - return; - } - if done && self.progress_done { - return; - } - if !self.progress_active { - eprintln!(); - self.progress_anchor = true; - self.progress_done = false; - } - let mut output = String::new(); - if self.progress_anchor { - output.push_str("\u{1b}[1A\u{1b}[1G\u{1b}[2K"); - } else { - output.push_str("\u{1b}[1G\u{1b}[2K"); - } - output.push_str(&line); - if done { - output.push('\n'); - eprint!("{output}"); - self.progress_active = false; - self.progress_last_len = 0; - self.progress_anchor = false; - self.progress_done = true; - return; - } - eprint!("{output}"); - let _ = std::io::stderr().flush(); - self.progress_active = true; - self.progress_last_len = line.len(); +fn reasoning_text( + summary: &[String], + content: &[String], + show_raw_agent_reasoning: bool, +) -> Option { + let entries = if show_raw_agent_reasoning && !content.is_empty() { + content + } else { + summary + }; + if entries.is_empty() { + None + } else { + Some(entries.join("\n")) } } +fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { + items + .iter() + .rev() + .find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) + .or_else(|| { + items.iter().rev().find_map(|item| match item { + ThreadItem::Plan { text, .. } => Some(text.clone()), + _ => None, + }) + }) +} + +fn blended_total(usage: &ThreadTokenUsage) -> i64 { + let cached_input = usage.total.cached_input_tokens.max(0); + let non_cached_input = (usage.total.input_tokens - cached_input).max(0); + (non_cached_input + usage.total.output_tokens.max(0)).max(0) +} + fn should_print_final_message_to_stdout( final_message: Option<&str>, stdout_is_terminal: bool, @@ -1165,293 +554,357 @@ fn should_print_final_message_to_stdout( final_message.is_some() && !(stdout_is_terminal && stderr_is_terminal) } -struct AgentJobProgressStats { - processed: usize, - total: usize, - percent: i64, - failed: usize, - running: usize, - pending: usize, -} - -fn format_agent_job_progress_line( - columns: Option, - job_label: &str, - stats: AgentJobProgressStats, - eta: &str, -) -> String { - let rest = format!( - "{processed}/{total} {percent}% f{failed} r{running} p{pending} eta {eta}", - processed = stats.processed, - total = stats.total, - percent = stats.percent, - failed = stats.failed, - running = stats.running, - pending = stats.pending - ); - let prefix = format!("job {job_label}"); - let base_len = prefix.len() + rest.len() + 4; - let mut bar_width = columns - .and_then(|columns| columns.checked_sub(base_len)) - .filter(|available| *available > 0) - .unwrap_or(20usize); - let with_bar = |width: usize| { - let filled = ((stats.processed as f64 / stats.total as f64) * width as f64) - .round() - .clamp(0.0, width as f64) as usize; - let mut bar = "#".repeat(filled); - bar.push_str(&"-".repeat(width - filled)); - format!("{prefix} [{bar}] {rest}") - }; - let mut line = with_bar(bar_width); - if let Some(columns) = columns - && line.len() > columns - { - let min_line = format!("{prefix} {rest}"); - if min_line.len() > columns { - let mut truncated = min_line; - if columns > 2 && truncated.len() > columns { - truncated.truncate(columns - 2); - truncated.push_str(".."); - } - return truncated; - } - let available = columns.saturating_sub(base_len); - if available == 0 { - return min_line; - } - bar_width = available.min(bar_width).max(1); - line = with_bar(bar_width); - } - line -} - -fn escape_command(command: &[String]) -> String { - try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")) -} - -fn format_file_change(change: &FileChange) -> &'static str { - match change { - FileChange::Add { .. } => "A", - FileChange::Delete { .. } => "D", - FileChange::Update { - move_path: Some(_), .. - } => "R", - FileChange::Update { - move_path: None, .. - } => "M", - } -} - -fn format_collab_invocation(tool: &str, call_id: &str, prompt: Option<&str>) -> String { - let prompt = prompt - .map(str::trim) - .filter(|prompt| !prompt.is_empty()) - .map(|prompt| truncate_preview(prompt, /*max_chars*/ 120)); - match prompt { - Some(prompt) => format!("{tool}({call_id}, prompt=\"{prompt}\")"), - None => format!("{tool}({call_id})"), - } -} - -fn format_collab_status(status: &AgentStatus) -> String { - match status { - AgentStatus::PendingInit => "pending init".to_string(), - AgentStatus::Running => "running".to_string(), - AgentStatus::Interrupted => "interrupted".to_string(), - AgentStatus::Completed(Some(message)) => { - let preview = truncate_preview(message.trim(), /*max_chars*/ 120); - if preview.is_empty() { - "completed".to_string() - } else { - format!("completed: \"{preview}\"") - } - } - AgentStatus::Completed(None) => "completed".to_string(), - AgentStatus::Errored(message) => { - let preview = truncate_preview(message.trim(), /*max_chars*/ 120); - if preview.is_empty() { - "errored".to_string() - } else { - format!("errored: \"{preview}\"") - } - } - AgentStatus::Shutdown => "shutdown".to_string(), - AgentStatus::NotFound => "not found".to_string(), - } -} - -fn style_for_agent_status( - status: &AgentStatus, - processor: &EventProcessorWithHumanOutput, -) -> Style { - match status { - AgentStatus::PendingInit | AgentStatus::Shutdown => processor.dimmed, - AgentStatus::Running => processor.cyan, - AgentStatus::Interrupted => processor.yellow, - AgentStatus::Completed(_) => processor.green, - AgentStatus::Errored(_) | AgentStatus::NotFound => processor.red, - } -} - -fn is_collab_status_failure(status: &AgentStatus) -> bool { - matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound) -} - -fn format_receiver_list(ids: &[codex_protocol::ThreadId]) -> String { - if ids.is_empty() { - return "none".to_string(); - } - ids.iter() - .map(ToString::to_string) - .collect::>() - .join(", ") -} - -fn truncate_preview(text: &str, max_chars: usize) -> String { - if text.chars().count() <= max_chars { - return text.to_string(); - } - - let preview = text.chars().take(max_chars).collect::(); - format!("{preview}…") -} - -fn format_mcp_invocation(invocation: &McpInvocation) -> String { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); - - // Format arguments as compact JSON so they fit on one line. - let args_str = invocation - .arguments - .as_ref() - .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) - .unwrap_or_default(); - - if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - } +fn should_print_final_message_to_tty( + final_message: Option<&str>, + final_message_rendered: bool, + stdout_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + final_message.is_some() && !final_message_rendered && stdout_is_terminal && stderr_is_terminal } #[cfg(test)] mod tests { - use std::path::PathBuf; - - use codex_protocol::protocol::EventMsg; - use codex_protocol::protocol::HookCompletedEvent; - use codex_protocol::protocol::HookEventName; - use codex_protocol::protocol::HookExecutionMode; - use codex_protocol::protocol::HookHandlerType; - use codex_protocol::protocol::HookOutputEntry; - use codex_protocol::protocol::HookRunStatus; - use codex_protocol::protocol::HookRunSummary; - use codex_protocol::protocol::HookScope; - use codex_protocol::protocol::HookStartedEvent; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnStatus; + use owo_colors::Style; use super::EventProcessorWithHumanOutput; + use super::final_message_from_turn_items; + use super::reasoning_text; use super::should_print_final_message_to_stdout; - use pretty_assertions::assert_eq; + use super::should_print_final_message_to_tty; + use crate::event_processor::EventProcessor; + use codex_app_server_protocol::ServerNotification; #[test] fn suppresses_final_stdout_message_when_both_streams_are_terminals() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), true, true), - false - ); + assert!(!should_print_final_message_to_stdout( + Some("hello"), + true, + true + )); } #[test] fn prints_final_stdout_message_when_stdout_is_not_terminal() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), false, true), + assert!(should_print_final_message_to_stdout( + Some("hello"), + false, true - ); + )); } #[test] fn prints_final_stdout_message_when_stderr_is_not_terminal() { - assert_eq!( - should_print_final_message_to_stdout(Some("hello"), true, false), - true - ); - } - - #[test] - fn does_not_print_when_message_is_missing() { - assert_eq!( - should_print_final_message_to_stdout(None, false, false), + assert!(should_print_final_message_to_stdout( + Some("hello"), + true, false + )); + } + + #[test] + fn suppresses_final_stdout_message_when_missing() { + assert!(!should_print_final_message_to_stdout(None, false, false)); + } + + #[test] + fn prints_final_tty_message_when_not_yet_rendered() { + assert!(should_print_final_message_to_tty( + Some("hello"), + false, + true, + true + )); + } + + #[test] + fn suppresses_final_tty_message_when_already_rendered() { + assert!(!should_print_final_message_to_tty( + Some("hello"), + true, + true, + true + )); + } + + #[test] + fn reasoning_text_prefers_summary_when_raw_reasoning_is_hidden() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ false, ); + + assert_eq!(text.as_deref(), Some("summary")); } #[test] - fn hook_started_with_status_message_is_not_silent() { - let event = HookStartedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run( - HookRunStatus::Running, - Some("running hook"), - Vec::new(), - HookEventName::Stop, - ), - }; + fn reasoning_text_uses_raw_content_when_enabled() { + let text = reasoning_text( + &["summary".to_string()], + &["raw".to_string()], + /*show_raw_agent_reasoning*/ true, + ); - assert!(!EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookStarted(event) - )); + assert_eq!(text.as_deref(), Some("raw")); } #[test] - fn hook_completed_failure_interrupts_progress() { - let event = HookCompletedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run(HookRunStatus::Failed, None, Vec::new(), HookEventName::Stop), - }; + fn final_message_from_turn_items_uses_latest_agent_message() { + let message = final_message_from_turn_items(&[ + ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "first".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "plan".to_string(), + }, + ThreadItem::AgentMessage { + id: "msg-2".to_string(), + text: "second".to_string(), + phase: None, + memory_citation: None, + }, + ]); - assert!(EventProcessorWithHumanOutput::should_interrupt_progress( - &EventMsg::HookCompleted(event) - )); + assert_eq!(message.as_deref(), Some("second")); } #[test] - fn hook_completed_success_without_entries_stays_silent() { - let event = HookCompletedEvent { - turn_id: Some("turn-1".to_string()), - run: hook_run( - HookRunStatus::Completed, - None, - Vec::new(), - HookEventName::Stop, - ), - }; + fn final_message_from_turn_items_falls_back_to_latest_plan() { + let message = final_message_from_turn_items(&[ + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["inspect".to_string()], + content: Vec::new(), + }, + ThreadItem::Plan { + id: "plan-1".to_string(), + text: "first plan".to_string(), + }, + ThreadItem::Plan { + id: "plan-2".to_string(), + text: "final plan".to_string(), + }, + ]); - assert!(EventProcessorWithHumanOutput::is_silent_event( - &EventMsg::HookCompleted(event) - )); + assert_eq!(message.as_deref(), Some("final plan")); } - fn hook_run( - status: HookRunStatus, - status_message: Option<&str>, - entries: Vec, - event_name: HookEventName, - ) -> HookRunSummary { - HookRunSummary { - id: "hook-run-1".to_string(), - event_name, - handler_type: HookHandlerType::Command, - execution_mode: HookExecutionMode::Sync, - scope: HookScope::Turn, - source_path: PathBuf::from("/tmp/hooks.json"), - display_order: 0, + #[test] + fn turn_completed_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: None, + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( status, - status_message: status_message.map(ToOwned::to_owned), - started_at: 0, - completed_at: Some(1), - duration_ms: Some(1), - entries, - } + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + } + + #[test] + fn turn_completed_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("stale answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("final answer")); + assert!(!processor.final_message_rendered); + } + + #[test] + fn turn_completed_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("streamed answer".to_string()), + final_message_rendered: false, + emit_final_message_on_shutdown: false, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message.as_deref(), Some("streamed answer")); + assert!(processor.emit_final_message_on_shutdown); + } + + #[test] + fn turn_failed_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); + } + + #[test] + fn turn_interrupted_clears_stale_final_message() { + let mut processor = EventProcessorWithHumanOutput { + bold: Style::new(), + cyan: Style::new(), + dimmed: Style::new(), + green: Style::new(), + italic: Style::new(), + magenta: Style::new(), + red: Style::new(), + yellow: Style::new(), + show_agent_reasoning: true, + show_raw_agent_reasoning: false, + last_message_path: None, + final_message: Some("partial answer".to_string()), + final_message_rendered: true, + emit_final_message_on_shutdown: true, + last_total_token_usage: None, + }; + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Interrupted, + error: None, + }, + }, + )); + + assert_eq!( + status, + crate::event_processor::CodexStatus::InitiateShutdown + ); + assert_eq!(processor.final_message, None); + assert!(!processor.final_message_rendered); + assert!(!processor.emit_final_message_on_shutdown); } } diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 33f44add77..1a085d93d0 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -1,8 +1,24 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; -use crate::event_processor::CodexStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TurnStatus; +use codex_core::config::Config; +use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::SessionConfiguredEvent; +use serde_json::json; + +pub use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::handle_last_message; use crate::exec_events::AgentMessageItem; @@ -12,7 +28,7 @@ use crate::exec_events::CollabTool; use crate::exec_events::CollabToolCallItem; use crate::exec_events::CollabToolCallStatus; use crate::exec_events::CommandExecutionItem; -use crate::exec_events::CommandExecutionStatus; +use crate::exec_events::CommandExecutionStatus as ExecCommandExecutionStatus; use crate::exec_events::ErrorItem; use crate::exec_events::FileChangeItem; use crate::exec_events::FileUpdateChange; @@ -22,13 +38,13 @@ use crate::exec_events::ItemUpdatedEvent; use crate::exec_events::McpToolCallItem; use crate::exec_events::McpToolCallItemError; use crate::exec_events::McpToolCallItemResult; -use crate::exec_events::McpToolCallStatus; -use crate::exec_events::PatchApplyStatus; -use crate::exec_events::PatchChangeKind; +use crate::exec_events::McpToolCallStatus as ExecMcpToolCallStatus; +use crate::exec_events::PatchApplyStatus as ExecPatchApplyStatus; +use crate::exec_events::PatchChangeKind as ExecPatchChangeKind; use crate::exec_events::ReasoningItem; use crate::exec_events::ThreadErrorEvent; use crate::exec_events::ThreadEvent; -use crate::exec_events::ThreadItem; +use crate::exec_events::ThreadItem as ExecThreadItem; use crate::exec_events::ThreadItemDetails; use crate::exec_events::ThreadStartedEvent; use crate::exec_events::TodoItem; @@ -38,45 +54,16 @@ use crate::exec_events::TurnFailedEvent; use crate::exec_events::TurnStartedEvent; use crate::exec_events::Usage; use crate::exec_events::WebSearchItem; -use codex_core::config::Config; -use codex_protocol::models::WebSearchAction; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol; -use codex_protocol::protocol::AgentStatus as CoreAgentStatus; -use codex_protocol::protocol::CollabAgentInteractionBeginEvent; -use codex_protocol::protocol::CollabAgentInteractionEndEvent; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabCloseBeginEvent; -use codex_protocol::protocol::CollabCloseEndEvent; -use codex_protocol::protocol::CollabWaitingBeginEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use serde_json::Value as JsonValue; -use tracing::error; -use tracing::warn; pub struct EventProcessorWithJsonOutput { last_message_path: Option, - last_proposed_plan: Option, - next_event_id: AtomicU64, - // Tracks running commands by call_id, including the associated item id. - running_commands: HashMap, - running_patch_applies: HashMap, - // Tracks the todo list for the current turn (at most one per turn). + next_item_id: AtomicU64, + raw_to_exec_item_id: HashMap, running_todo_list: Option, - last_total_token_usage: Option, - running_mcp_tool_calls: HashMap, - running_collab_tool_calls: HashMap, - running_web_search_calls: HashMap, + last_total_token_usage: Option, last_critical_error: Option, -} - -#[derive(Debug, Clone)] -struct RunningCommand { - command: String, - item_id: String, - aggregated_output: String, + final_message: Option, + emit_final_message_on_shutdown: bool, } #[derive(Debug, Clone)] @@ -85,800 +72,608 @@ struct RunningTodoList { items: Vec, } -#[derive(Debug, Clone)] -struct RunningMcpToolCall { - server: String, - tool: String, - item_id: String, - arguments: JsonValue, -} - -#[derive(Debug, Clone)] -struct RunningCollabToolCall { - tool: CollabTool, - item_id: String, +#[derive(Debug, PartialEq)] +pub struct CollectedThreadEvents { + pub events: Vec, + pub status: CodexStatus, } impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { last_message_path, - last_proposed_plan: None, - next_event_id: AtomicU64::new(0), - running_commands: HashMap::new(), - running_patch_applies: HashMap::new(), + next_item_id: AtomicU64::new(0), + raw_to_exec_item_id: HashMap::new(), running_todo_list: None, last_total_token_usage: None, - running_mcp_tool_calls: HashMap::new(), - running_collab_tool_calls: HashMap::new(), - running_web_search_calls: HashMap::new(), last_critical_error: None, + final_message: None, + emit_final_message_on_shutdown: false, } } - pub fn collect_thread_events(&mut self, event: &protocol::Event) -> Vec { - match &event.msg { - protocol::EventMsg::SessionConfigured(ev) => self.handle_session_configured(ev), - protocol::EventMsg::ThreadNameUpdated(_) => Vec::new(), - protocol::EventMsg::AgentMessage(ev) => self.handle_agent_message(ev), - protocol::EventMsg::ItemCompleted(protocol::ItemCompletedEvent { - item: codex_protocol::items::TurnItem::Plan(item), - .. - }) => { - self.last_proposed_plan = Some(item.text.clone()); - Vec::new() - } - protocol::EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), - protocol::EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), - protocol::EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), - protocol::EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), - protocol::EventMsg::ExecCommandOutputDelta(ev) => { - self.handle_output_chunk(&ev.call_id, &ev.chunk) - } - protocol::EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), - protocol::EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), - protocol::EventMsg::CollabAgentSpawnBegin(ev) => self.handle_collab_spawn_begin(ev), - protocol::EventMsg::CollabAgentSpawnEnd(ev) => self.handle_collab_spawn_end(ev), - protocol::EventMsg::CollabAgentInteractionBegin(ev) => { - self.handle_collab_interaction_begin(ev) - } - protocol::EventMsg::CollabAgentInteractionEnd(ev) => { - self.handle_collab_interaction_end(ev) - } - protocol::EventMsg::CollabWaitingBegin(ev) => self.handle_collab_wait_begin(ev), - protocol::EventMsg::CollabWaitingEnd(ev) => self.handle_collab_wait_end(ev), - protocol::EventMsg::CollabCloseBegin(ev) => self.handle_collab_close_begin(ev), - protocol::EventMsg::CollabCloseEnd(ev) => self.handle_collab_close_end(ev), - protocol::EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), - protocol::EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), - protocol::EventMsg::WebSearchBegin(ev) => self.handle_web_search_begin(ev), - protocol::EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev), - protocol::EventMsg::TokenCount(ev) => { - if let Some(info) = &ev.info { - self.last_total_token_usage = Some(info.total_token_usage.clone()); - } - Vec::new() - } - protocol::EventMsg::TurnStarted(ev) => self.handle_task_started(ev), - protocol::EventMsg::TurnComplete(_) => self.handle_task_complete(), - protocol::EventMsg::Error(ev) => { - let error = ThreadErrorEvent { - message: ev.message.clone(), - }; - self.last_critical_error = Some(error.clone()); - vec![ThreadEvent::Error(error)] - } - protocol::EventMsg::Warning(ev) => { - let item = ThreadItem { - id: self.get_next_item_id(), - details: ThreadItemDetails::Error(ErrorItem { - message: ev.message.clone(), - }), - }; - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - protocol::EventMsg::StreamError(ev) => { - let message = match &ev.additional_details { - Some(details) if !details.trim().is_empty() => { - format!("{} ({})", ev.message, details) - } - _ => ev.message.clone(), - }; - vec![ThreadEvent::Error(ThreadErrorEvent { message })] - } - protocol::EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), - _ => Vec::new(), - } + pub fn final_message(&self) -> Option<&str> { + self.final_message.as_deref() } - fn get_next_item_id(&self) -> String { - format!( - "item_{}", - self.next_event_id - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - ) + fn next_item_id(&self) -> String { + format!("item_{}", self.next_item_id.fetch_add(1, Ordering::SeqCst)) } - fn handle_session_configured( - &self, - payload: &protocol::SessionConfiguredEvent, - ) -> Vec { - vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: payload.session_id.to_string(), - })] - } - - fn handle_web_search_begin(&mut self, ev: &protocol::WebSearchBeginEvent) -> Vec { - if self.running_web_search_calls.contains_key(&ev.call_id) { - return Vec::new(); - } - let item_id = self.get_next_item_id(); - self.running_web_search_calls - .insert(ev.call_id.clone(), item_id.clone()); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: ev.call_id.clone(), - query: String::new(), - action: WebSearchAction::Other, - }), - }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - fn handle_web_search_end(&mut self, ev: &protocol::WebSearchEndEvent) -> Vec { - let item_id = self - .running_web_search_calls - .remove(&ev.call_id) - .unwrap_or_else(|| self.get_next_item_id()); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: ev.call_id.clone(), - query: ev.query.clone(), - action: ev.action.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { - //TODO see how we want to process them - vec![] - } - - fn handle_terminal_interaction( - &mut self, - _ev: &protocol::TerminalInteractionEvent, - ) -> Vec { - //TODO see how we want to process them - vec![] - } - - fn handle_agent_message(&self, payload: &protocol::AgentMessageEvent) -> Vec { - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::AgentMessage(AgentMessageItem { - text: payload.message.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_reasoning_event(&self, ev: &protocol::AgentReasoningEvent) -> Vec { - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::Reasoning(ReasoningItem { - text: ev.text.clone(), - }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - fn handle_exec_command_begin( - &mut self, - ev: &protocol::ExecCommandBeginEvent, - ) -> Vec { - let item_id = self.get_next_item_id(); - - let command_string = match shlex::try_join(ev.command.iter().map(String::as_str)) { - Ok(command_string) => command_string, - Err(e) => { - warn!( - call_id = ev.call_id, - "Failed to stringify command: {e:?}; skipping item.started" - ); - ev.command.join(" ") - } - }; - - self.running_commands.insert( - ev.call_id.clone(), - RunningCommand { - command: command_string.clone(), - item_id: item_id.clone(), - aggregated_output: String::new(), - }, + #[allow(clippy::print_stdout)] + fn emit(&self, event: ThreadEvent) { + println!( + "{}", + serde_json::to_string(&event).unwrap_or_else(|err| { + json!({ + "type": "error", + "message": format!("failed to serialize exec json event: {err}"), + }) + .to_string() + }) ); - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: command_string, - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] } - fn handle_mcp_tool_call_begin( - &mut self, - ev: &protocol::McpToolCallBeginEvent, - ) -> Vec { - let item_id = self.get_next_item_id(); - let server = ev.invocation.server.clone(); - let tool = ev.invocation.tool.clone(); - let arguments = ev.invocation.arguments.clone().unwrap_or(JsonValue::Null); - - self.running_mcp_tool_calls.insert( - ev.call_id.clone(), - RunningMcpToolCall { - server: server.clone(), - tool: tool.clone(), - item_id: item_id.clone(), - arguments: arguments.clone(), - }, - ); - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server, - tool, - arguments, - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + fn usage_from_last_total(&self) -> Usage { + let Some(usage) = self.last_total_token_usage.as_ref() else { + return Usage::default(); }; - - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] + Usage { + input_tokens: usage.total.input_tokens, + cached_input_tokens: usage.total.cached_input_tokens, + output_tokens: usage.total.output_tokens, + } } - fn handle_mcp_tool_call_end(&mut self, ev: &protocol::McpToolCallEndEvent) -> Vec { - let status = if ev.is_success() { - McpToolCallStatus::Completed - } else { - McpToolCallStatus::Failed - }; - - let (server, tool, item_id, arguments) = - match self.running_mcp_tool_calls.remove(&ev.call_id) { - Some(running) => ( - running.server, - running.tool, - running.item_id, - running.arguments, + pub fn map_todo_items(plan: &[codex_app_server_protocol::TurnPlanStep]) -> Vec { + plan.iter() + .map(|step| TodoItem { + text: step.step.clone(), + completed: matches!( + step.status, + codex_app_server_protocol::TurnPlanStepStatus::Completed ), - None => { - warn!( - call_id = ev.call_id, - "Received McpToolCallEnd without begin; synthesizing new item" - ); - ( - ev.invocation.server.clone(), - ev.invocation.tool.clone(), - self.get_next_item_id(), - ev.invocation.arguments.clone().unwrap_or(JsonValue::Null), - ) + }) + .collect() + } + + fn map_item_with_id( + item: ThreadItem, + make_id: impl FnOnce() -> String, + ) -> Option { + match item { + ThreadItem::AgentMessage { text, .. } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }), + }), + ThreadItem::Reasoning { summary, .. } => { + let text = summary.join("\n"); + if text.trim().is_empty() { + return None; } - }; - - let (result, error) = match &ev.result { - Ok(value) => { - let result = McpToolCallItemResult { - content: value.content.clone(), - structured_content: value.structured_content.clone(), - }; - (Some(result), None) + Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::Reasoning(ReasoningItem { text }), + }) } - Err(message) => ( - None, - Some(McpToolCallItemError { - message: message.clone(), + ThreadItem::CommandExecution { + command, + aggregated_output, + exit_code, + status, + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command, + aggregated_output: aggregated_output.unwrap_or_default(), + exit_code, + status: match status { + CommandExecutionStatus::InProgress => ExecCommandExecutionStatus::InProgress, + CommandExecutionStatus::Completed => ExecCommandExecutionStatus::Completed, + CommandExecutionStatus::Failed => ExecCommandExecutionStatus::Failed, + CommandExecutionStatus::Declined => ExecCommandExecutionStatus::Declined, + }, }), - ), - }; - - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::McpToolCall(McpToolCallItem { + }), + ThreadItem::FileChange { + changes, status, .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: changes + .into_iter() + .map(|change| FileUpdateChange { + path: change.path, + kind: match change.kind { + PatchChangeKind::Add => ExecPatchChangeKind::Add, + PatchChangeKind::Delete => ExecPatchChangeKind::Delete, + PatchChangeKind::Update { .. } => ExecPatchChangeKind::Update, + }, + }) + .collect(), + status: match status { + PatchApplyStatus::InProgress => ExecPatchApplyStatus::InProgress, + PatchApplyStatus::Completed => ExecPatchApplyStatus::Completed, + PatchApplyStatus::Failed | PatchApplyStatus::Declined => { + ExecPatchApplyStatus::Failed + } + }, + }), + }), + ThreadItem::McpToolCall { server, tool, + status, arguments, result, error, - status, + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server, + tool, + status: match status { + McpToolCallStatus::InProgress => ExecMcpToolCallStatus::InProgress, + McpToolCallStatus::Completed => ExecMcpToolCallStatus::Completed, + McpToolCallStatus::Failed => ExecMcpToolCallStatus::Failed, + }, + arguments, + result: result.map(|result| McpToolCallItemResult { + content: result.content, + structured_content: result.structured_content, + }), + error: error.map(|error| McpToolCallItemError { + message: error.message, + }), + }), }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_collab_spawn_begin(&mut self, ev: &CollabAgentSpawnBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::SpawnAgent, - ev.sender_thread_id.to_string(), - Vec::new(), - Some(ev.prompt.clone()), - ) - } - - fn handle_collab_spawn_end(&mut self, ev: &CollabAgentSpawnEndEvent) -> Vec { - let (receiver_thread_ids, agents_states) = match ev.new_thread_id { - Some(id) => { - let receiver_id = id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - ( - vec![receiver_id.clone()], - [(receiver_id, agent_state)].into_iter().collect(), - ) - } - None => (Vec::new(), HashMap::new()), - }; - let status = if ev.new_thread_id.is_some() && !is_collab_failure(&ev.status) { - CollabToolCallStatus::Completed - } else { - CollabToolCallStatus::Failed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::SpawnAgent, - ev.sender_thread_id.to_string(), - receiver_thread_ids, - Some(ev.prompt.clone()), - agents_states, - status, - ) - } - - fn handle_collab_interaction_begin( - &mut self, - ev: &CollabAgentInteractionBeginEvent, - ) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::SendInput, - ev.sender_thread_id.to_string(), - vec![ev.receiver_thread_id.to_string()], - Some(ev.prompt.clone()), - ) - } - - fn handle_collab_interaction_end( - &mut self, - ev: &CollabAgentInteractionEndEvent, - ) -> Vec { - let receiver_id = ev.receiver_thread_id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - let status = if is_collab_failure(&ev.status) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::SendInput, - ev.sender_thread_id.to_string(), - vec![receiver_id.clone()], - Some(ev.prompt.clone()), - [(receiver_id, agent_state)].into_iter().collect(), - status, - ) - } - - fn handle_collab_wait_begin(&mut self, ev: &CollabWaitingBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::Wait, - ev.sender_thread_id.to_string(), - ev.receiver_thread_ids - .iter() - .map(ToString::to_string) - .collect(), - /*prompt*/ None, - ) - } - - fn handle_collab_wait_end(&mut self, ev: &CollabWaitingEndEvent) -> Vec { - let status = if ev.statuses.values().any(is_collab_failure) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - let mut receiver_thread_ids = ev - .statuses - .keys() - .map(ToString::to_string) - .collect::>(); - receiver_thread_ids.sort(); - let agents_states = ev - .statuses - .iter() - .map(|(thread_id, status)| { - ( - thread_id.to_string(), - CollabAgentState::from(status.clone()), - ) - }) - .collect(); - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::Wait, - ev.sender_thread_id.to_string(), - receiver_thread_ids, - /*prompt*/ None, - agents_states, - status, - ) - } - - fn handle_collab_close_begin(&mut self, ev: &CollabCloseBeginEvent) -> Vec { - self.start_collab_tool_call( - &ev.call_id, - CollabTool::CloseAgent, - ev.sender_thread_id.to_string(), - vec![ev.receiver_thread_id.to_string()], - /*prompt*/ None, - ) - } - - fn handle_collab_close_end(&mut self, ev: &CollabCloseEndEvent) -> Vec { - let receiver_id = ev.receiver_thread_id.to_string(); - let agent_state = CollabAgentState::from(ev.status.clone()); - let status = if is_collab_failure(&ev.status) { - CollabToolCallStatus::Failed - } else { - CollabToolCallStatus::Completed - }; - self.finish_collab_tool_call( - &ev.call_id, - CollabTool::CloseAgent, - ev.sender_thread_id.to_string(), - vec![receiver_id.clone()], - /*prompt*/ None, - [(receiver_id, agent_state)].into_iter().collect(), - status, - ) - } - - fn start_collab_tool_call( - &mut self, - call_id: &str, - tool: CollabTool, - sender_thread_id: String, - receiver_thread_ids: Vec, - prompt: Option, - ) -> Vec { - let item_id = self.get_next_item_id(); - self.running_collab_tool_calls.insert( - call_id.to_string(), - RunningCollabToolCall { - tool: tool.clone(), - item_id: item_id.clone(), - }, - ); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool, - sender_thread_id, - receiver_thread_ids, - prompt, - agents_states: HashMap::new(), - status: CollabToolCallStatus::InProgress, - }), - }; - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - #[allow(clippy::too_many_arguments)] - fn finish_collab_tool_call( - &mut self, - call_id: &str, - tool: CollabTool, - sender_thread_id: String, - receiver_thread_ids: Vec, - prompt: Option, - agents_states: HashMap, - status: CollabToolCallStatus, - ) -> Vec { - let (tool, item_id) = match self.running_collab_tool_calls.remove(call_id) { - Some(running) => (running.tool, running.item_id), - None => { - warn!( - call_id, - "Received collab tool end without begin; synthesizing new item" - ); - (tool, self.get_next_item_id()) - } - }; - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + ThreadItem::CollabAgentToolCall { tool, sender_thread_id, receiver_thread_ids, prompt, agents_states, status, - }), - }; - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] - } - - fn handle_patch_apply_begin( - &mut self, - ev: &protocol::PatchApplyBeginEvent, - ) -> Vec { - self.running_patch_applies - .insert(ev.call_id.clone(), ev.clone()); - - Vec::new() - } - - fn map_change_kind(&self, kind: &protocol::FileChange) -> PatchChangeKind { - match kind { - protocol::FileChange::Add { .. } => PatchChangeKind::Add, - protocol::FileChange::Delete { .. } => PatchChangeKind::Delete, - protocol::FileChange::Update { .. } => PatchChangeKind::Update, - } - } - - fn handle_patch_apply_end(&mut self, ev: &protocol::PatchApplyEndEvent) -> Vec { - if let Some(running_patch_apply) = self.running_patch_applies.remove(&ev.call_id) { - let status = if ev.success { - PatchApplyStatus::Completed - } else { - PatchApplyStatus::Failed - }; - let item = ThreadItem { - id: self.get_next_item_id(), - - details: ThreadItemDetails::FileChange(FileChangeItem { - changes: running_patch_apply - .changes - .iter() - .map(|(path, change)| FileUpdateChange { - path: path.to_str().unwrap_or("").to_string(), - kind: self.map_change_kind(change), + .. + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: match tool { + CollabAgentTool::SpawnAgent => CollabTool::SpawnAgent, + CollabAgentTool::SendInput => CollabTool::SendInput, + CollabAgentTool::ResumeAgent => CollabTool::Wait, + CollabAgentTool::Wait => CollabTool::Wait, + CollabAgentTool::CloseAgent => CollabTool::CloseAgent, + }, + sender_thread_id, + receiver_thread_ids, + prompt, + agents_states: agents_states + .into_iter() + .map(|(thread_id, state)| { + ( + thread_id, + CollabAgentState { + status: match state.status { + codex_app_server_protocol::CollabAgentStatus::PendingInit => { + CollabAgentStatus::PendingInit + } + codex_app_server_protocol::CollabAgentStatus::Running => { + CollabAgentStatus::Running + } + codex_app_server_protocol::CollabAgentStatus::Interrupted => { + CollabAgentStatus::Interrupted + } + codex_app_server_protocol::CollabAgentStatus::Completed => { + CollabAgentStatus::Completed + } + codex_app_server_protocol::CollabAgentStatus::Errored => { + CollabAgentStatus::Errored + } + codex_app_server_protocol::CollabAgentStatus::Shutdown => { + CollabAgentStatus::Shutdown + } + codex_app_server_protocol::CollabAgentStatus::NotFound => { + CollabAgentStatus::NotFound + } + }, + message: state.message, + }, + ) }) .collect(), - status, + status: match status { + CollabAgentToolCallStatus::InProgress => CollabToolCallStatus::InProgress, + CollabAgentToolCallStatus::Completed => CollabToolCallStatus::Completed, + CollabAgentToolCallStatus::Failed => CollabToolCallStatus::Failed, + }, }), - }; - - return vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })]; - } - - Vec::new() - } - - fn handle_exec_command_end(&mut self, ev: &protocol::ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { - command, - item_id, - aggregated_output, - }) = self.running_commands.remove(&ev.call_id) - else { - warn!( - call_id = ev.call_id, - "ExecCommandEnd without matching ExecCommandBegin; skipping item.completed" - ); - return Vec::new(); - }; - let status = if ev.exit_code == 0 { - CommandExecutionStatus::Completed - } else { - CommandExecutionStatus::Failed - }; - let aggregated_output = if ev.aggregated_output.is_empty() { - aggregated_output - } else { - ev.aggregated_output.clone() - }; - let item = ThreadItem { - id: item_id, - - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command, - aggregated_output, - exit_code: Some(ev.exit_code), - status, }), - }; - - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + ThreadItem::WebSearch { + id: raw_id, + query, + action, + } => Some(ExecThreadItem { + id: make_id(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: raw_id, + query, + action: match action { + Some(action) => serde_json::from_value( + serde_json::to_value(action).unwrap_or_else(|_| json!("other")), + ) + .unwrap_or(WebSearchAction::Other), + None => WebSearchAction::Other, + }, + }), + }), + _ => None, + } } - fn todo_items_from_plan(&self, args: &UpdatePlanArgs) -> Vec { - args.plan + fn started_item_id(&mut self, raw_id: &str) -> String { + if let Some(existing) = self.raw_to_exec_item_id.get(raw_id) { + return existing.clone(); + } + let exec_id = self.next_item_id(); + self.raw_to_exec_item_id + .insert(raw_id.to_string(), exec_id.clone()); + exec_id + } + + fn completed_item_id(&mut self, raw_id: &str) -> String { + self.raw_to_exec_item_id + .remove(raw_id) + .unwrap_or_else(|| self.next_item_id()) + } + + fn map_started_item(&mut self, item: ThreadItem) -> Option { + match item { + ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => None, + other => { + let raw_id = other.id().to_string(); + Self::map_item_with_id(other, || self.started_item_id(&raw_id)) + } + } + } + + fn map_completed_item_mut(&mut self, item: ThreadItem) -> Option { + if let ThreadItem::Reasoning { summary, .. } = &item + && summary.join("\n").trim().is_empty() + { + return None; + } + match &item { + ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => { + Self::map_item_with_id(item, || self.next_item_id()) + } + other => { + let raw_id = other.id().to_string(); + Self::map_item_with_id(item, || self.completed_item_id(&raw_id)) + } + } + } + + fn reconcile_unfinished_started_items( + &mut self, + turn_items: &[ThreadItem], + ) -> Vec { + turn_items .iter() - .map(|p| TodoItem { - text: p.step.clone(), - completed: matches!(p.status, StepStatus::Completed), + .filter_map(|item| { + let raw_id = item.id().to_string(); + if !self.raw_to_exec_item_id.contains_key(&raw_id) { + return None; + } + self.map_completed_item_mut(item.clone()) + .map(|item| ThreadEvent::ItemCompleted(ItemCompletedEvent { item })) }) .collect() } - fn handle_plan_update(&mut self, args: &UpdatePlanArgs) -> Vec { - let items = self.todo_items_from_plan(args); - - if let Some(running) = &mut self.running_todo_list { - running.items = items.clone(); - let item = ThreadItem { - id: running.item_id.clone(), - details: ThreadItemDetails::TodoList(TodoListItem { items }), - }; - return vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { item })]; - } - - let item_id = self.get_next_item_id(); - self.running_todo_list = Some(RunningTodoList { - item_id: item_id.clone(), - items: items.clone(), - }); - let item = ThreadItem { - id: item_id, - details: ThreadItemDetails::TodoList(TodoListItem { items }), - }; - vec![ThreadEvent::ItemStarted(ItemStartedEvent { item })] - } - - fn handle_task_started(&mut self, _: &protocol::TurnStartedEvent) -> Vec { - self.last_critical_error = None; - vec![ThreadEvent::TurnStarted(TurnStartedEvent {})] - } - - fn handle_task_complete(&mut self) -> Vec { - let usage = if let Some(u) = &self.last_total_token_usage { - Usage { - input_tokens: u.input_tokens, - cached_input_tokens: u.cached_input_tokens, - output_tokens: u.output_tokens, - } - } else { - Usage::default() - }; - - let mut items = Vec::new(); - - if let Some(running) = self.running_todo_list.take() { - let item = ThreadItem { - id: running.item_id, - details: ThreadItemDetails::TodoList(TodoListItem { - items: running.items, - }), - }; - items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); - } - - if !self.running_commands.is_empty() { - for (_, running) in self.running_commands.drain() { - let item = ThreadItem { - id: running.item_id, - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: running.command, - aggregated_output: running.aggregated_output, - exit_code: None, - status: CommandExecutionStatus::Completed, - }), - }; - items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); - } - } - - if let Some(error) = self.last_critical_error.take() { - items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); - } else { - items.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { usage })); - } - + fn final_message_from_turn_items(items: &[ThreadItem]) -> Option { items + .iter() + .rev() + .find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.clone()), + _ => None, + }) + .or_else(|| { + items.iter().rev().find_map(|item| match item { + ThreadItem::Plan { text, .. } => Some(text.clone()), + _ => None, + }) + }) } -} -fn is_collab_failure(status: &CoreAgentStatus) -> bool { - matches!( - status, - CoreAgentStatus::Errored(_) | CoreAgentStatus::NotFound - ) -} + pub fn thread_started_event(session_configured: &SessionConfiguredEvent) -> ThreadEvent { + ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: session_configured.session_id.to_string(), + }) + } -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Interrupted => Self { - status: CollabAgentStatus::Interrupted, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, + pub fn collect_warning(&mut self, message: String) -> CollectedThreadEvents { + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })], + status: CodexStatus::Running, } } + + pub fn collect_thread_events( + &mut self, + notification: ServerNotification, + ) -> CollectedThreadEvents { + let mut events = Vec::new(); + let status = match notification { + ServerNotification::ConfigWarning(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + ServerNotification::Error(notification) => { + let message = match notification.error.additional_details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.error.message) + } + _ => notification.error.message, + }; + let error = ThreadErrorEvent { message }; + self.last_critical_error = Some(error.clone()); + events.push(ThreadEvent::Error(error)); + CodexStatus::Running + } + ServerNotification::DeprecationNotice(notification) => { + let message = match notification.details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", notification.summary) + } + _ => notification.summary, + }; + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { message }), + }, + })); + CodexStatus::Running + } + ServerNotification::HookStarted(_) | ServerNotification::HookCompleted(_) => { + CodexStatus::Running + } + ServerNotification::ItemStarted(notification) => { + if let Some(item) = self.map_started_item(notification.item) { + events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item })); + } + CodexStatus::Running + } + ServerNotification::ItemCompleted(notification) => { + if let Some(item) = self.map_completed_item_mut(notification.item) { + if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) = + &item.details + { + self.final_message = Some(text.clone()); + } + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + CodexStatus::Running + } + ServerNotification::ModelRerouted(notification) => { + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: self.next_item_id(), + details: ThreadItemDetails::Error(ErrorItem { + message: format!( + "model rerouted: {} -> {} ({:?})", + notification.from_model, notification.to_model, notification.reason + ), + }), + }, + })); + CodexStatus::Running + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.last_total_token_usage = Some(notification.token_usage); + CodexStatus::Running + } + ServerNotification::TurnCompleted(notification) => { + if let Some(running) = self.running_todo_list.take() { + events.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: running.item_id, + details: ThreadItemDetails::TodoList(TodoListItem { + items: running.items, + }), + }, + })); + } + events.extend(self.reconcile_unfinished_started_items(¬ification.turn.items)); + match notification.turn.status { + TurnStatus::Completed => { + if let Some(final_message) = + Self::final_message_from_turn_items(notification.turn.items.as_slice()) + { + self.final_message = Some(final_message); + } + self.emit_final_message_on_shutdown = true; + events.push(ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: self.usage_from_last_total(), + })); + CodexStatus::InitiateShutdown + } + TurnStatus::Failed => { + self.final_message = None; + self.emit_final_message_on_shutdown = false; + let error = notification + .turn + .error + .map(|error| ThreadErrorEvent { + message: match error.additional_details { + Some(details) if !details.is_empty() => { + format!("{} ({details})", error.message) + } + _ => error.message, + }, + }) + .or_else(|| self.last_critical_error.clone()) + .unwrap_or_else(|| ThreadErrorEvent { + message: "turn failed".to_string(), + }); + events.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); + CodexStatus::InitiateShutdown + } + TurnStatus::Interrupted => { + self.final_message = None; + self.emit_final_message_on_shutdown = false; + CodexStatus::InitiateShutdown + } + TurnStatus::InProgress => CodexStatus::Running, + } + } + ServerNotification::TurnDiffUpdated(_) => CodexStatus::Running, + ServerNotification::TurnPlanUpdated(notification) => { + let items = Self::map_todo_items(¬ification.plan); + if let Some(running) = self.running_todo_list.as_mut() { + running.items = items.clone(); + let item_id = running.item_id.clone(); + events.push(ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } else { + let item_id = self.next_item_id(); + self.running_todo_list = Some(RunningTodoList { + item_id: item_id.clone(), + items: items.clone(), + }); + events.push(ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: item_id, + details: ThreadItemDetails::TodoList(TodoListItem { items }), + }, + })); + } + CodexStatus::Running + } + ServerNotification::TurnStarted(_) => { + events.push(ThreadEvent::TurnStarted(TurnStartedEvent {})); + CodexStatus::Running + } + _ => CodexStatus::Running, + }; + + CollectedThreadEvents { events, status } + } } impl EventProcessor for EventProcessorWithJsonOutput { - fn print_config_summary(&mut self, _: &Config, _: &str, ev: &protocol::SessionConfiguredEvent) { - self.process_event(protocol::Event { - id: "".to_string(), - msg: protocol::EventMsg::SessionConfigured(ev.clone()), - }); + fn print_config_summary( + &mut self, + _: &Config, + _: &str, + session_configured: &SessionConfiguredEvent, + ) { + self.emit(Self::thread_started_event(session_configured)); } - #[allow(clippy::print_stdout)] - fn process_event(&mut self, event: protocol::Event) -> CodexStatus { - let aggregated = self.collect_thread_events(&event); - for conv_event in aggregated { - match serde_json::to_string(&conv_event) { - Ok(line) => { - println!("{line}"); - } - Err(e) => { - error!("Failed to serialize event: {e:?}"); - } - } + fn process_server_notification(&mut self, notification: ServerNotification) -> CodexStatus { + let collected = self.collect_thread_events(notification); + for event in collected.events { + self.emit(event); } + collected.status + } - let protocol::Event { msg, .. } = event; + fn process_warning(&mut self, message: String) -> CodexStatus { + let collected = self.collect_warning(message); + for event in collected.events { + self.emit(event); + } + collected.status + } - match msg { - protocol::EventMsg::TurnComplete(protocol::TurnCompleteEvent { - last_agent_message, - .. - }) => { - if let Some(output_file) = self.last_message_path.as_deref() { - let last_message = last_agent_message - .as_deref() - .or(self.last_proposed_plan.as_deref()); - handle_last_message(last_message, output_file); - } - CodexStatus::InitiateShutdown - } - protocol::EventMsg::TurnAborted(_) => CodexStatus::InitiateShutdown, - protocol::EventMsg::ShutdownComplete => CodexStatus::Shutdown, - _ => CodexStatus::Running, + fn print_final_output(&mut self) { + if self.emit_final_message_on_shutdown + && let Some(path) = self.last_message_path.as_deref() + { + handle_last_message(self.final_message.as_deref(), path); } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn failed_turn_does_not_overwrite_output_last_message_file() { + let tempdir = tempdir().expect("create tempdir"); + let output_path = tempdir.path().join("last-message.txt"); + std::fs::write(&output_path, "keep existing contents").expect("seed output file"); + + let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone())); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let status = processor.process_server_notification(ServerNotification::TurnCompleted( + codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(codex_app_server_protocol::TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); + + EventProcessor::print_final_output(&mut processor); + + assert_eq!( + std::fs::read_to_string(&output_path).expect("read output file"), + "keep existing contents" + ); + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index c10b86f696..c4f106b464 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -17,11 +17,9 @@ use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_app_server_client::InProcessServerEvent; -use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::RequestId; @@ -30,8 +28,16 @@ use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::Thread as AppServerThread; +use codex_app_server_protocol::ThreadItem as AppServerThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; @@ -40,9 +46,9 @@ use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_requirements::cloud_requirements_loader; -use codex_core::AuthManager; +use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::auth::AuthConfig; @@ -58,17 +64,18 @@ use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::format_config_error_with_source; use codex_core::format_exec_policy_error_with_source; -use codex_core::git_info::get_git_repo_root; +use codex_core::path_utils; use codex_feedback::CodexFeedback; +use codex_git_utils::get_git_repo_root; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; -use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::SandboxMode; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; @@ -79,10 +86,9 @@ use event_processor_with_human_output::EventProcessorWithHumanOutput; use event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use serde_json::Value; use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::VecDeque; use std::io::IsTerminal; use std::io::Read; +use std::path::Path; use std::path::PathBuf; use supports_color::Stream; use tokio::sync::mpsc; @@ -101,8 +107,6 @@ use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::default_client::set_default_originator; -use codex_core::find_thread_path_by_id_str; -use codex_core::find_thread_path_by_name_str; const DEFAULT_ANALYTICS_ENABLED: bool = true; @@ -136,7 +140,6 @@ struct ExecRunArgs { in_process_start_args: InProcessClientStartArgs, command: Option, config: Config, - cursor_ansi: bool, dangerously_bypass_approvals_and_sandbox: bool, exec_span: tracing::Span, images: Vec, @@ -184,7 +187,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result prompt, output_schema: output_schema_path, config_overrides, - progress_cursor, } = cli; let (_stdout_with_ansi, stderr_with_ansi) = match color { @@ -195,25 +197,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result supports_color::on_cached(Stream::Stderr).is_some(), ), }; - let cursor_ansi = if progress_cursor { - true - } else { - match color { - cli::Color::Never => false, - cli::Color::Always => true, - cli::Color::Auto => { - if stderr_with_ansi || std::io::stderr().is_terminal() { - true - } else { - match std::env::var("TERM") { - Ok(term) => !term.is_empty() && term != "dumb", - Err(_) => false, - } - } - } - } - }; - // Build fmt layer (existing logging) to compose with OTEL layer. let default_level = "error"; @@ -287,18 +270,17 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result } }; - let cloud_auth_manager = AuthManager::shared( - codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config_toml.cli_auth_credentials_store.unwrap_or_default(), - ); let chatgpt_base_url = config_toml .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); // TODO(gt): Make cloud requirements failures blocking once we can fail-closed. - let cloud_requirements = - cloud_requirements_loader(cloud_auth_manager, chatgpt_base_url, codex_home.clone()); + let cloud_requirements = cloud_requirements_loader_for_storage( + codex_home.clone(), + /*enable_codex_api_key_env*/ false, + config_toml.cli_auth_credentials_store.unwrap_or_default(), + chatgpt_base_url, + ); let run_cli_overrides = cli_kv_overrides.clone(); let run_loader_overrides = LoaderOverrides::default(); let run_cloud_requirements = cloud_requirements.clone(); @@ -455,7 +437,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result in_process_start_args, command, config, - cursor_ansi, dangerously_bypass_approvals_and_sandbox, exec_span: exec_span.clone(), images, @@ -477,7 +458,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { in_process_start_args, command, config, - cursor_ansi, dangerously_bypass_approvals_and_sandbox, exec_span, images, @@ -495,19 +475,10 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())), _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi( stderr_with_ansi, - cursor_ansi, &config, last_message_file.clone(), )), }; - let required_mcp_servers: HashSet = config - .mcp_servers - .get() - .iter() - .filter(|(_, server)| server.enabled && server.required) - .map(|(name, _)| name.clone()) - .collect(); - if oss { // We're in the oss section, so provider_id should be Some // Let's handle None case gracefully though just in case @@ -547,17 +518,16 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { anyhow::anyhow!("failed to initialize in-process app-server client: {err}") })?; - // Handle resume subcommand by resolving a rollout path and using explicit resume API. + // Handle resume subcommand through existing `thread/list` + `thread/resume` + // APIs so exec no longer reaches into rollout storage directly. let (primary_thread_id, fallback_session_configured) = if let Some(ExecCommand::Resume(args)) = command.as_ref() { - let resume_path = resolve_resume_path(&config, args).await?; - - if let Some(path) = resume_path { + if let Some(thread_id) = resolve_resume_thread_id(&client, &config, args).await? { let response: ThreadResumeResponse = send_request_with_response( &client, ClientRequest::ThreadResume { request_id: request_ids.next(), - params: thread_resume_params_from_config(&config, Some(path)), + params: thread_resume_params_from_config(&config, thread_id), }, "thread/resume", ) @@ -598,7 +568,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { }; let primary_thread_id_for_span = primary_thread_id.to_string(); - let mut buffered_events = VecDeque::new(); // Use the start/resume response as the authoritative bootstrap payload. // Waiting for a later streamed `SessionConfigured` event adds up to 10s of // avoidable startup latency on the in-process path. @@ -670,10 +639,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { // is using. event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = codex_core::config::system_bwrap_warning() { - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + event_processor.process_warning(message); } info!("Codex initialized with event: {session_configured:?}"); @@ -734,6 +700,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; + let _ = event_processor.process_server_notification(ServerNotification::TurnStarted( + TurnStartedNotification { + thread_id: response.review_thread_id.clone(), + turn: response.turn.clone(), + }, + )); let task_id = response.turn.id; info!("Sent review request with event ID: {task_id}"); task_id @@ -748,34 +720,30 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let mut interrupt_channel_open = true; let primary_thread_id_for_requests = primary_thread_id.to_string(); loop { - let server_event = if let Some(event) = buffered_events.pop_front() { - Some(event) - } else { - tokio::select! { - maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { - if maybe_interrupt.is_none() { - interrupt_channel_open = false; - continue; - } - if let Err(err) = send_request_with_response::( - &client, - ClientRequest::TurnInterrupt { - request_id: request_ids.next(), - params: TurnInterruptParams { - thread_id: primary_thread_id_for_requests.clone(), - turn_id: task_id.clone(), - }, - }, - "turn/interrupt", - ) - .await - { - warn!("turn/interrupt failed: {err}"); - } + let server_event = tokio::select! { + maybe_interrupt = interrupt_rx.recv(), if interrupt_channel_open => { + if maybe_interrupt.is_none() { + interrupt_channel_open = false; continue; } - maybe_event = client.next_event() => maybe_event, + if let Err(err) = send_request_with_response::( + &client, + ClientRequest::TurnInterrupt { + request_id: request_ids.next(), + params: TurnInterruptParams { + thread_id: primary_thread_id_for_requests.clone(), + turn_id: task_id.clone(), + }, + }, + "turn/interrupt", + ) + .await + { + warn!("turn/interrupt failed: {err}"); + } + continue; } + maybe_event = client.next_event() => maybe_event, }; let Some(server_event) = server_event else { @@ -784,69 +752,39 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { match server_event { InProcessServerEvent::ServerRequest(request) => { - handle_server_request( - &client, - request, - &config, - &primary_thread_id_for_requests, - &mut error_seen, - ) - .await; + handle_server_request(&client, request, &mut error_seen).await; } - InProcessServerEvent::ServerNotification(notification) => { - if let ServerNotification::Error(payload) = ¬ification + InProcessServerEvent::ServerNotification(mut notification) => { + if let ServerNotification::Error(payload) = ¬ification { + if payload.thread_id == primary_thread_id_for_requests + && payload.turn_id == task_id + && !payload.will_retry + { + error_seen = true; + } + } else if let ServerNotification::TurnCompleted(payload) = ¬ification && payload.thread_id == primary_thread_id_for_requests - && payload.turn_id == task_id - && !payload.will_retry + && payload.turn.id == task_id + && matches!( + payload.turn.status, + codex_app_server_protocol::TurnStatus::Failed + | codex_app_server_protocol::TurnStatus::Interrupted + ) { error_seen = true; } - } - InProcessServerEvent::LegacyNotification(notification) => { - let decoded = match decode_legacy_notification(notification) { - Ok(event) => event, - Err(err) => { - warn!("{err}"); - continue; - } - }; - if decoded.conversation_id.as_deref() - != Some(primary_thread_id_for_requests.as_str()) - && decoded.conversation_id.is_some() - { - continue; - } - let event = decoded.event; - if matches!(event.msg, EventMsg::SessionConfigured(_)) { - continue; - } - if matches!(event.msg, EventMsg::Error(_)) { - // The legacy bridge still carries fatal turn failures for - // exec. Preserve the non-zero exit behavior until this - // path is fully replaced by typed server notifications. - error_seen = true; - } - match &event.msg { - EventMsg::TurnComplete(payload) => { - if payload.turn_id != task_id { - continue; - } - } - EventMsg::TurnAborted(payload) => { - if payload.turn_id.as_deref() != Some(task_id.as_str()) { - continue; - } - } - EventMsg::McpStartupUpdate(update) => { - if required_mcp_servers.contains(&update.server) - && let codex_protocol::protocol::McpStartupStatus::Failed { error } = - &update.status - { - error_seen = true; - eprintln!( - "Required MCP server '{}' failed to initialize: {error}", - update.server - ); + + maybe_backfill_turn_completed_items(&client, &mut request_ids, &mut notification) + .await; + + if should_process_notification( + ¬ification, + &primary_thread_id_for_requests, + &task_id, + ) { + match event_processor.process_server_notification(notification) { + CodexStatus::Running => {} + CodexStatus::InitiateShutdown => { if let Err(err) = request_shutdown( &client, &mut request_ids, @@ -859,37 +797,12 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { break; } } - _ => {} - } - - match event_processor.process_event(event) { - CodexStatus::Running => {} - CodexStatus::InitiateShutdown => { - if let Err(err) = request_shutdown( - &client, - &mut request_ids, - &primary_thread_id_for_requests, - ) - .await - { - warn!("thread/unsubscribe failed during shutdown: {err}"); - } - break; - } - CodexStatus::Shutdown => { - // `ShutdownComplete` does not identify which attached - // thread emitted it, so subagent shutdowns must not end - // the primary exec loop early. - } } } InProcessServerEvent::Lagged { skipped } => { let message = lagged_event_warning_message(skipped); warn!("{message}"); - let _ = event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::Warning(codex_protocol::protocol::WarningEvent { message }), - }); + event_processor.process_warning(message); } } } @@ -936,10 +849,9 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { } } -fn thread_resume_params_from_config(config: &Config, path: Option) -> ThreadResumeParams { +fn thread_resume_params_from_config(config: &Config, thread_id: String) -> ThreadResumeParams { ThreadResumeParams { - thread_id: "resume".to_string(), - path, + thread_id, model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), @@ -1017,20 +929,19 @@ fn session_configured_from_thread_resume_response( ) } +fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { + match target { + ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, + ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, + ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, + } +} + #[expect( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" )] -/// Synthesizes startup session metadata from `thread/start` or `thread/resume`. -/// -/// This is a compatibility bridge for the current in-process architecture. -/// Some session fields are not available synchronously from the start/resume -/// response, so callers must treat the result as a best-effort fallback until -/// a later `SessionConfigured` event proves otherwise. -/// TODO(architecture): stop synthesizing a partial `SessionConfiguredEvent` -/// here. Either return the authoritative session-configured payload from -/// `thread/start`/`thread/resume`, or introduce a smaller bootstrap type for -/// exec so this path cannot accidentally depend on placeholder fields. fn session_configured_from_thread_response( thread_id: &str, thread_name: Option, @@ -1040,7 +951,7 @@ fn session_configured_from_thread_response( service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - sandbox_policy: codex_protocol::protocol::SandboxPolicy, + sandbox_policy: SandboxPolicy, cwd: PathBuf, reasoning_effort: Option, ) -> Result { @@ -1067,71 +978,261 @@ fn session_configured_from_thread_response( }) } -fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { - match target { - ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, - ReviewTarget::BaseBranch { branch } => ApiReviewTarget::BaseBranch { branch }, - ReviewTarget::Commit { sha, title } => ApiReviewTarget::Commit { sha, title }, - ReviewTarget::Custom { instructions } => ApiReviewTarget::Custom { instructions }, - } -} - -fn normalize_legacy_notification_method(method: &str) -> &str { - method.strip_prefix("codex/event/").unwrap_or(method) -} - fn lagged_event_warning_message(skipped: usize) -> String { format!("in-process app-server event stream lagged; dropped {skipped} events") } -struct DecodedLegacyNotification { - conversation_id: Option, - event: Event, +fn should_process_notification( + notification: &ServerNotification, + thread_id: &str, + turn_id: &str, +) -> bool { + match notification { + ServerNotification::ConfigWarning(_) | ServerNotification::DeprecationNotice(_) => true, + ServerNotification::Error(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::HookCompleted(notification) => { + notification.thread_id == thread_id + && notification + .turn_id + .as_deref() + .is_none_or(|candidate| candidate == turn_id) + } + ServerNotification::HookStarted(notification) => { + notification.thread_id == thread_id + && notification + .turn_id + .as_deref() + .is_none_or(|candidate| candidate == turn_id) + } + ServerNotification::ItemCompleted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ItemStarted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ModelRerouted(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnCompleted(notification) => { + notification.thread_id == thread_id && notification.turn.id == turn_id + } + ServerNotification::TurnDiffUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnPlanUpdated(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } + ServerNotification::TurnStarted(notification) => { + notification.thread_id == thread_id && notification.turn.id == turn_id + } + _ => false, + } } -fn decode_legacy_notification( - notification: JSONRPCNotification, -) -> Result { - let value = notification - .params - .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); - let method = notification.method; - let normalized_method = normalize_legacy_notification_method(&method).to_string(); - let serde_json::Value::Object(mut object) = value else { - return Err(format!( - "legacy notification `{method}` params were not an object" - )); +async fn maybe_backfill_turn_completed_items( + client: &InProcessAppServerClient, + request_ids: &mut RequestIdSequencer, + notification: &mut ServerNotification, +) { + // In-process delivery may drop non-terminal item notifications under backpressure while still + // guaranteeing `turn/completed`. Because app-server currently emits that completion with an + // empty `turn.items`, exec does one last `thread/read` here so human/json output can recover + // the final message and reconcile any still-running items before shutdown. + let ServerNotification::TurnCompleted(payload) = notification else { + return; }; - let conversation_id = object - .get("conversationId") - .and_then(serde_json::Value::as_str) - .map(str::to_owned); - let mut event_payload = if let Some(serde_json::Value::Object(msg_payload)) = object.get("msg") - { - serde_json::Value::Object(msg_payload.clone()) - } else { - object.remove("conversationId"); - serde_json::Value::Object(object) - }; - let serde_json::Value::Object(ref mut object) = event_payload else { - return Err(format!( - "legacy notification `{method}` event payload was not an object" - )); - }; - object.insert( - "type".to_string(), - serde_json::Value::String(normalized_method), - ); + if !payload.turn.items.is_empty() { + return; + } - let msg: EventMsg = serde_json::from_value(event_payload) - .map_err(|err| format!("failed to decode event: {err}"))?; - Ok(DecodedLegacyNotification { - conversation_id, - event: Event { - id: String::new(), - msg, + let response = send_request_with_response::( + client, + ClientRequest::ThreadRead { + request_id: request_ids.next(), + params: ThreadReadParams { + thread_id: payload.thread_id.clone(), + include_turns: true, + }, }, - }) + "thread/read", + ) + .await; + + match response { + Ok(response) => { + if let Some(items) = turn_items_for_thread(&response.thread, &payload.turn.id) { + payload.turn.items = items; + } + } + Err(err) => { + warn!("thread/read failed while backfilling turn items for turn completion: {err}"); + } + } +} + +fn turn_items_for_thread( + thread: &AppServerThread, + turn_id: &str, +) -> Option> { + thread + .turns + .iter() + .find(|turn| turn.id == turn_id) + .map(|turn| turn.items.clone()) +} + +fn all_thread_source_kinds() -> Vec { + vec![ + ThreadSourceKind::Cli, + ThreadSourceKind::VsCode, + ThreadSourceKind::Exec, + ThreadSourceKind::AppServer, + ThreadSourceKind::SubAgent, + ThreadSourceKind::SubAgentReview, + ThreadSourceKind::SubAgentCompact, + ThreadSourceKind::SubAgentThreadSpawn, + ThreadSourceKind::SubAgentOther, + ThreadSourceKind::Unknown, + ] +} + +async fn latest_thread_cwd(thread: &AppServerThread) -> PathBuf { + if let Some(path) = thread.path.as_deref() + && let Some(cwd) = parse_latest_turn_context_cwd(path).await + { + return cwd; + } + thread.cwd.clone() +} + +async fn parse_latest_turn_context_cwd(path: &Path) -> Option { + let text = tokio::fs::read_to_string(path).await.ok()?; + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(rollout_line) = serde_json::from_str::(trimmed) else { + continue; + }; + if let RolloutItem::TurnContext(item) = rollout_line.item { + return Some(item.cwd); + } + } + None +} + +fn cwds_match(current_cwd: &Path, session_cwd: &Path) -> bool { + match ( + path_utils::normalize_for_path_comparison(current_cwd), + path_utils::normalize_for_path_comparison(session_cwd), + ) { + (Ok(current), Ok(session)) => current == session, + _ => current_cwd == session_cwd, + } +} + +async fn resolve_resume_thread_id( + client: &InProcessAppServerClient, + config: &Config, + args: &crate::cli::ResumeArgs, +) -> anyhow::Result> { + let model_providers = resume_lookup_model_providers(config, args); + + if args.last { + let mut cursor = None; + loop { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers: model_providers.clone(), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: None, + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + for thread in response.data { + if args.all || cwds_match(config.cwd.as_path(), &latest_thread_cwd(&thread).await) { + return Ok(Some(thread.id)); + } + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } + } + + let Some(session_id) = args.session_id.as_deref() else { + return Ok(None); + }; + if Uuid::parse_str(session_id).is_ok() { + return Ok(Some(session_id.to_string())); + } + + let mut cursor = None; + loop { + let response: ThreadListResponse = send_request_with_response( + client, + ClientRequest::ThreadList { + request_id: RequestId::Integer(0), + params: ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::UpdatedAt), + model_providers: model_providers.clone(), + source_kinds: Some(all_thread_source_kinds()), + archived: Some(false), + cwd: None, + // Thread names are attached separately from rollout titles, so name + // resolution must scan the filtered list client-side instead of relying + // on the backend `search_term` filter. + search_term: None, + }, + }, + "thread/list", + ) + .await + .map_err(anyhow::Error::msg)?; + for thread in response.data { + if thread.name.as_deref() != Some(session_id) { + continue; + } + if args.all || cwds_match(config.cwd.as_path(), &latest_thread_cwd(&thread).await) { + return Ok(Some(thread.id)); + } + } + let Some(next_cursor) = response.next_cursor else { + return Ok(None); + }; + cursor = Some(next_cursor); + } +} + +fn resume_lookup_model_providers( + config: &Config, + args: &crate::cli::ResumeArgs, +) -> Option> { + if args.last { + Some(vec![config.model_provider_id.clone()]) + } else { + None + } } fn canceled_mcp_server_elicitation_response() -> Result { @@ -1205,8 +1306,6 @@ fn server_request_method_name(request: &ServerRequest) -> String { async fn handle_server_request( client: &InProcessAppServerClient, request: ServerRequest, - config: &Config, - _thread_id: &str, error_seen: &mut bool, ) { let method = server_request_method_name(&request); @@ -1228,50 +1327,6 @@ async fn handle_server_request( Err(err) => Err(err), } } - ServerRequest::ChatgptAuthTokensRefresh { request_id, params } => { - let refresh_result = tokio::task::spawn_blocking({ - let config = config.clone(); - move || local_external_chatgpt_tokens(&config) - }) - .await; - - match refresh_result { - Err(err) => { - reject_server_request( - client, - request_id, - &method, - format!("local chatgpt auth refresh task failed in exec: {err}"), - ) - .await - } - Ok(Err(reason)) => reject_server_request(client, request_id, &method, reason).await, - Ok(Ok(response)) => { - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != response.chatgpt_account_id - { - warn!( - "local auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - response.chatgpt_account_id - ); - } - match serde_json::to_value(response) { - Ok(value) => { - resolve_server_request( - client, - request_id, - value, - "account/chatgptAuthTokens/refresh", - ) - .await - } - Err(err) => Err(format!( - "failed to serialize chatgpt auth refresh response: {err}" - )), - } - } - } - } ServerRequest::CommandExecutionRequestApproval { request_id, params } => { reject_server_request( client, @@ -1320,6 +1375,15 @@ async fn handle_server_request( ) .await } + ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "chatgpt auth token refresh is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, @@ -1364,91 +1428,6 @@ async fn handle_server_request( } } -fn local_external_chatgpt_tokens( - config: &Config, -) -> Result { - let auth_manager = AuthManager::shared( - config.codex_home.clone(), - /*enable_codex_api_key_env*/ false, - config.cli_auth_credentials_store_mode, - ); - auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - auth_manager.reload(); - - let auth = auth_manager - .auth_cached() - .ok_or_else(|| "no cached auth available for local token refresh".to_string())?; - if !auth.is_external_chatgpt_tokens() { - return Err("external ChatGPT token auth is not active".to_string()); - } - - let access_token = auth - .get_token() - .map_err(|err| format!("failed to read external access token: {err}"))?; - let chatgpt_account_id = auth - .get_account_id() - .ok_or_else(|| "external token auth is missing chatgpt account id".to_string())?; - let chatgpt_plan_type = auth.account_plan_type().map(|plan_type| match plan_type { - AccountPlanType::Free => "free".to_string(), - AccountPlanType::Go => "go".to_string(), - AccountPlanType::Plus => "plus".to_string(), - AccountPlanType::Pro => "pro".to_string(), - AccountPlanType::Team => "team".to_string(), - AccountPlanType::Business => "business".to_string(), - AccountPlanType::Enterprise => "enterprise".to_string(), - AccountPlanType::Edu => "edu".to_string(), - AccountPlanType::Unknown => "unknown".to_string(), - }); - - Ok(ChatgptAuthTokensRefreshResponse { - access_token, - chatgpt_account_id, - chatgpt_plan_type, - }) -} - -async fn resolve_resume_path( - config: &Config, - args: &crate::cli::ResumeArgs, -) -> anyhow::Result> { - if args.last { - let default_provider_filter = vec![config.model_provider_id.clone()]; - let filter_cwd = if args.all { - None - } else { - Some(config.cwd.as_path()) - }; - match codex_core::RolloutRecorder::find_latest_thread_path( - config, - /*page_size*/ 1, - /*cursor*/ None, - codex_core::ThreadSortKey::UpdatedAt, - &[], - Some(default_provider_filter.as_slice()), - &config.model_provider_id, - filter_cwd, - ) - .await - { - Ok(path) => Ok(path), - Err(e) => { - error!("Error listing threads: {e}"); - Ok(None) - } - } - } else if let Some(id_str) = args.session_id.as_deref() { - if Uuid::parse_str(id_str).is_ok() { - let path = find_thread_path_by_id_str(&config.codex_home, id_str).await?; - Ok(path) - } else { - let path = find_thread_path_by_name_str(&config.codex_home, id_str).await?; - Ok(path) - } - } else { - Ok(None) - } -} - fn load_output_schema(path: Option) -> Option { let path = path?; @@ -1806,27 +1785,92 @@ mod tests { ); } - #[test] - fn decode_legacy_notification_preserves_conversation_id() { - let decoded = decode_legacy_notification(JSONRPCNotification { - method: "codex/event/error".to_string(), - params: Some(serde_json::json!({ - "conversationId": "thread-123", - "msg": { - "message": "boom" - } - })), - }) - .expect("legacy notification should decode"); + #[tokio::test] + async fn resume_lookup_model_providers_filters_only_last_lookup() { + let codex_home = tempdir().expect("create temp codex home"); + let cwd = tempdir().expect("create temp cwd"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build default config"); + config.model_provider_id = "test-provider".to_string(); - assert_eq!(decoded.conversation_id.as_deref(), Some("thread-123")); - assert!(matches!( - decoded.event.msg, - EventMsg::Error(codex_protocol::protocol::ErrorEvent { - message, - codex_error_info: None, - }) if message == "boom" - )); + let last_args = crate::cli::ResumeArgs { + session_id: None, + last: true, + all: false, + images: vec![], + prompt: None, + }; + let named_args = crate::cli::ResumeArgs { + session_id: Some("named-session".to_string()), + last: false, + all: false, + images: vec![], + prompt: None, + }; + + assert_eq!( + resume_lookup_model_providers(&config, &last_args), + Some(vec!["test-provider".to_string()]) + ); + assert_eq!(resume_lookup_model_providers(&config, &named_args), None); + } + + #[test] + fn turn_items_for_thread_returns_matching_turn_items() { + let thread = AppServerThread { + id: "thread-1".to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: codex_app_server_protocol::ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/project"), + cli_version: "0.0.0-test".to_string(), + source: codex_app_server_protocol::SessionSource::Exec, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![ + codex_app_server_protocol::Turn { + id: "turn-1".to_string(), + items: vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + codex_app_server_protocol::Turn { + id: "turn-2".to_string(), + items: vec![AppServerThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship it".to_string(), + }], + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + ], + }; + + assert_eq!( + turn_items_for_thread(&thread, "turn-1"), + Some(vec![AppServerThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }]) + ); + assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None); } #[test] diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e9f295337e..58579ccc2d 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -1,3 +1,44 @@ +use std::path::PathBuf; + +use codex_app_server_protocol::CollabAgentState as ApiCollabAgentState; +use codex_app_server_protocol::CollabAgentStatus as ApiCollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus as ApiCollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus as ApiCommandExecutionStatus; +use codex_app_server_protocol::ErrorNotification; +use codex_app_server_protocol::FileUpdateChange as ApiFileUpdateChange; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::McpToolCallError; +use codex_app_server_protocol::McpToolCallResult; +use codex_app_server_protocol::McpToolCallStatus as ApiMcpToolCallStatus; +use codex_app_server_protocol::PatchApplyStatus as ApiPatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind as ApiPatchChangeKind; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadTokenUsage; +use codex_app_server_protocol::TokenUsageBreakdown; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnPlanStep; +use codex_app_server_protocol::TurnPlanStepStatus; +use codex_app_server_protocol::TurnPlanUpdatedNotification; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_protocol::ThreadId; +use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionConfiguredEvent; +use pretty_assertions::assert_eq; +use serde_json::json; + +use codex_exec::event_processor_with_jsonl_output::CodexStatus; +use codex_exec::event_processor_with_jsonl_output::CollectedThreadEvents; use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AgentMessageItem; use codex_exec::exec_events::CollabAgentState; @@ -8,6 +49,8 @@ use codex_exec::exec_events::CollabToolCallStatus; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; use codex_exec::exec_events::ErrorItem; +use codex_exec::exec_events::FileChangeItem; +use codex_exec::exec_events::FileUpdateChange as ExecFileUpdateChange; use codex_exec::exec_events::ItemCompletedEvent; use codex_exec::exec_events::ItemStartedEvent; use codex_exec::exec_events::ItemUpdatedEvent; @@ -20,1299 +63,1509 @@ use codex_exec::exec_events::PatchChangeKind; use codex_exec::exec_events::ReasoningItem; use codex_exec::exec_events::ThreadErrorEvent; use codex_exec::exec_events::ThreadEvent; -use codex_exec::exec_events::ThreadItem; +use codex_exec::exec_events::ThreadItem as ExecThreadItem; use codex_exec::exec_events::ThreadItemDetails; use codex_exec::exec_events::ThreadStartedEvent; -use codex_exec::exec_events::TodoItem as ExecTodoItem; -use codex_exec::exec_events::TodoListItem as ExecTodoListItem; +use codex_exec::exec_events::TodoItem; +use codex_exec::exec_events::TodoListItem; use codex_exec::exec_events::TurnCompletedEvent; use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; use codex_exec::exec_events::WebSearchItem; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ModeKind; -use codex_protocol::mcp::CallToolResult; -use codex_protocol::models::WebSearchAction; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::protocol::AgentMessageEvent; -use codex_protocol::protocol::AgentReasoningEvent; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::CodexErrorInfo; -use codex_protocol::protocol::CollabAgentSpawnBeginEvent; -use codex_protocol::protocol::CollabAgentSpawnEndEvent; -use codex_protocol::protocol::CollabWaitingEndEvent; -use codex_protocol::protocol::ErrorEvent; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; -use codex_protocol::protocol::ExecCommandOutputDeltaEvent; -use codex_protocol::protocol::ExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_protocol::protocol::ExecOutputStream; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::McpInvocation; -use codex_protocol::protocol::McpToolCallBeginEvent; -use codex_protocol::protocol::McpToolCallEndEvent; -use codex_protocol::protocol::PatchApplyBeginEvent; -use codex_protocol::protocol::PatchApplyEndEvent; -use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::WarningEvent; -use codex_protocol::protocol::WebSearchBeginEvent; -use codex_protocol::protocol::WebSearchEndEvent; -use pretty_assertions::assert_eq; -use rmcp::model::Content; -use serde_json::json; -use std::path::PathBuf; -use std::time::Duration; - -fn event(id: &str, msg: EventMsg) -> Event { - Event { - id: id.to_string(), - msg, - } -} #[test] -fn session_configured_produces_thread_started_event() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let session_id = - codex_protocol::ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let rollout_path = PathBuf::from("/tmp/rollout.json"); - let ev = event( - "e1", - EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id, - forked_from_id: None, - thread_name: None, - model: "codex-mini-latest".to_string(), - model_provider_id: "test-provider".to_string(), - service_tier: None, - approval_policy: AskForApproval::Never, - approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/home/user/project"), - reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, - initial_messages: None, - network_proxy: None, - rollout_path: Some(rollout_path), - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), - })] - ); -} - -#[test] -fn task_started_produces_turn_started_event() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "t1", - EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: Some(32_000), - collaboration_mode_kind: ModeKind::Default, - }), - )); - - assert_eq!(out, vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]); -} - -#[test] -fn web_search_end_emits_item_completed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let query = "rust async await".to_string(); - let action = WebSearchAction::Search { - query: Some(query.clone()), - queries: None, - }; - let out = ep.collect_thread_events(&event( - "w1", - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: "call-123".to_string(), - query: query.clone(), - action: action.clone(), - }), - )); +fn map_todo_items_preserves_text_and_completion_state() { + let items = EventProcessorWithJsonOutput::map_todo_items(&[ + TurnPlanStep { + step: "inspect bootstrap".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + TurnPlanStep { + step: "drop legacy notifications".to_string(), + status: TurnPlanStepStatus::Completed, + }, + ]); assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-123".to_string(), - query, - action, - }), - }, - })] - ); -} - -#[test] -fn web_search_begin_emits_item_started() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "w0", - EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: "call-0".to_string(), - }), - )); - - assert_eq!(out.len(), 1); - let ThreadEvent::ItemStarted(ItemStartedEvent { item }) = &out[0] else { - panic!("expected ItemStarted"); - }; - assert!(item.id.starts_with("item_")); - assert_eq!( - item.details, - ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-0".to_string(), - query: String::new(), - action: WebSearchAction::Other, - }) - ); -} - -#[test] -fn web_search_begin_then_end_reuses_item_id() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let begin = ep.collect_thread_events(&event( - "w0", - EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: "call-1".to_string(), - }), - )); - let ThreadEvent::ItemStarted(ItemStartedEvent { item: started_item }) = &begin[0] else { - panic!("expected ItemStarted"); - }; - let action = WebSearchAction::Search { - query: Some("rust async await".to_string()), - queries: None, - }; - let end = ep.collect_thread_events(&event( - "w1", - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: "call-1".to_string(), - query: "rust async await".to_string(), - action: action.clone(), - }), - )); - let ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: completed_item, - }) = &end[0] - else { - panic!("expected ItemCompleted"); - }; - - assert_eq!(completed_item.id, started_item.id); - assert_eq!( - completed_item.details, - ThreadItemDetails::WebSearch(WebSearchItem { - id: "call-1".to_string(), - query: "rust async await".to_string(), - action, - }) - ); -} - -#[test] -fn plan_update_emits_todo_list_started_updated_and_completed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First plan update => item.started (todo_list) - let first = event( - "p1", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![ - PlanItemArg { - step: "step one".to_string(), - status: StepStatus::Pending, - }, - PlanItemArg { - step: "step two".to_string(), - status: StepStatus::InProgress, - }, - ], - }), - ); - let out_first = ep.collect_thread_events(&first); - assert_eq!( - out_first, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: false - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] - ); - - // Second plan update in same turn => item.updated (same id) - let second = event( - "p2", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![ - PlanItemArg { - step: "step one".to_string(), - status: StepStatus::Completed, - }, - PlanItemArg { - step: "step two".to_string(), - status: StepStatus::InProgress, - }, - ], - }), - ); - let out_second = ep.collect_thread_events(&second); - assert_eq!( - out_second, - vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - })] - ); - - // Task completes => item.completed (same id, latest state) - let complete = event( - "p3", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - let out_complete = ep.collect_thread_events(&complete); - assert_eq!( - out_complete, + items, vec![ - ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::TodoList(ExecTodoListItem { - items: vec![ - ExecTodoItem { - text: "step one".to_string(), - completed: true - }, - ExecTodoItem { - text: "step two".to_string(), - completed: false - }, - ], - }), - }, - }), - ThreadEvent::TurnCompleted(TurnCompletedEvent { - usage: Usage::default(), - }), + TodoItem { + text: "inspect bootstrap".to_string(), + completed: false, + }, + TodoItem { + text: "drop legacy notifications".to_string(), + completed: true, + }, ] ); } #[test] -fn mcp_tool_call_begin_and_end_emit_item_events() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: Some(json!({ "key": "value" })), +fn session_configured_produces_thread_started_event() { + let session_configured = SessionConfiguredEvent { + session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse"), + forked_from_id: None, + thread_name: None, + model: "codex-mini-latest".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: None, }; - let begin = event( - "m1", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-1".to_string(), - invocation: invocation.clone(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: json!({ "key": "value" }), - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + EventProcessorWithJsonOutput::thread_started_event(&session_configured), + ThreadEvent::ThreadStarted(ThreadStartedEvent { + thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + }) + ); +} + +#[test] +fn turn_started_emits_turn_started_event() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = + processor.collect_thread_events(ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::InProgress, + error: None, }, - })] + })); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnStarted(TurnStartedEvent {})], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn command_execution_started_and_completed_translate_to_thread_events() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let command_item = ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::InProgress, + command_actions: Vec::::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: command_item, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } ); - let end = event( - "m2", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-1".to_string(), - invocation, - duration: Duration::from_secs(1), - result: Ok(CallToolResult { - content: Vec::new(), - is_error: None, - structured_content: None, - meta: None, - }), - }), - ); - let end_events = ep.collect_thread_events(&end); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::Completed, + command_actions: Vec::::new(), + aggregated_output: Some("a.txt\n".to_string()), + exit_code: Some(0), + duration_ms: Some(3), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); assert_eq!( - end_events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_a".to_string(), - tool: "tool_x".to_string(), - arguments: json!({ "key": "value" }), - result: Some(McpToolCallItemResult { - content: Vec::new(), - structured_content: None, + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: "a.txt\n".to_string(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, }), - error: None, - status: McpToolCallStatus::Completed, + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn empty_reasoning_items_are_ignored() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: Vec::new(), + content: vec!["raw reasoning".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); +} + +#[test] +fn unsupported_items_do_not_consume_synthetic_ids() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let ignored = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Plan { + id: "plan-1".to_string(), + text: "ignored plan".to_string(), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + ignored, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "message-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { + text: "hello".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn reasoning_items_emit_summary_not_raw_content() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["safe summary".to_string()], + content: vec!["raw reasoning".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Reasoning(ReasoningItem { + text: "safe summary".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn web_search_completion_preserves_query_and_action() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: Some(ApiWebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, }), }, - })] + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: WebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn web_search_start_and_completion_reuse_item_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: String::new(), + action: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: Some(ApiWebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: String::new(), + action: WebSearchAction::Other, + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "rust async await".to_string(), + action: WebSearchAction::Search { + query: Some("rust async await".to_string()), + queries: None, + }, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn mcp_tool_call_begin_and_end_emit_item_events() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: ApiMcpToolCallStatus::InProgress, + arguments: json!({ "key": "value" }), + result: None, + error: None, + duration_ms: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server_a".to_string(), + tool: "tool_x".to_string(), + status: ApiMcpToolCallStatus::Completed, + arguments: json!({ "key": "value" }), + result: Some(McpToolCallResult { + content: Vec::new(), + structured_content: None, + }), + error: None, + duration_ms: Some(1_000), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: json!({ "key": "value" }), + result: None, + error: None, + status: McpToolCallStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_a".to_string(), + tool: "tool_x".to_string(), + arguments: json!({ "key": "value" }), + result: Some(McpToolCallItemResult { + content: Vec::new(), + structured_content: None, + }), + error: None, + status: McpToolCallStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn mcp_tool_call_failure_sets_failed_status() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_b".to_string(), - tool: "tool_y".to_string(), - arguments: Some(json!({ "param": 42 })), - }; + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "m3", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-2".to_string(), - invocation: invocation.clone(), - }), - ); - ep.collect_thread_events(&begin); - - let end = event( - "m4", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-2".to_string(), - invocation, - duration: Duration::from_millis(5), - result: Err("tool exploded".to_string()), - }), - ); - let events = ep.collect_thread_events(&end); - assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_b".to_string(), - tool: "tool_y".to_string(), - arguments: json!({ "param": 42 }), - result: None, - error: Some(McpToolCallItemError { - message: "tool exploded".to_string(), - }), - status: McpToolCallStatus::Failed, + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server_b".to_string(), + tool: "tool_y".to_string(), + status: ApiMcpToolCallStatus::Failed, + arguments: json!({ "param": 42 }), + result: None, + error: Some(McpToolCallError { + message: "tool exploded".to_string(), }), + duration_ms: Some(5), }, - })] + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_b".to_string(), + tool: "tool_y".to_string(), + arguments: json!({ "param": 42 }), + result: None, + error: Some(McpToolCallItemError { + message: "tool exploded".to_string(), + }), + status: McpToolCallStatus::Failed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let invocation = McpInvocation { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: None, - }; + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "m5", - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: "call-3".to_string(), - invocation: invocation.clone(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); - assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: serde_json::Value::Null, - result: None, - error: None, - status: McpToolCallStatus::InProgress, - }), + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-3".to_string(), + server: "server_c".to_string(), + tool: "tool_z".to_string(), + status: ApiMcpToolCallStatus::InProgress, + arguments: serde_json::Value::Null, + result: None, + error: None, + duration_ms: None, }, - })] - ); + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::McpToolCall { + id: "mcp-3".to_string(), + server: "server_c".to_string(), + tool: "tool_z".to_string(), + status: ApiMcpToolCallStatus::Completed, + arguments: serde_json::Value::Null, + result: Some(McpToolCallResult { + content: vec![json!({ + "type": "text", + "text": "done", + })], + structured_content: Some(json!({ "status": "ok" })), + }), + error: None, + duration_ms: Some(10), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); - let end = event( - "m6", - EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: "call-3".to_string(), - invocation, - duration: Duration::from_millis(10), - result: Ok(CallToolResult { - content: vec![serde_json::to_value(Content::text("done")).unwrap()], - is_error: None, - structured_content: Some(json!({ "status": "ok" })), - meta: None, - }), - }), - ); - let events = ep.collect_thread_events(&end); assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::McpToolCall(McpToolCallItem { - server: "server_c".to_string(), - tool: "tool_z".to_string(), - arguments: serde_json::Value::Null, - result: Some(McpToolCallItemResult { - content: vec![serde_json::to_value(Content::text("done")).unwrap()], - structured_content: Some(json!({ "status": "ok" })), + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_c".to_string(), + tool: "tool_z".to_string(), + arguments: serde_json::Value::Null, + result: None, + error: None, + status: McpToolCallStatus::InProgress, }), - error: None, - status: McpToolCallStatus::Completed, - }), - }, - })] + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::McpToolCall(McpToolCallItem { + server: "server_c".to_string(), + tool: "tool_z".to_string(), + arguments: serde_json::Value::Null, + result: Some(McpToolCallItemResult { + content: vec![json!({ + "type": "text", + "text": "done", + })], + structured_content: Some(json!({ "status": "ok" })), + }), + error: None, + status: McpToolCallStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn collab_spawn_begin_and_end_emit_item_events() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let new_thread_id = ThreadId::from_string("9e107d9d-372b-4b8c-a2a4-1d9bb3fce0c1").unwrap(); - let prompt = "draft a plan".to_string(); + let mut processor = EventProcessorWithJsonOutput::new(None); - let begin = event( - "c1", - EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent { - call_id: "call-10".to_string(), - sender_thread_id, - prompt: prompt.clone(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::default(), - }), - ); - let begin_events = ep.collect_thread_events(&begin); - assert_eq!( - begin_events, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::SpawnAgent, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids: Vec::new(), - prompt: Some(prompt.clone()), - agents_states: std::collections::HashMap::new(), - status: CollabToolCallStatus::InProgress, - }), + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: ApiCollabAgentToolCallStatus::InProgress, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("draft a plan".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: std::collections::HashMap::new(), }, - })] - ); - - let end = event( - "c2", - EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { - call_id: "call-10".to_string(), - sender_thread_id, - new_thread_id: Some(new_thread_id), - new_agent_nickname: None, - new_agent_role: None, - prompt: prompt.clone(), - model: "gpt-5".to_string(), - reasoning_effort: ReasoningEffortConfig::default(), - status: AgentStatus::Running, - }), - ); - let end_events = ep.collect_thread_events(&end); - assert_eq!( - end_events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::SpawnAgent, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids: vec![new_thread_id.to_string()], - prompt: Some(prompt), - agents_states: [( - new_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Running, - message: None, - }, - )] - .into_iter() - .collect(), - status: CollabToolCallStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn collab_wait_end_without_begin_synthesizes_failed_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let sender_thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); - let running_thread_id = ThreadId::from_string("3f76d2a0-943e-4f43-8a38-b289c9c6c3d1").unwrap(); - let failed_thread_id = ThreadId::from_string("c1dfd96e-1f0c-4f26-9b4f-1aa02c2d3c4d").unwrap(); - let mut receiver_thread_ids = vec![running_thread_id.to_string(), failed_thread_id.to_string()]; - receiver_thread_ids.sort(); - let mut statuses = std::collections::HashMap::new(); - statuses.insert( - running_thread_id, - AgentStatus::Completed(Some("done".to_string())), - ); - statuses.insert(failed_thread_id, AgentStatus::Errored("boom".to_string())); - - let end = event( - "c3", - EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { - sender_thread_id, - call_id: "call-11".to_string(), - agent_statuses: Vec::new(), - statuses: statuses.clone(), - }), - ); - let events = ep.collect_thread_events(&end); - assert_eq!( - events, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { - tool: CollabTool::Wait, - sender_thread_id: sender_thread_id.to_string(), - receiver_thread_ids, - prompt: None, - agents_states: [ - ( - running_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Completed, - message: Some("done".to_string()), - }, - ), - ( - failed_thread_id.to_string(), - CollabAgentState { - status: CollabAgentStatus::Errored, - message: Some("boom".to_string()), - }, - ), - ] - .into_iter() - .collect(), - status: CollabToolCallStatus::Failed, - }), - }, - })] - ); -} - -#[test] -fn plan_update_after_complete_starts_new_todo_list_with_new_id() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First turn: start + complete - let start = event( - "t1", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![PlanItemArg { - step: "only".to_string(), - status: StepStatus::Pending, - }], - }), - ); - let _ = ep.collect_thread_events(&start); - let complete = event( - "t2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { + thread_id: "thread-parent".to_string(), turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - let _ = ep.collect_thread_events(&complete); - - // Second turn: a new todo list should have a new id - let start_again = event( - "t3", - EventMsg::PlanUpdate(UpdatePlanArgs { - explanation: None, - plan: vec![PlanItemArg { - step: "again".to_string(), - status: StepStatus::Pending, - }], - }), - ); - let out = ep.collect_thread_events(&start_again); - - match &out[0] { - ThreadEvent::ItemStarted(ItemStartedEvent { item }) => { - assert_eq!(&item.id, "item_1"); - } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn agent_reasoning_produces_item_completed_reasoning() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let ev = event( - "e1", - EventMsg::AgentReasoning(AgentReasoningEvent { - text: "thinking...".to_string(), - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::Reasoning(ReasoningItem { - text: "thinking...".to_string(), - }), + })); + let completed = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: ApiCollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("draft a plan".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: std::collections::HashMap::from([( + "thread-child".to_string(), + ApiCollabAgentState { + status: ApiCollabAgentStatus::Running, + message: None, + }, + )]), }, - })] - ); -} - -#[test] -fn agent_message_produces_item_completed_agent_message() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let ev = event( - "e1", - EventMsg::AgentMessage(AgentMessageEvent { - message: "hello".to_string(), - phase: None, - memory_citation: None, - }), - ); - let out = ep.collect_thread_events(&ev); - assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::AgentMessage(AgentMessageItem { - text: "hello".to_string(), - }), - }, - })] - ); -} - -#[test] -fn error_event_produces_error() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::Error(codex_protocol::protocol::ErrorEvent { - message: "boom".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), + thread_id: "thread-parent".to_string(), + turn_id: "turn-1".to_string(), + }, )); + assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: Vec::new(), + prompt: Some("draft a plan".to_string()), + agents_states: std::collections::HashMap::new(), + status: CollabToolCallStatus::InProgress, + },), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CollabToolCall(CollabToolCallItem { + tool: CollabTool::SpawnAgent, + sender_thread_id: "thread-parent".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("draft a plan".to_string()), + agents_states: std::collections::HashMap::from([( + "thread-child".to_string(), + CollabAgentState { + status: CollabAgentStatus::Running, + message: None, + }, + )]), + status: CollabToolCallStatus::Completed, + },), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn file_change_completion_maps_change_kinds() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![ + ApiFileUpdateChange { + path: "a/added.txt".to_string(), + kind: ApiPatchChangeKind::Add, + diff: String::new(), + }, + ApiFileUpdateChange { + path: "b/deleted.txt".to_string(), + kind: ApiPatchChangeKind::Delete, + diff: String::new(), + }, + ApiFileUpdateChange { + path: "c/modified.txt".to_string(), + kind: ApiPatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@".to_string(), + }, + ], + status: ApiPatchApplyStatus::Completed, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: vec![ + ExecFileUpdateChange { + path: "a/added.txt".to_string(), + kind: PatchChangeKind::Add, + }, + ExecFileUpdateChange { + path: "b/deleted.txt".to_string(), + kind: PatchChangeKind::Delete, + }, + ExecFileUpdateChange { + path: "c/modified.txt".to_string(), + kind: PatchChangeKind::Update, + }, + ], + status: PatchApplyStatus::Completed, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn file_change_declined_maps_to_failed_status() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::FileChange { + id: "patch-2".to_string(), + changes: vec![ApiFileUpdateChange { + path: "file.txt".to_string(), + kind: ApiPatchChangeKind::Update { move_path: None }, + diff: "@@ -1 +1 @@".to_string(), + }], + status: ApiPatchApplyStatus::Declined, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::FileChange(FileChangeItem { + changes: vec![ExecFileUpdateChange { + path: "file.txt".to_string(), + kind: PatchChangeKind::Update, + }], + status: PatchApplyStatus::Failed, + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn agent_message_item_updates_final_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::AgentMessage(AgentMessageItem { + text: "hello".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); + assert_eq!(processor.final_message(), Some("hello")); +} + +#[test] +fn agent_message_item_started_is_ignored() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + + assert_eq!( + collected, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); +} + +#[test] +fn reasoning_item_completed_uses_synthetic_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "rs-1".to_string(), + summary: vec!["thinking...".to_string()], + content: vec!["raw".to_string()], + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Reasoning(ReasoningItem { + text: "thinking...".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } ); } #[test] fn warning_event_produces_error_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::Warning(WarningEvent { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), - }), + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_warning( + "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), + ); + + assert_eq!( + collected, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::Error(ErrorItem { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn plan_update_emits_started_then_updated_then_completed() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![ + TurnPlanStep { + step: "step one".to_string(), + status: TurnPlanStepStatus::Pending, + }, + TurnPlanStep { + step: "step two".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + ], + }, )); assert_eq!( - out, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::Error(ErrorItem { - message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(), - }), - }, - })] + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: false, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + })], + status: CodexStatus::Running, + } ); -} -#[test] -fn stream_error_event_produces_error() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let out = ep.collect_thread_events(&event( - "e1", - EventMsg::StreamError(codex_protocol::protocol::StreamErrorEvent { - message: "retrying".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - additional_details: None, - }), + let updated = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![ + TurnPlanStep { + step: "step one".to_string(), + status: TurnPlanStepStatus::Completed, + }, + TurnPlanStep { + step: "step two".to_string(), + status: TurnPlanStepStatus::InProgress, + }, + ], + }, )); assert_eq!( - out, - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "retrying".to_string(), - })] - ); -} - -#[test] -fn error_followed_by_task_complete_produces_turn_failed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - let error_event = event( - "e1", - EventMsg::Error(ErrorEvent { - message: "boom".to_string(), - codex_error_info: Some(CodexErrorInfo::Other), - }), - ); - assert_eq!( - ep.collect_thread_events(&error_event), - vec![ThreadEvent::Error(ThreadErrorEvent { - message: "boom".to_string(), - })] - ); - - let complete_event = event( - "e2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: None, - }), - ); - assert_eq!( - ep.collect_thread_events(&complete_event), - vec![ThreadEvent::TurnFailed(TurnFailedEvent { - error: ThreadErrorEvent { - message: "boom".to_string(), - }, - })] - ); -} - -#[test] -fn exec_command_end_success_produces_completed_command_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec!["bash".to_string(), "-lc".to_string(), "echo hi".to_string()]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - // Begin -> no output - let begin = event( - "c1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "1".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert_eq!( - out_begin, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo hi'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - // End (success) -> item.completed (item_0) - let end_ok = event( - "c2", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "1".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: "hi\n".to_string(), - exit_code: 0, - duration: Duration::from_millis(5), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out_ok = ep.collect_thread_events(&end_ok); - assert_eq!( - out_ok, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo hi'".to_string(), - aggregated_output: "hi\n".to_string(), - exit_code: Some(0), - status: CommandExecutionStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn command_execution_output_delta_updates_item_progress() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "echo delta".to_string(), - ]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - let begin = event( - "d1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "delta-1".to_string(), - process_id: Some("42".to_string()), - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert_eq!( - out_begin, - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo delta'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - let delta = event( - "d2", - EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { - call_id: "delta-1".to_string(), - stream: ExecOutputStream::Stdout, - chunk: b"partial output\n".to_vec(), - }), - ); - let out_delta = ep.collect_thread_events(&delta); - assert_eq!(out_delta, Vec::::new()); - - let end = event( - "d3", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "delta-1".to_string(), - process_id: Some("42".to_string()), - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: Duration::from_millis(3), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!( - out_end, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "bash -lc 'echo delta'".to_string(), - aggregated_output: String::new(), - exit_code: Some(0), - status: CommandExecutionStatus::Completed, - }), - }, - })] - ); -} - -#[test] -fn exec_command_end_failure_produces_failed_command_item() { - let mut ep = EventProcessorWithJsonOutput::new(None); - let command = vec!["sh".to_string(), "-c".to_string(), "exit 1".to_string()]; - let cwd = std::env::current_dir().unwrap(); - let parsed_cmd = Vec::new(); - - // Begin -> no output - let begin = event( - "c1", - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: "2".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: command.clone(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.clone(), - source: ExecCommandSource::Agent, - interaction_input: None, - }), - ); - assert_eq!( - ep.collect_thread_events(&begin), - vec![ThreadEvent::ItemStarted(ItemStartedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "sh -c 'exit 1'".to_string(), - aggregated_output: String::new(), - exit_code: None, - status: CommandExecutionStatus::InProgress, - }), - }, - })] - ); - - // End (failure) -> item.completed (item_0) - let end_fail = event( - "c2", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "2".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command, - cwd, - parsed_cmd, - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 1, - duration: Duration::from_millis(2), - formatted_output: String::new(), - status: CoreExecCommandStatus::Failed, - }), - ); - let out_fail = ep.collect_thread_events(&end_fail); - assert_eq!( - out_fail, - vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { - item: ThreadItem { - id: "item_0".to_string(), - details: ThreadItemDetails::CommandExecution(CommandExecutionItem { - command: "sh -c 'exit 1'".to_string(), - aggregated_output: String::new(), - exit_code: Some(1), - status: CommandExecutionStatus::Failed, - }), - }, - })] - ); -} - -#[test] -fn exec_command_end_without_begin_is_ignored() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // End event arrives without a prior Begin; should produce no thread events. - let end_only = event( - "c1", - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: "no-begin".to_string(), - process_id: None, - turn_id: "turn-1".to_string(), - command: Vec::new(), - cwd: PathBuf::from("."), - parsed_cmd: Vec::new(), - source: ExecCommandSource::Agent, - interaction_input: None, - stdout: String::new(), - stderr: String::new(), - aggregated_output: String::new(), - exit_code: 0, - duration: Duration::from_millis(1), - formatted_output: String::new(), - status: CoreExecCommandStatus::Completed, - }), - ); - let out = ep.collect_thread_events(&end_only); - assert!(out.is_empty()); -} - -#[test] -fn patch_apply_success_produces_item_completed_patchapply() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // Prepare a patch with multiple kinds of changes - let mut changes = std::collections::HashMap::new(); - changes.insert( - PathBuf::from("a/added.txt"), - FileChange::Add { - content: "+hello".to_string(), - }, - ); - changes.insert( - PathBuf::from("b/deleted.txt"), - FileChange::Delete { - content: "-goodbye".to_string(), - }, - ); - changes.insert( - PathBuf::from("c/modified.txt"), - FileChange::Update { - unified_diff: "--- c/modified.txt\n+++ c/modified.txt\n@@\n-old\n+new\n".to_string(), - move_path: Some(PathBuf::from("c/renamed.txt")), - }, - ); - - // Begin -> no output - let begin = event( - "p1", - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - auto_approved: true, - changes: changes.clone(), - }), - ); - let out_begin = ep.collect_thread_events(&begin); - assert!(out_begin.is_empty()); - - // End (success) -> item.completed (item_0) - let end = event( - "p2", - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-1".to_string(), - turn_id: "turn-1".to_string(), - stdout: "applied 3 changes".to_string(), - stderr: String::new(), - success: true, - changes: changes.clone(), - status: CorePatchApplyStatus::Completed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!(out_end.len(), 1); - - // Validate structure without relying on HashMap iteration order - match &out_end[0] { - ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { - assert_eq!(&item.id, "item_0"); - match &item.details { - ThreadItemDetails::FileChange(file_update) => { - assert_eq!(file_update.status, PatchApplyStatus::Completed); - - let mut actual: Vec<(String, PatchChangeKind)> = file_update - .changes - .iter() - .map(|c| (c.path.clone(), c.kind.clone())) - .collect(); - actual.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut expected = vec![ - ("a/added.txt".to_string(), PatchChangeKind::Add), - ("b/deleted.txt".to_string(), PatchChangeKind::Delete), - ("c/modified.txt".to_string(), PatchChangeKind::Update), - ]; - expected.sort_by(|a, b| a.0.cmp(&b.0)); - - assert_eq!(actual, expected); - } - other => panic!("unexpected details: {other:?}"), - } + updated, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: true, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + })], + status: CodexStatus::Running, } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn patch_apply_failure_produces_item_completed_patchapply_failed() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - let mut changes = std::collections::HashMap::new(); - changes.insert( - PathBuf::from("file.txt"), - FileChange::Update { - unified_diff: "--- file.txt\n+++ file.txt\n@@\n-old\n+new\n".to_string(), - move_path: None, - }, ); - // Begin -> no output - let begin = event( - "p1", - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - auto_approved: false, - changes: changes.clone(), - }), - ); - assert!(ep.collect_thread_events(&begin).is_empty()); - - // End (failure) -> item.completed (item_0) with Failed status - let end = event( - "p2", - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: "call-2".to_string(), - turn_id: "turn-2".to_string(), - stdout: String::new(), - stderr: "failed to apply".to_string(), - success: false, - changes: changes.clone(), - status: CorePatchApplyStatus::Failed, - }), - ); - let out_end = ep.collect_thread_events(&end); - assert_eq!(out_end.len(), 1); - - match &out_end[0] { - ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) => { - assert_eq!(&item.id, "item_0"); - match &item.details { - ThreadItemDetails::FileChange(file_update) => { - assert_eq!(file_update.status, PatchApplyStatus::Failed); - assert_eq!(file_update.changes.len(), 1); - assert_eq!(file_update.changes[0].path, "file.txt".to_string()); - assert_eq!(file_update.changes[0].kind, PatchChangeKind::Update); - } - other => panic!("unexpected details: {other:?}"), - } - } - other => panic!("unexpected event: {other:?}"), - } -} - -#[test] -fn task_complete_produces_turn_completed_with_usage() { - let mut ep = EventProcessorWithJsonOutput::new(None); - - // First, feed a TokenCount event with known totals. - let usage = codex_protocol::protocol::TokenUsage { - input_tokens: 1200, - cached_input_tokens: 200, - output_tokens: 345, - reasoning_output_tokens: 0, - total_tokens: 0, - }; - let info = codex_protocol::protocol::TokenUsageInfo { - total_token_usage: usage.clone(), - last_token_usage: usage, - model_context_window: None, - }; - let token_count_event = event( - "e1", - EventMsg::TokenCount(codex_protocol::protocol::TokenCountEvent { - info: Some(info), - rate_limits: None, - }), - ); - assert!(ep.collect_thread_events(&token_count_event).is_empty()); - - // Then TurnComplete should produce turn.completed with the captured usage. - let complete_event = event( - "e2", - EventMsg::TurnComplete(codex_protocol::protocol::TurnCompleteEvent { - turn_id: "turn-1".to_string(), - last_agent_message: Some("done".to_string()), - }), - ); - let out = ep.collect_thread_events(&complete_event); - assert_eq!( - out, - vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { - usage: Usage { - input_tokens: 1200, - cached_input_tokens: 200, - output_tokens: 345, + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, }, - })] + }, + )); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![ + TodoItem { + text: "step one".to_string(), + completed: true, + }, + TodoItem { + text: "step two".to_string(), + completed: false, + }, + ], + }), + }, + }), + ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn plan_update_after_completion_starts_new_todo_list_with_new_id() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let _ = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + explanation: None, + plan: vec![TurnPlanStep { + step: "only".to_string(), + status: TurnPlanStepStatus::Pending, + }], + }, + )); + let _ = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + let restarted = processor.collect_thread_events(ServerNotification::TurnPlanUpdated( + TurnPlanUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + explanation: None, + plan: vec![TurnPlanStep { + step: "again".to_string(), + status: TurnPlanStepStatus::Pending, + }], + }, + )); + + assert_eq!( + restarted, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_1".to_string(), + details: ThreadItemDetails::TodoList(TodoListItem { + items: vec![TodoItem { + text: "again".to_string(), + completed: false, + }], + }), + }, + })], + status: CodexStatus::Running, + } + ); +} + +#[test] +fn token_usage_update_is_emitted_on_turn_completion() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let usage_update = + processor.collect_thread_events(ServerNotification::ThreadTokenUsageUpdated( + codex_app_server_protocol::ThreadTokenUsageUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + token_usage: ThreadTokenUsage { + total: TokenUsageBreakdown { + total_tokens: 42, + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + reasoning_output_tokens: 7, + }, + last: TokenUsageBreakdown { + total_tokens: 42, + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + reasoning_output_tokens: 7, + }, + model_context_window: Some(128_000), + }, + }, + )); + assert_eq!( + usage_update, + CollectedThreadEvents { + events: Vec::new(), + status: CodexStatus::Running, + } + ); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage { + input_tokens: 10, + cached_input_tokens: 3, + output_tokens: 29, + }, + })], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn turn_completion_recovers_final_message_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("final answer")); +} + +#[test] +fn turn_completion_reconciles_started_items_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let started = + processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification { + item: ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::InProgress, + command_actions: Vec::::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + started, + CollectedThreadEvents { + events: vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })], + status: CodexStatus::Running, + } + ); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "ls".to_string(), + cwd: PathBuf::from("/tmp/project"), + process_id: Some("123".to_string()), + source: CommandExecutionSource::UserShell, + status: ApiCommandExecutionStatus::Completed, + command_actions: Vec::::new(), + aggregated_output: Some("a.txt\n".to_string()), + exit_code: Some(0), + duration_ms: Some(3), + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ + ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ExecThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "ls".to_string(), + aggregated_output: "a.txt\n".to_string(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + }), + ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + }), + ], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn turn_completion_overwrites_stale_final_message_from_turn_items() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-stale".to_string(), + text: "stale answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "final answer".to_string(), + phase: None, + memory_citation: None, + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("final answer")); +} + +#[test] +fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() { + let mut processor = EventProcessorWithJsonOutput::new(None); + let _ = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-streamed".to_string(), + text: "streamed answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("streamed answer")); +} + +#[test] +fn failed_turn_clears_stale_final_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: "msg-1".to_string(), + text: "partial answer".to_string(), + phase: None, + memory_citation: None, + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(processor.final_message(), Some("partial answer")); + + let collected = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(TurnError { + message: "turn failed".to_string(), + additional_details: None, + codex_error_info: None, + }), + }, + }, + )); + + assert_eq!(collected.status, CodexStatus::InitiateShutdown); + assert_eq!(processor.final_message(), None); +} + +#[test] +fn turn_completion_falls_back_to_final_plan_text() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let completed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::Plan { + id: "plan-1".to_string(), + text: "ship the typed adapter".to_string(), + }], + status: TurnStatus::Completed, + error: None, + }, + }, + )); + + assert_eq!( + completed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent { + usage: Usage::default(), + })], + status: CodexStatus::InitiateShutdown, + } + ); + assert_eq!(processor.final_message(), Some("ship the typed adapter")); +} + +#[test] +fn turn_failure_prefers_structured_error_message() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let error = processor.collect_thread_events(ServerNotification::Error(ErrorNotification { + error: TurnError { + message: "backend failed".to_string(), + codex_error_info: None, + additional_details: Some("request id abc".to_string()), + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + })); + assert_eq!( + error, + CollectedThreadEvents { + events: vec![ThreadEvent::Error(ThreadErrorEvent { + message: "backend failed (request id abc)".to_string(), + })], + status: CodexStatus::Running, + } + ); + + let failed = processor.collect_thread_events(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: None, + }, + }, + )); + assert_eq!( + failed, + CollectedThreadEvents { + events: vec![ThreadEvent::TurnFailed(TurnFailedEvent { + error: ThreadErrorEvent { + message: "backend failed (request id abc)".to_string(), + }, + })], + status: CodexStatus::InitiateShutdown, + } + ); +} + +#[test] +fn model_reroute_surfaces_as_error_item() { + let mut processor = EventProcessorWithJsonOutput::new(None); + + let collected = processor.collect_thread_events(ServerNotification::ModelRerouted( + codex_app_server_protocol::ModelReroutedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + from_model: "gpt-5".to_string(), + to_model: "gpt-5-mini".to_string(), + reason: codex_app_server_protocol::ModelRerouteReason::HighRiskCyberActivity, + }, + )); + + assert_eq!(collected.status, CodexStatus::Running); + assert_eq!(collected.events.len(), 1); + let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else { + panic!("expected ItemCompleted"); + }; + assert_eq!(item.id, "item_0"); + assert_eq!( + item.details, + ThreadItemDetails::Error(ErrorItem { + message: "model rerouted: gpt-5 -> gpt-5-mini (HighRiskCyberActivity)".to_string(), + }) ); } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f49428cec5..ccde6a5e54 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -142,6 +142,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable the tool_search tool for apps. + ToolSearch, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -714,24 +716,26 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Apps, key: "apps", - stage: Stage::Experimental { - name: "Apps", - menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.", - announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!", - }, + stage: Stage::Stable, + default_enabled: true, + }, + FeatureSpec { + id: Feature::ToolSearch, + key: "tool_search", + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Plugins, key: "plugins", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::ImageGeneration, @@ -782,8 +786,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolCallMcpElicitation, key: "tool_call_mcp_elicitation", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index faf02b083e..ecfa87b41b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -115,9 +115,15 @@ fn request_permissions_tool_is_under_development() { } #[test] -fn tool_suggest_is_under_development() { - assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::ToolSuggest.default_enabled(), false); +fn tool_suggest_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); + assert_eq!(Feature::ToolSuggest.default_enabled(), true); +} + +#[test] +fn tool_search_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSearch.default_enabled(), false); } #[test] @@ -138,6 +144,12 @@ fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.default_enabled(), false); } +#[test] +fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable); + assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); +} + #[test] fn image_detail_original_feature_is_under_development() { assert_eq!( diff --git a/codex-rs/git-utils/BUILD.bazel b/codex-rs/git-utils/BUILD.bazel new file mode 100644 index 0000000000..346fd3f8f4 --- /dev/null +++ b/codex-rs/git-utils/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "git-utils", + crate_name = "codex_git_utils", +) diff --git a/codex-rs/utils/git/Cargo.toml b/codex-rs/git-utils/Cargo.toml similarity index 74% rename from codex-rs/utils/git/Cargo.toml rename to codex-rs/git-utils/Cargo.toml index ef673644a7..7ecc72dfa9 100644 --- a/codex-rs/utils/git/Cargo.toml +++ b/codex-rs/git-utils/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "codex-git" +name = "codex-git-utils" version.workspace = true edition.workspace = true license.workspace = true @@ -9,12 +9,15 @@ readme = "README.md" workspace = true [dependencies] +codex-utils-absolute-path = { workspace = true } +futures = { workspace = true, features = ["alloc"] } once_cell = { workspace = true } regex = "1" schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } tempfile = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "process", "rt", "time"] } ts-rs = { workspace = true, features = [ "uuid-impl", "serde-json-impl", diff --git a/codex-rs/utils/git/README.md b/codex-rs/git-utils/README.md similarity index 95% rename from codex-rs/utils/git/README.md rename to codex-rs/git-utils/README.md index 5bcc6dcc33..bd24b72b7a 100644 --- a/codex-rs/utils/git/README.md +++ b/codex-rs/git-utils/README.md @@ -1,4 +1,4 @@ -# codex-git +# codex-git-utils Helpers for interacting with git, including patch application and worktree snapshot utilities. @@ -6,7 +6,7 @@ snapshot utilities. ```rust,no_run use std::path::Path; -use codex_git::{ +use codex_git_utils::{ apply_git_patch, create_ghost_commit, restore_ghost_commit, ApplyGitRequest, CreateGhostCommitOptions, }; diff --git a/codex-rs/utils/git/src/apply.rs b/codex-rs/git-utils/src/apply.rs similarity index 100% rename from codex-rs/utils/git/src/apply.rs rename to codex-rs/git-utils/src/apply.rs diff --git a/codex-rs/utils/git/src/branch.rs b/codex-rs/git-utils/src/branch.rs similarity index 100% rename from codex-rs/utils/git/src/branch.rs rename to codex-rs/git-utils/src/branch.rs diff --git a/codex-rs/utils/git/src/errors.rs b/codex-rs/git-utils/src/errors.rs similarity index 100% rename from codex-rs/utils/git/src/errors.rs rename to codex-rs/git-utils/src/errors.rs diff --git a/codex-rs/utils/git/src/ghost_commits.rs b/codex-rs/git-utils/src/ghost_commits.rs similarity index 100% rename from codex-rs/utils/git/src/ghost_commits.rs rename to codex-rs/git-utils/src/ghost_commits.rs diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/git-utils/src/info.rs similarity index 96% rename from codex-rs/core/src/git_info.rs rename to codex-rs/git-utils/src/info.rs index 052f786bfa..06e39b5655 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -4,15 +4,17 @@ use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; -use crate::util::resolve_path; -use codex_app_server_protocol::GitSha; -use codex_protocol::protocol::GitInfo; +use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::join_all; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use tokio::process::Command; use tokio::time::Duration as TokioDuration; use tokio::time::timeout; +use ts_rs::TS; + +use crate::GitSha; /// Return `true` if the project folder specified by the `Config` is inside a /// Git repository. @@ -38,6 +40,19 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option { /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +pub struct GitInfo { + /// Current commit hash (SHA) + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hash: Option, + /// Current branch name + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// Repository URL (if available from remote) + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_url: Option, +} + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GitDiffToRemote { pub sha: GitSha, @@ -77,7 +92,7 @@ pub async fn collect_git_info(cwd: &Path) -> Option { && output.status.success() && let Ok(hash) = String::from_utf8(output.stdout) { - git_info.commit_hash = Some(hash.trim().to_string()); + git_info.commit_hash = Some(GitSha::new(hash.trim())); } // Process branch name @@ -127,7 +142,7 @@ pub async fn get_git_remote_urls_assume_git_repo(cwd: &Path) -> Option Option { +pub async fn get_head_commit_hash(cwd: &Path) -> Option { let output = run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd).await?; if !output.status.success() { return None; @@ -138,7 +153,7 @@ pub async fn get_head_commit_hash(cwd: &Path) -> Option { if hash.is_empty() { None } else { - Some(hash.to_string()) + Some(GitSha::new(hash)) } } @@ -616,7 +631,11 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option { return None; } - let git_dir_path = canonicalize_or_raw(resolve_path(&repo_root, &PathBuf::from(git_dir_rel))); + let git_dir_path = canonicalize_or_raw( + AbsolutePathBuf::resolve_path_against_base(git_dir_rel, &repo_root) + .ok()? + .into_path_buf(), + ); let worktrees_dir = git_dir_path.parent()?; if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) { return None; @@ -689,7 +708,3 @@ pub async fn current_branch_name(cwd: &Path) -> Option { .map(|s| s.trim().to_string()) .filter(|name| !name.is_empty()) } - -#[cfg(test)] -#[path = "git_info_tests.rs"] -mod tests; diff --git a/codex-rs/utils/git/src/lib.rs b/codex-rs/git-utils/src/lib.rs similarity index 77% rename from codex-rs/utils/git/src/lib.rs rename to codex-rs/git-utils/src/lib.rs index c4db10fc69..8f188e4bf0 100644 --- a/codex-rs/utils/git/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -5,6 +5,7 @@ mod apply; mod branch; mod errors; mod ghost_commits; +mod info; mod operations; mod platform; @@ -28,6 +29,21 @@ pub use ghost_commits::create_ghost_commit_with_report; pub use ghost_commits::restore_ghost_commit; pub use ghost_commits::restore_ghost_commit_with_options; pub use ghost_commits::restore_to_commit; +pub use info::CommitLogEntry; +pub use info::GitDiffToRemote; +pub use info::GitInfo; +pub use info::collect_git_info; +pub use info::current_branch_name; +pub use info::default_branch_name; +pub use info::get_git_remote_urls; +pub use info::get_git_remote_urls_assume_git_repo; +pub use info::get_git_repo_root; +pub use info::get_has_changes; +pub use info::get_head_commit_hash; +pub use info::git_diff_to_remote; +pub use info::local_git_branches; +pub use info::recent_commits; +pub use info::resolve_root_git_project_for_trust; pub use platform::create_symlink; use schemars::JsonSchema; use serde::Deserialize; @@ -36,6 +52,17 @@ use ts_rs::TS; type CommitID = String; +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "string")] +pub struct GitSha(pub String); + +impl GitSha { + pub fn new(sha: &str) -> Self { + Self(sha.to_string()) + } +} + /// Details of a ghost commit created from a repository state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] pub struct GhostCommit { diff --git a/codex-rs/utils/git/src/operations.rs b/codex-rs/git-utils/src/operations.rs similarity index 100% rename from codex-rs/utils/git/src/operations.rs rename to codex-rs/git-utils/src/operations.rs diff --git a/codex-rs/utils/git/src/platform.rs b/codex-rs/git-utils/src/platform.rs similarity index 100% rename from codex-rs/utils/git/src/platform.rs rename to codex-rs/git-utils/src/platform.rs diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json new file mode 100644 index 0000000000..86dfac165c --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.input.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + }, + "PreToolUseToolInput": { + "additionalProperties": false, + "properties": { + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "type": "object" + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "hook_event_name": { + "const": "PreToolUse", + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tool_input": { + "$ref": "#/definitions/PreToolUseToolInput" + }, + "tool_name": { + "const": "Bash", + "type": "string" + }, + "tool_use_id": { + "type": "string" + }, + "transcript_path": { + "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" + } + }, + "required": [ + "cwd", + "hook_event_name", + "model", + "permission_mode", + "session_id", + "tool_input", + "tool_name", + "tool_use_id", + "transcript_path", + "turn_id" + ], + "title": "pre-tool-use.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json new file mode 100644 index 0000000000..0992983fe4 --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "HookEventNameWire": { + "enum": [ + "PreToolUse", + "SessionStart", + "UserPromptSubmit", + "Stop" + ], + "type": "string" + }, + "PreToolUseDecisionWire": { + "enum": [ + "approve", + "block" + ], + "type": "string" + }, + "PreToolUseHookSpecificOutputWire": { + "additionalProperties": false, + "properties": { + "additionalContext": { + "default": null, + "type": "string" + }, + "hookEventName": { + "$ref": "#/definitions/HookEventNameWire" + }, + "permissionDecision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUsePermissionDecisionWire" + } + ], + "default": null + }, + "permissionDecisionReason": { + "default": null, + "type": "string" + }, + "updatedInput": { + "default": null + } + }, + "required": [ + "hookEventName" + ], + "type": "object" + }, + "PreToolUsePermissionDecisionWire": { + "enum": [ + "allow", + "deny", + "ask" + ], + "type": "string" + } + }, + "properties": { + "continue": { + "default": true, + "type": "boolean" + }, + "decision": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseDecisionWire" + } + ], + "default": null + }, + "hookSpecificOutput": { + "allOf": [ + { + "$ref": "#/definitions/PreToolUseHookSpecificOutputWire" + } + ], + "default": null + }, + "reason": { + "default": null, + "type": "string" + }, + "stopReason": { + "default": null, + "type": "string" + }, + "suppressOutput": { + "default": false, + "type": "boolean" + }, + "systemMessage": { + "default": null, + "type": "string" + } + }, + "title": "pre-tool-use.command.output", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 292777ff67..f44928983d 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -4,6 +4,7 @@ "definitions": { "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index c6935aa6da..27878752c1 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -10,6 +10,7 @@ }, "HookEventNameWire": { "enum": [ + "PreToolUse", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 0d9357e392..1a2d962bc8 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -8,6 +8,8 @@ pub(crate) struct HooksFile { #[derive(Debug, Default, Deserialize)] pub(crate) struct HookEvents { + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index db0f38c645..55dfca63cd 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -3,11 +3,12 @@ use std::path::Path; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; -use regex::Regex; use super::ConfiguredHandler; use super::config::HookHandlerConfig; use super::config::HooksFile; +use crate::events::common::matcher_pattern_for_event; +use crate::events::common::validate_matcher_pattern; pub(crate) struct DiscoveryResult { pub handlers: Vec, @@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - } }; + for group in parsed.hooks.pre_tool_use { + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + source_path.as_path(), + codex_protocol::protocol::HookEventName::PreToolUse, + matcher_pattern_for_event( + codex_protocol::protocol::HookEventName::PreToolUse, + group.matcher.as_deref(), + ), + group.hooks, + ); + } + for group in parsed.hooks.session_start { append_group_handlers( &mut handlers, @@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::SessionStart, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::SessionStart, group.matcher.as_deref(), ), @@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::UserPromptSubmit, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::UserPromptSubmit, group.matcher.as_deref(), ), @@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - &mut display_order, source_path.as_path(), codex_protocol::protocol::HookEventName::Stop, - effective_matcher( + matcher_pattern_for_event( codex_protocol::protocol::HookEventName::Stop, group.matcher.as_deref(), ), @@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - DiscoveryResult { handlers, warnings } } -fn effective_matcher( - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, -) -> Option<&str> { - match event_name { - codex_protocol::protocol::HookEventName::SessionStart => matcher, - codex_protocol::protocol::HookEventName::UserPromptSubmit - | codex_protocol::protocol::HookEventName::Stop => None, - } -} - fn append_group_handlers( handlers: &mut Vec, warnings: &mut Vec, @@ -139,7 +144,7 @@ fn append_group_handlers( group_handlers: Vec, ) { if let Some(matcher) = matcher - && let Err(err) = Regex::new(matcher) + && let Err(err) = validate_matcher_pattern(matcher) { warnings.push(format!( "invalid matcher {matcher:?} in {}: {err}", @@ -205,7 +210,7 @@ mod tests { use super::ConfiguredHandler; use super::HookHandlerConfig; use super::append_group_handlers; - use super::effective_matcher; + use crate::events::common::matcher_pattern_for_event; #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { @@ -219,7 +224,7 @@ mod tests { &mut display_order, Path::new("/tmp/hooks.json"), HookEventName::UserPromptSubmit, - effective_matcher(HookEventName::UserPromptSubmit, Some("[")), + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")), vec![HookHandlerConfig::Command { command: "echo hello".to_string(), timeout_sec: None, @@ -242,4 +247,66 @@ mod tests { }] ); } + + #[test] + fn pre_tool_use_keeps_valid_matcher_during_discovery() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + handlers, + vec![ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hello".to_string(), + timeout_sec: 600, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + }] + ); + } + + #[test] + fn pre_tool_use_treats_star_matcher_as_match_all() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + + append_group_handlers( + &mut handlers, + &mut warnings, + &mut display_order, + Path::new("/tmp/hooks.json"), + HookEventName::PreToolUse, + matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")), + vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(handlers[0].matcher.as_deref(), Some("*")); + } } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index e316d9af98..0b29e12fa0 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -14,6 +14,7 @@ use super::CommandShell; use super::ConfiguredHandler; use super::command_runner::CommandRunResult; use super::command_runner::run_command; +use crate::events::common::matches_matcher; #[derive(Debug)] pub(crate) struct ParsedHandler { @@ -30,13 +31,9 @@ pub(crate) fn select_handlers( .iter() .filter(|handler| handler.event_name == event_name) .filter(|handler| match event_name { - HookEventName::SessionStart => match (&handler.matcher, matcher_input) { - (Some(matcher), Some(input)) => regex::Regex::new(matcher) - .map(|regex| regex.is_match(input)) - .unwrap_or(false), - (None, _) => true, - _ => false, - }, + HookEventName::PreToolUse | HookEventName::SessionStart => { + matches_matcher(handler.matcher.as_deref(), matcher_input) + } HookEventName::UserPromptSubmit | HookEventName::Stop => true, }) .cloned() @@ -109,7 +106,9 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { HookEventName::SessionStart => HookScope::Thread, - HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, + HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => { + HookScope::Turn + } } } @@ -172,6 +171,50 @@ mod tests { assert_eq!(selected[1].display_order, 1); } + #[test] + fn pre_tool_use_matches_tool_name() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_star_matcher_matches_all_tools() { + let handlers = vec![ + make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0), + make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1), + ]; + + let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].display_order, 0); + } + + #[test] + fn pre_tool_use_regex_alternation_matches_each_tool_name() { + let handlers = vec![make_handler( + HookEventName::PreToolUse, + Some("Edit|Write"), + "echo same", + 0, + )]; + + let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit")); + let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write")); + let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash")); + + assert_eq!(selected_edit.len(), 1); + assert_eq!(selected_write.len(), 1); + assert_eq!(selected_bash.len(), 0); + } + #[test] fn user_prompt_submit_ignores_matcher() { let handlers = vec![ diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 24ff72990e..f54403b74b 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; use codex_config::ConfigLayerStack; use codex_protocol::protocol::HookRunSummary; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -46,6 +48,7 @@ impl ConfiguredHandler { fn event_name_label(&self) -> &'static str { match self.event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", codex_protocol::protocol::HookEventName::SessionStart => "session-start", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::Stop => "stop", @@ -105,6 +108,10 @@ impl ClaudeHooksEngine { crate::events::session_start::preview(&self.handlers, request) } + pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec { + crate::events::pre_tool_use::preview(&self.handlers, request) + } + pub(crate) async fn run_session_start( &self, request: SessionStartRequest, @@ -113,6 +120,10 @@ impl ClaudeHooksEngine { crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await } + pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await + } + pub(crate) fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index d72ae07155..3fc0e7a0ba 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput { pub additional_context: Option, } +#[derive(Debug, Clone)] +pub(crate) struct PreToolUseOutput { + pub universal: UniversalOutput, + pub block_reason: Option, + pub invalid_reason: Option, +} + #[derive(Debug, Clone)] pub(crate) struct UserPromptSubmitOutput { pub universal: UniversalOutput, @@ -31,6 +38,9 @@ pub(crate) struct StopOutput { use crate::schema::BlockDecisionWire; use crate::schema::HookUniversalOutputWire; +use crate::schema::PreToolUseCommandOutputWire; +use crate::schema::PreToolUseDecisionWire; +use crate::schema::PreToolUsePermissionDecisionWire; use crate::schema::SessionStartCommandOutputWire; use crate::schema::StopCommandOutputWire; use crate::schema::UserPromptSubmitCommandOutputWire; @@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option { }) } +pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option { + let PreToolUseCommandOutputWire { + universal: universal_wire, + decision, + reason, + hook_specific_output, + } = parse_json(stdout)?; + let universal = UniversalOutput::from(universal_wire); + let hook_specific_output = hook_specific_output.as_ref(); + let use_hook_specific_decision = hook_specific_output.is_some_and(|output| { + output.permission_decision.is_some() + || output.permission_decision_reason.is_some() + || output.updated_input.is_some() + || output.additional_context.is_some() + }); + let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| { + if use_hook_specific_decision { + hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output) + } else { + unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref()) + } + }); + let block_reason = if invalid_reason.is_none() { + if use_hook_specific_decision { + hook_specific_output.and_then(|output| match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Deny) => output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason), + _ => None, + }) + } else { + match decision.as_ref() { + Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason), + Some(PreToolUseDecisionWire::Approve) | None => None, + } + } + } else { + None + }; + + Some(PreToolUseOutput { + universal, + block_reason, + invalid_reason, + }) +} + pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option { let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?; let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); @@ -119,3 +177,97 @@ where fn invalid_block_message(event_name: &str) -> String { format!("{event_name} hook returned decision:block without a non-empty reason") } + +fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option { + if !universal.continue_processing { + Some("PreToolUse hook returned unsupported continue:false".to_string()) + } else if universal.stop_reason.is_some() { + Some("PreToolUse hook returned unsupported stopReason".to_string()) + } else if universal.suppress_output { + Some("PreToolUse hook returned unsupported suppressOutput".to_string()) + } else { + None + } +} + +fn unsupported_pre_tool_use_hook_specific_output( + output: &crate::schema::PreToolUseHookSpecificOutputWire, +) -> Option { + if output.updated_input.is_some() { + Some("PreToolUse hook returned unsupported updatedInput".to_string()) + } else if output + .additional_context + .as_deref() + .and_then(trimmed_reason) + .is_some() + { + Some("PreToolUse hook returned unsupported additionalContext".to_string()) + } else { + match output.permission_decision { + Some(PreToolUsePermissionDecisionWire::Allow) => { + Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Ask) => { + Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string()) + } + Some(PreToolUsePermissionDecisionWire::Deny) => { + if output + .permission_decision_reason + .as_deref() + .and_then(trimmed_reason) + .is_none() + { + Some(invalid_pre_tool_use_reason_message()) + } else { + None + } + } + None => { + if output.permission_decision_reason.is_some() { + Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string()) + } else { + None + } + } + } + } +} + +fn unsupported_pre_tool_use_legacy_decision( + decision: Option<&PreToolUseDecisionWire>, + reason: Option<&str>, +) -> Option { + match decision { + Some(PreToolUseDecisionWire::Approve) => { + Some("PreToolUse hook returned unsupported decision:approve".to_string()) + } + Some(PreToolUseDecisionWire::Block) => { + if reason.and_then(trimmed_reason).is_none() { + Some(invalid_block_message("PreToolUse")) + } else { + None + } + } + None => { + if reason.is_some() { + Some("PreToolUse hook returned reason without decision".to_string()) + } else { + None + } + } + } +} + +fn invalid_pre_tool_use_reason_message() -> String { + "PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason" + .to_string() +} + +fn trimmed_reason(reason: &str) -> Option { + let trimmed = reason.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index 2ad54e5062..1a51e59527 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -4,6 +4,8 @@ use serde_json::Value; #[allow(dead_code)] pub(crate) struct GeneratedHookSchemas { + pub pre_tool_use_command_input: Value, + pub pre_tool_use_command_output: Value, pub session_start_command_input: Value, pub session_start_command_output: Value, pub user_prompt_submit_command_input: Value, @@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas { pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas { static SCHEMAS: OnceLock = OnceLock::new(); SCHEMAS.get_or_init(|| GeneratedHookSchemas { + pre_tool_use_command_input: parse_json_schema( + "pre-tool-use.command.input", + include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"), + ), + pre_tool_use_command_output: parse_json_schema( + "pre-tool-use.command.output", + include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"), + ), session_start_command_input: parse_json_schema( "session-start.command.input", include_str!("../../schema/generated/session-start.command.input.schema.json"), @@ -56,6 +66,8 @@ mod tests { fn loads_generated_hook_schemas() { let schemas = generated_hook_schemas(); + assert_eq!(schemas.pre_tool_use_command_input["type"], "object"); + assert_eq!(schemas.pre_tool_use_command_output["type"], "object"); assert_eq!(schemas.session_start_command_input["type"], "object"); assert_eq!(schemas.session_start_command_output["type"], "object"); assert_eq!(schemas.user_prompt_submit_command_input["type"], "object"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index b6358e068a..b4c274bbd3 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -1,4 +1,5 @@ use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; @@ -67,3 +68,113 @@ pub(crate) fn serialization_failure_hook_events( }) .collect() } + +pub(crate) fn matcher_pattern_for_event( + event_name: HookEventName, + matcher: Option<&str>, +) -> Option<&str> { + match event_name { + HookEventName::PreToolUse | HookEventName::SessionStart => matcher, + HookEventName::UserPromptSubmit | HookEventName::Stop => None, + } +} + +pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> { + if is_match_all_matcher(matcher) { + return Ok(()); + } + regex::Regex::new(matcher).map(|_| ()) +} + +pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool { + match matcher { + None => true, + Some(matcher) if is_match_all_matcher(matcher) => true, + Some(matcher) => input + .and_then(|input| { + regex::Regex::new(matcher) + .ok() + .map(|regex| regex.is_match(input)) + }) + .unwrap_or(false), + } +} + +fn is_match_all_matcher(matcher: &str) -> bool { + matcher.is_empty() || matcher == "*" +} + +#[cfg(test)] +mod tests { + use codex_protocol::protocol::HookEventName; + use pretty_assertions::assert_eq; + + use super::matcher_pattern_for_event; + use super::matches_matcher; + use super::validate_matcher_pattern; + + #[test] + fn matcher_omitted_matches_all_occurrences() { + assert!(matches_matcher(None, Some("Bash"))); + assert!(matches_matcher(None, Some("Write"))); + } + + #[test] + fn matcher_star_matches_all_occurrences() { + assert!(matches_matcher(Some("*"), Some("Bash"))); + assert!(matches_matcher(Some("*"), Some("Edit"))); + assert_eq!(validate_matcher_pattern("*"), Ok(())); + } + + #[test] + fn matcher_empty_string_matches_all_occurrences() { + assert!(matches_matcher(Some(""), Some("Bash"))); + assert!(matches_matcher(Some(""), Some("SessionStart"))); + assert_eq!(validate_matcher_pattern(""), Ok(())); + } + + #[test] + fn matcher_uses_regex_matching() { + assert!(matches_matcher(Some("Edit|Write"), Some("Edit"))); + assert!(matches_matcher(Some("Edit|Write"), Some("Write"))); + assert!(!matches_matcher(Some("Edit|Write"), Some("Bash"))); + assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(())); + } + + #[test] + fn matcher_supports_anchored_regexes() { + assert!(matches_matcher(Some("^Bash$"), Some("Bash"))); + assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput"))); + assert_eq!(validate_matcher_pattern("^Bash$"), Ok(())); + } + + #[test] + fn invalid_regex_is_rejected() { + assert!(validate_matcher_pattern("[").is_err()); + assert!(!matches_matcher(Some("["), Some("Bash"))); + } + + #[test] + fn unsupported_events_ignore_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")), + None + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::Stop, Some("^done$")), + None + ); + } + + #[test] + fn supported_events_keep_matchers() { + assert_eq!( + matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")), + Some("Bash") + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")), + Some("startup|resume") + ); + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 3bb54699af..603395eb5a 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -1,4 +1,5 @@ -mod common; +pub(crate) mod common; +pub mod pre_tool_use; pub mod session_start; pub mod stop; pub mod user_prompt_submit; diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs new file mode 100644 index 0000000000..8366bb632c --- /dev/null +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -0,0 +1,479 @@ +use std::path::PathBuf; + +use codex_protocol::ThreadId; +use codex_protocol::protocol::HookCompletedEvent; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::HookRunSummary; + +use super::common; +use crate::engine::CommandShell; +use crate::engine::ConfiguredHandler; +use crate::engine::command_runner::CommandRunResult; +use crate::engine::dispatcher; +use crate::engine::output_parser; +use crate::schema::PreToolUseCommandInput; + +#[derive(Debug, Clone)] +pub struct PreToolUseRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub tool_use_id: String, + pub command: String, +} + +#[derive(Debug)] +pub struct PreToolUseOutcome { + pub hook_events: Vec, + pub should_block: bool, + pub block_reason: Option, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PreToolUseHandlerData { + should_block: bool, + block_reason: Option, +} + +pub(crate) fn preview( + handlers: &[ConfiguredHandler], + request: &PreToolUseRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: PreToolUseRequest, +) -> PreToolUseOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::PreToolUse, + Some(&request.tool_name), + ); + if matched.is_empty() { + return PreToolUseOutcome { + hook_events: Vec::new(), + should_block: false, + block_reason: None, + }; + } + + let input_json = match serde_json::to_string(&PreToolUseCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "PreToolUse".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + tool_name: "Bash".to_string(), + tool_input: crate::schema::PreToolUseToolInput { + command: request.command.clone(), + }, + tool_use_id: request.tool_use_id.clone(), + }) { + Ok(input_json) => input_json, + Err(error) => { + return serialization_failure_outcome(common::serialization_failure_hook_events( + matched, + Some(request.turn_id), + format!("failed to serialize pre tool use hook input: {error}"), + )); + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_completed, + ) + .await; + + let should_block = results.iter().any(|result| result.data.should_block); + let block_reason = results + .iter() + .find_map(|result| result.data.block_reason.clone()); + + PreToolUseOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + should_block, + block_reason, + } +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + let mut should_block = false; + let mut block_reason = None; + + match run_result.error.as_deref() { + Some(error) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: error.to_string(), + }); + } + None => match run_result.exit_code { + Some(0) => { + let trimmed_stdout = run_result.stdout.trim(); + if trimmed_stdout.is_empty() { + } else if let Some(parsed) = output_parser::parse_pre_tool_use(&run_result.stdout) { + if let Some(system_message) = parsed.universal.system_message { + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: system_message, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } else if let Some(reason) = parsed.block_reason { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } + } else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }); + } + } + Some(2) => { + if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) { + status = HookRunStatus::Blocked; + should_block = true; + block_reason = Some(reason.clone()); + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: reason, + }); + } else { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook exited with code 2 but did not write a blocking reason to stderr".to_string(), + }); + } + } + Some(exit_code) => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {exit_code}"), + }); + } + None => { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook exited without a status code".to_string(), + }); + } + }, + } + + let completed = HookCompletedEvent { + turn_id, + run: dispatcher::completed_summary(handler, &run_result, status, entries), + }; + + dispatcher::ParsedHandler { + completed, + data: PreToolUseHandlerData { + should_block, + block_reason, + }, + } +} + +fn serialization_failure_outcome(hook_events: Vec) -> PreToolUseOutcome { + PreToolUseOutcome { + hook_events, + should_block: false, + block_reason: None, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookOutputEntry; + use codex_protocol::protocol::HookOutputEntryKind; + use codex_protocol::protocol::HookRunStatus; + use pretty_assertions::assert_eq; + + use super::PreToolUseHandlerData; + use super::parse_completed; + use crate::engine::ConfiguredHandler; + use crate::engine::command_runner::CommandRunResult; + + #[test] + fn permission_decision_deny_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn deprecated_block_decision_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"decision":"block","reason":"do not run that"}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("do not run that".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "do not run that".to_string(), + }] + ); + } + + #[test] + fn unsupported_permission_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"please confirm"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported permissionDecision:ask".to_string(), + }] + ); + } + + #[test] + fn deprecated_approve_decision_fails_open() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), r#"{"decision":"approve"}"#, ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported decision:approve".to_string(), + }] + ); + } + + #[test] + fn unsupported_additional_context_fails_open() { + let parsed = parse_completed( + &handler(), + run_result( + Some(0), + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"do not run that","additionalContext":"nope"}}"#, + "", + ), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreToolUse hook returned unsupported additionalContext".to_string(), + }] + ); + } + + #[test] + fn plain_stdout_is_ignored() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "hook ran successfully\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Completed); + assert_eq!(parsed.completed.run.entries, vec![]); + } + + #[test] + fn invalid_json_like_stdout_fails_instead_of_becoming_noop() { + let parsed = parse_completed( + &handler(), + run_result(Some(0), "{\"decision\":\n", ""), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: false, + block_reason: None, + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook returned invalid pre-tool-use JSON output".to_string(), + }] + ); + } + + #[test] + fn exit_code_two_blocks_processing() { + let parsed = parse_completed( + &handler(), + run_result(Some(2), "", "blocked by policy\n"), + Some("turn-1".to_string()), + ); + + assert_eq!( + parsed.data, + PreToolUseHandlerData { + should_block: true, + block_reason: Some("blocked by policy".to_string()), + } + ); + assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Feedback, + text: "blocked by policy".to_string(), + }] + ); + } + + fn handler() -> ConfiguredHandler { + ConfiguredHandler { + event_name: HookEventName::PreToolUse, + matcher: Some("^Bash$".to_string()), + command: "echo hook".to_string(), + timeout_sec: 5, + status_message: None, + source_path: PathBuf::from("/tmp/hooks.json"), + display_order: 0, + } + } + + fn run_result(exit_code: Option, stdout: &str, stderr: &str) -> CommandRunResult { + CommandRunResult { + started_at: 1, + completed_at: 2, + duration_ms: 1, + exit_code, + stdout: stdout.to_string(), + stderr: stderr.to_string(), + error: None, + } + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 768a24c5e3..86b39b0b7d 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,8 @@ mod registry; mod schema; mod types; +pub use events::pre_tool_use::PreToolUseOutcome; +pub use events::pre_tool_use::PreToolUseRequest; pub use events::session_start::SessionStartOutcome; pub use events::session_start::SessionStartRequest; pub use events::session_start::SessionStartSource; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 3b63bda8c3..440af648dd 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -3,6 +3,8 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; use crate::events::session_start::SessionStartOutcome; use crate::events::session_start::SessionStartRequest; use crate::events::stop::StopOutcome; @@ -92,6 +94,13 @@ impl Hooks { self.engine.preview_session_start(request) } + pub fn preview_pre_tool_use( + &self, + request: &PreToolUseRequest, + ) -> Vec { + self.engine.preview_pre_tool_use(request) + } + pub async fn run_session_start( &self, request: SessionStartRequest, @@ -100,6 +109,10 @@ impl Hooks { self.engine.run_session_start(request, turn_id).await } + pub async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome { + self.engine.run_pre_tool_use(request).await + } + pub fn preview_user_prompt_submit( &self, request: &UserPromptSubmitRequest, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 067658541a..277500984c 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -13,6 +13,8 @@ use std::path::Path; use std::path::PathBuf; const GENERATED_DIR: &str = "generated"; +const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json"; +const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json"; const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json"; const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json"; const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json"; @@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub(crate) enum HookEventNameWire { + #[serde(rename = "PreToolUse")] + PreToolUse, #[serde(rename = "SessionStart")] SessionStart, #[serde(rename = "UserPromptSubmit")] @@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire { Stop, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.output")] +pub(crate) struct PreToolUseCommandOutputWire { + #[serde(flatten)] + pub universal: HookUniversalOutputWire, + #[serde(default)] + pub decision: Option, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub hook_specific_output: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseHookSpecificOutputWire { + pub hook_event_name: HookEventNameWire, + #[serde(default)] + pub permission_decision: Option, + #[serde(default)] + pub permission_decision_reason: Option, + #[serde(default)] + pub updated_input: Option, + #[serde(default)] + pub additional_context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUsePermissionDecisionWire { + #[serde(rename = "allow")] + Allow, + #[serde(rename = "deny")] + Deny, + #[serde(rename = "ask")] + Ask, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub(crate) enum PreToolUseDecisionWire { + #[serde(rename = "approve")] + Approve, + #[serde(rename = "block")] + Block, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +pub(crate) struct PreToolUseToolInput { + pub command: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "pre-tool-use.command.input")] +pub(crate) struct PreToolUseCommandInput { + pub session_id: String, + /// Codex extension: expose the active turn id to internal turn-scoped hooks. + pub turn_id: String, + pub transcript_path: NullableString, + pub cwd: String, + #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")] + pub hook_event_name: String, + pub model: String, + #[schemars(schema_with = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "pre_tool_use_tool_name_schema")] + pub tool_name: String, + pub tool_input: PreToolUseToolInput, + pub tool_use_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(SESSION_START_INPUT_FIXTURE), schema_json::()?, @@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("SessionStart") } +fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("PreToolUse") +} + +fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema { + string_const_schema("Bash") +} + fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema { string_const_schema("UserPromptSubmit") } @@ -346,6 +441,9 @@ fn default_continue() -> bool { #[cfg(test)] mod tests { + use super::PRE_TOOL_USE_INPUT_FIXTURE; + use super::PRE_TOOL_USE_OUTPUT_FIXTURE; + use super::PreToolUseCommandInput; use super::SESSION_START_INPUT_FIXTURE; use super::SESSION_START_OUTPUT_FIXTURE; use super::STOP_INPUT_FIXTURE; @@ -362,6 +460,12 @@ mod tests { fn expected_fixture(name: &str) -> &'static str { match name { + PRE_TOOL_USE_INPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.input.schema.json") + } + PRE_TOOL_USE_OUTPUT_FIXTURE => { + include_str!("../schema/generated/pre-tool-use.command.output.schema.json") + } SESSION_START_INPUT_FIXTURE => { include_str!("../schema/generated/session-start.command.input.schema.json") } @@ -395,6 +499,8 @@ mod tests { write_schema_fixtures(&schema_root).expect("write generated hook schemas"); for fixture in [ + PRE_TOOL_USE_INPUT_FIXTURE, + PRE_TOOL_USE_OUTPUT_FIXTURE, SESSION_START_INPUT_FIXTURE, SESSION_START_OUTPUT_FIXTURE, USER_PROMPT_SUBMIT_INPUT_FIXTURE, @@ -414,6 +520,10 @@ mod tests { fn turn_scoped_hook_inputs_include_codex_turn_id_extension() { // Codex intentionally diverges from Claude's public hook docs here so // internal hook consumers can key off the active turn. + let pre_tool_use: Value = serde_json::from_slice( + &schema_json::().expect("serialize pre tool use input schema"), + ) + .expect("parse pre tool use input schema"); let user_prompt_submit: Value = serde_json::from_slice( &schema_json::() .expect("serialize user prompt submit input schema"), @@ -424,7 +534,7 @@ mod tests { ) .expect("parse stop input schema"); - for schema in [&user_prompt_submit, &stop] { + for schema in [&pre_tool_use, &user_prompt_submit, &stop] { assert_eq!(schema["properties"]["turn_id"]["type"], "string"); assert!( schema["required"] diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index f9fb58a9d5..60511caa4d 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -197,6 +197,49 @@ fn unauthorized_recovery_reports_mode_and_step_names() { assert_eq!(external.step_name(), "external_refresh"); } +#[test] +fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { + let codex_home = tempdir().unwrap(); + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + let mut updated_auth_dot_json = auth + .get_current_auth_json() + .expect("AuthDotJson should exist"); + let updated_tokens = updated_auth_dot_json + .tokens + .as_mut() + .expect("tokens should exist"); + updated_tokens.access_token = "new-access-token".to_string(); + updated_tokens.refresh_token = "new-refresh-token".to_string(); + let updated_auth = CodexAuth::from_auth_dot_json( + codex_home.path(), + updated_auth_dot_json, + AuthCredentialsStoreMode::File, + ) + .expect("updated auth should parse"); + + let manager = AuthManager::from_auth_for_testing(auth.clone()); + let error = RefreshTokenFailedError::new( + RefreshTokenFailedReason::Exhausted, + "refresh token already used", + ); + manager.record_permanent_refresh_failure_if_unchanged(&auth, &error); + + assert_eq!(manager.refresh_failure_for_auth(&auth), Some(error)); + assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None); +} + struct AuthFileParams { openai_api_key: Option, chatgpt_plan_type: Option, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 31860cd585..94d546a56e 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -28,6 +28,7 @@ use crate::token_data::KnownPlan as InternalKnownPlan; use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; +use crate::token_data::parse_jwt_expiration; use codex_client::CodexHttpClient; use codex_protocol::account::PlanType as AccountPlanType; use serde_json::Value; @@ -69,7 +70,6 @@ impl PartialEq for CodexAuth { } } -// TODO(pakrym): use token exp field to check for expiration instead const TOKEN_REFRESH_INTERVAL: i64 = 8; 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."; @@ -796,6 +796,15 @@ struct CachedAuth { auth: Option, /// Callback used to refresh external auth by asking the parent app for new tokens. external_refresher: Option>, + /// Permanent refresh failure cached for the current auth snapshot so + /// later refresh attempts for the same credentials fail fast without network. + permanent_refresh_failure: Option, +} + +#[derive(Clone)] +struct AuthScopedRefreshFailure { + auth: CodexAuth, + error: RefreshTokenFailedError, } impl Debug for CachedAuth { @@ -809,6 +818,13 @@ impl Debug for CachedAuth { "external_refresher", &self.external_refresher.as_ref().map(|_| "present"), ) + .field( + "permanent_refresh_failure", + &self + .permanent_refresh_failure + .as_ref() + .map(|failure| failure.error.reason), + ) .finish() } } @@ -1046,6 +1062,7 @@ impl AuthManager { inner: RwLock::new(CachedAuth { auth: managed_auth, external_refresher: None, + permanent_refresh_failure: None, }), enable_codex_api_key_env, auth_credentials_store_mode, @@ -1058,6 +1075,7 @@ impl AuthManager { let cached = CachedAuth { auth: Some(auth), external_refresher: None, + permanent_refresh_failure: None, }; Arc::new(Self { @@ -1074,6 +1092,7 @@ impl AuthManager { let cached = CachedAuth { auth: Some(auth), external_refresher: None, + permanent_refresh_failure: None, }; Arc::new(Self { codex_home, @@ -1089,6 +1108,16 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } + pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option { + self.inner.read().ok().and_then(|cached| { + cached + .permanent_refresh_failure + .as_ref() + .filter(|failure| Self::auths_equal_for_refresh(Some(auth), Some(&failure.auth))) + .map(|failure| failure.error.clone()) + }) + } + /// 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. @@ -1166,6 +1195,25 @@ impl AuthManager { } } + /// Records a permanent refresh failure only if the failed refresh was + /// attempted against the auth snapshot that is still cached. + fn record_permanent_refresh_failure_if_unchanged( + &self, + attempted_auth: &CodexAuth, + error: &RefreshTokenFailedError, + ) { + if let Ok(mut guard) = self.inner.write() { + let current_auth_matches = + Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref()); + if current_auth_matches { + guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure { + auth: attempted_auth.clone(), + error: error.clone(), + }); + } + } + } + fn load_auth_from_storage(&self) -> Option { load_auth( &self.codex_home, @@ -1180,6 +1228,11 @@ impl AuthManager { if let Ok(mut guard) = self.inner.write() { let previous = guard.auth.as_ref(); let changed = !AuthManager::auths_equal(previous, new_auth.as_ref()); + let auth_changed_for_refresh = + !Self::auths_equal_for_refresh(previous, new_auth.as_ref()); + if auth_changed_for_refresh { + guard.permanent_refresh_failure = None; + } tracing::info!("Reloaded auth, changed: {changed}"); guard.auth = new_auth; changed @@ -1255,6 +1308,12 @@ impl AuthManager { /// token is the same as the cached, then ask the token authority to refresh. pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> { let auth_before_reload = self.auth_cached(); + if auth_before_reload + .as_ref() + .is_some_and(CodexAuth::is_api_key_auth) + { + return Ok(()); + } let expected_account_id = auth_before_reload .as_ref() .and_then(CodexAuth::get_account_id); @@ -1285,7 +1344,12 @@ impl AuthManager { Some(auth) => auth, None => return Ok(()), }; - match auth { + if let Some(error) = self.refresh_failure_for_auth(&auth) { + return Err(RefreshTokenError::Permanent(error)); + } + + let attempted_auth = auth.clone(); + let result = match auth { CodexAuth::ChatgptAuthTokens(_) => { self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized) .await @@ -1297,11 +1361,14 @@ impl AuthManager { )) })?; self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) - .await?; - Ok(()) + .await } CodexAuth::ApiKey(_) => Ok(()), + }; + if let Err(RefreshTokenError::Permanent(error)) = &result { + self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); } + result } /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true) @@ -1333,6 +1400,11 @@ impl AuthManager { Some(auth_dot_json) => auth_dot_json, None => return false, }; + 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(); + } let last_refresh = match auth_dot_json.last_refresh { Some(last_refresh) => last_refresh, None => return false, diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index 304bf765f4..1056115a37 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -1,6 +1,9 @@ use base64::Engine; +use chrono::DateTime; +use chrono::Utc; use serde::Deserialize; use serde::Serialize; +use serde::de::DeserializeOwned; use thiserror::Error; #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] @@ -117,6 +120,12 @@ struct AuthClaims { chatgpt_account_id: Option, } +#[derive(Deserialize)] +struct StandardJwtClaims { + #[serde(default)] + exp: Option, +} + #[derive(Debug, Error)] pub enum IdTokenInfoError { #[error("invalid ID token format")] @@ -127,7 +136,7 @@ pub enum IdTokenInfoError { Json(#[from] serde_json::Error), } -pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result { +fn decode_jwt_payload(jwt: &str) -> Result { // JWT format: header.payload.signature let mut parts = jwt.split('.'); let (_header_b64, payload_b64, _sig_b64) = match (parts.next(), parts.next(), parts.next()) { @@ -136,7 +145,19 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result Result>, IdTokenInfoError> { + let claims: StandardJwtClaims = decode_jwt_payload(jwt)?; + Ok(claims + .exp + .and_then(|exp| DateTime::::from_timestamp(exp, 0))) +} + +pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result { + let claims: IdClaims = decode_jwt_payload(jwt)?; let email = claims .email .or_else(|| claims.profile.and_then(|profile| profile.email)); diff --git a/codex-rs/login/src/token_data_tests.rs b/codex-rs/login/src/token_data_tests.rs index e599379c18..7f77ee5eaf 100644 --- a/codex-rs/login/src/token_data_tests.rs +++ b/codex-rs/login/src/token_data_tests.rs @@ -1,9 +1,10 @@ use super::*; +use chrono::TimeZone; +use chrono::Utc; use pretty_assertions::assert_eq; use serde::Serialize; -#[test] -fn id_token_info_parses_email_and_plan() { +fn fake_jwt(payload: serde_json::Value) -> String { #[derive(Serialize)] struct Header { alg: &'static str, @@ -13,12 +14,6 @@ fn id_token_info_parses_email_and_plan() { alg: "none", typ: "JWT", }; - let payload = serde_json::json!({ - "email": "user@example.com", - "https://api.openai.com/auth": { - "chatgpt_plan_type": "pro" - } - }); fn b64url_no_pad(bytes: &[u8]) -> String { base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) @@ -27,7 +22,17 @@ fn id_token_info_parses_email_and_plan() { let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +#[test] +fn id_token_info_parses_email_and_plan() { + let fake_jwt = fake_jwt(serde_json::json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_plan_type": "pro" + } + })); let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); assert_eq!(info.email.as_deref(), Some("user@example.com")); @@ -36,30 +41,12 @@ fn id_token_info_parses_email_and_plan() { #[test] fn id_token_info_parses_go_plan() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ + let fake_jwt = fake_jwt(serde_json::json!({ "email": "user@example.com", "https://api.openai.com/auth": { "chatgpt_plan_type": "go" } - }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + })); let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); assert_eq!(info.email.as_deref(), Some("user@example.com")); @@ -68,31 +55,37 @@ fn id_token_info_parses_go_plan() { #[test] fn id_token_info_handles_missing_fields() { - #[derive(Serialize)] - struct Header { - alg: &'static str, - typ: &'static str, - } - let header = Header { - alg: "none", - typ: "JWT", - }; - let payload = serde_json::json!({ "sub": "123" }); - - fn b64url_no_pad(bytes: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) - } - - let header_b64 = b64url_no_pad(&serde_json::to_vec(&header).unwrap()); - let payload_b64 = b64url_no_pad(&serde_json::to_vec(&payload).unwrap()); - let signature_b64 = b64url_no_pad(b"sig"); - let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" })); let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse"); assert!(info.email.is_none()); assert!(info.get_chatgpt_plan_type().is_none()); } +#[test] +fn jwt_expiration_parses_exp_claim() { + let fake_jwt = fake_jwt(serde_json::json!({ + "exp": 1_700_000_000_i64, + })); + + let expires_at = parse_jwt_expiration(&fake_jwt).expect("should parse"); + assert_eq!(expires_at, Utc.timestamp_opt(1_700_000_000, 0).single()); +} + +#[test] +fn jwt_expiration_handles_missing_exp() { + let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" })); + + let expires_at = parse_jwt_expiration(&fake_jwt).expect("should parse"); + assert_eq!(expires_at, None); +} + +#[test] +fn jwt_expiration_rejects_malformed_jwt() { + let err = parse_jwt_expiration("not-a-jwt").expect_err("should fail"); + assert_eq!(err.to_string(), "invalid ID token format"); +} + #[test] fn workspace_account_detection_matches_workspace_plans() { let workspace = IdTokenInfo { diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md index 6fb689490a..0347e6ddf2 100644 --- a/codex-rs/network-proxy/README.md +++ b/codex-rs/network-proxy/README.md @@ -39,7 +39,8 @@ mitm = false # Hosts must match the allowlist (unless denied). # Use exact hosts or scoped wildcards like `*.openai.com` or `**.openai.com`. -# The global `*` wildcard is rejected. +# The global `*` wildcard is allowed in `allowed_domains` to delegate public-host filtering to +# `denied_domains`. # If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. allowed_domains = ["*.openai.com", "localhost", "127.0.0.1", "::1"] denied_domains = ["evil.example"] @@ -189,7 +190,7 @@ This section documents the protections implemented by `codex-network-proxy`, and what it can reasonably guarantee. - Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. -- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported; the global `*` wildcard is rejected. +- Domain patterns: exact hosts plus scoped wildcards (`*.example.com`, `**.example.com`) are supported. A global `*` wildcard is allowed in `allowed_domains` to permit all public hosts by default, while `denied_domains` remains field-specific and still rejects global `*`. - Deny wins: entries in `denied_domains` always override the allowlist. - Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs index 17927767f9..c586ee9c11 100644 --- a/codex-rs/network-proxy/src/policy.rs +++ b/codex-rs/network-proxy/src/policy.rs @@ -2,6 +2,7 @@ use crate::config::NetworkMode; use anyhow::Context; use anyhow::Result; +use anyhow::bail; use anyhow::ensure; use globset::GlobBuilder; use globset::GlobSet; @@ -151,19 +152,38 @@ pub(crate) fn is_global_wildcard_domain_pattern(pattern: &str) -> bool { .any(|candidate| candidate == "*") } -pub(crate) fn compile_globset(patterns: &[String]) -> Result { +#[derive(Clone, Copy, PartialEq, Eq)] +enum GlobalWildcard { + Allow, + Reject, +} + +pub(crate) fn compile_allowlist_globset(patterns: &[String]) -> Result { + compile_globset_with_policy(patterns, GlobalWildcard::Allow) +} + +pub(crate) fn compile_denylist_globset(patterns: &[String]) -> Result { + compile_globset_with_policy(patterns, GlobalWildcard::Reject) +} + +fn compile_globset_with_policy( + patterns: &[String], + global_wildcard: GlobalWildcard, +) -> Result { let mut builder = GlobSetBuilder::new(); let mut seen = HashSet::new(); for pattern in patterns { - ensure!( - !is_global_wildcard_domain_pattern(pattern), - "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" - ); + if global_wildcard == GlobalWildcard::Reject && is_global_wildcard_domain_pattern(pattern) { + bail!( + "unsupported global wildcard domain pattern \"*\"; use exact hosts or scoped wildcards like *.example.com or **.example.com" + ); + } let pattern = normalize_pattern(pattern); // Supported domain patterns: // - "example.com": match the exact host // - "*.example.com": match any subdomain (not the apex) // - "**.example.com": match the apex and any subdomain + // - "*": match every host when explicitly enabled for allowlist compilation for candidate in expand_domain_pattern(&pattern) { if !seen.insert(candidate.clone()) { continue; @@ -333,7 +353,7 @@ mod tests { #[test] fn compile_globset_normalizes_trailing_dots() { - let set = compile_globset(&["Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("example.com")); assert_eq!(false, set.is_match("api.example.com")); @@ -341,7 +361,7 @@ mod tests { #[test] fn compile_globset_normalizes_wildcards() { - let set = compile_globset(&["*.Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["*.Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("api.example.com")); assert_eq!(false, set.is_match("example.com")); @@ -349,7 +369,7 @@ mod tests { #[test] fn compile_globset_normalizes_apex_and_subdomains() { - let set = compile_globset(&["**.Example.COM.".to_string()]).unwrap(); + let set = compile_denylist_globset(&["**.Example.COM.".to_string()]).unwrap(); assert_eq!(true, set.is_match("example.com")); assert_eq!(true, set.is_match("api.example.com")); @@ -357,7 +377,7 @@ mod tests { #[test] fn compile_globset_normalizes_bracketed_ipv6_literals() { - let set = compile_globset(&["[::1]".to_string()]).unwrap(); + let set = compile_denylist_globset(&["[::1]".to_string()]).unwrap(); assert_eq!(true, set.is_match("::1")); } diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b634a8630e..9381fab7f4 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -819,7 +819,8 @@ mod tests { use crate::config::NetworkProxyConfig; use crate::config::NetworkProxySettings; - use crate::policy::compile_globset; + use crate::policy::compile_allowlist_globset; + use crate::policy::compile_denylist_globset; use crate::state::NetworkProxyConstraints; use crate::state::build_config_state; use crate::state::validate_policy_against_constraints; @@ -894,6 +895,29 @@ mod tests { ); } + #[tokio::test] + async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["*".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + + state.add_denied_domain("evil.example").await.unwrap(); + + let (allowed, denied) = state.current_patterns().await.unwrap(); + assert_eq!(allowed, vec!["*".to_string()]); + assert_eq!(denied, vec!["evil.example".to_string()]); + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + #[tokio::test] async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() { let config = NetworkProxyConfig { @@ -1089,6 +1113,28 @@ mod tests { ); } + #[tokio::test] + async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() { + let state = network_proxy_state_for_policy(NetworkProxySettings { + allowed_domains: vec!["*".to_string()], + denied_domains: vec!["evil.example".to_string()], + ..NetworkProxySettings::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("api.openai.com", 443).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("evil.example", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + #[tokio::test] async fn host_blocked_rejects_loopback_when_local_binding_disabled() { let state = network_proxy_state_for_policy(NetworkProxySettings { @@ -1484,7 +1530,7 @@ mod tests { #[test] fn compile_globset_is_case_insensitive() { let patterns = vec!["ExAmPle.CoM".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); } @@ -1492,7 +1538,7 @@ mod tests { #[test] fn compile_globset_excludes_apex_for_subdomain_patterns() { let patterns = vec!["*.openai.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("openai.com")); assert!(!set.is_match("evilopenai.com")); @@ -1501,7 +1547,7 @@ mod tests { #[test] fn compile_globset_includes_apex_for_double_wildcard_patterns() { let patterns = vec!["**.openai.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("openai.com")); assert!(set.is_match("api.openai.com")); assert!(!set.is_match("evilopenai.com")); @@ -1510,25 +1556,34 @@ mod tests { #[test] fn compile_globset_rejects_global_wildcard() { let patterns = vec!["*".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); + } + + #[test] + fn compile_globset_allows_global_wildcard_when_enabled() { + let patterns = vec!["*".to_string()]; + let set = compile_allowlist_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("api.openai.com")); + assert!(set.is_match("localhost")); } #[test] fn compile_globset_rejects_bracketed_global_wildcard() { let patterns = vec!["[*]".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() { let patterns = vec!["**.[*]".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] fn compile_globset_dedupes_patterns_without_changing_behavior() { let patterns = vec!["example.com".to_string(), "example.com".to_string()]; - let set = compile_globset(&patterns).unwrap(); + let set = compile_denylist_globset(&patterns).unwrap(); assert!(set.is_match("example.com")); assert!(set.is_match("EXAMPLE.COM")); assert!(!set.is_match("not-example.com")); @@ -1537,11 +1592,11 @@ mod tests { #[test] fn compile_globset_rejects_invalid_patterns() { let patterns = vec!["[".to_string()]; - assert!(compile_globset(&patterns).is_err()); + assert!(compile_denylist_globset(&patterns).is_err()); } #[test] - fn build_config_state_rejects_global_wildcard_allowed_domains() { + fn build_config_state_allows_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, @@ -1550,11 +1605,11 @@ mod tests { }, }; - assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok()); } #[test] - fn build_config_state_rejects_bracketed_global_wildcard_allowed_domains() { + fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() { let config = NetworkProxyConfig { network: NetworkProxySettings { enabled: true, @@ -1563,7 +1618,7 @@ mod tests { }, }; - assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err()); + assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok()); } #[test] diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs index 612e6c5b5c..435e5beab4 100644 --- a/codex-rs/network-proxy/src/state.rs +++ b/codex-rs/network-proxy/src/state.rs @@ -2,7 +2,8 @@ use crate::config::NetworkMode; use crate::config::NetworkProxyConfig; use crate::mitm::MitmState; use crate::policy::DomainPattern; -use crate::policy::compile_globset; +use crate::policy::compile_allowlist_globset; +use crate::policy::compile_denylist_globset; use crate::policy::is_global_wildcard_domain_pattern; use crate::runtime::ConfigState; use serde::Deserialize; @@ -59,12 +60,10 @@ pub fn build_config_state( constraints: NetworkProxyConstraints, ) -> anyhow::Result { crate::config::validate_unix_socket_allowlist_paths(&config)?; - validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains) + validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains) .map_err(NetworkProxyConstraintError::into_anyhow)?; - validate_domain_patterns("network.denied_domains", &config.network.denied_domains) - .map_err(NetworkProxyConstraintError::into_anyhow)?; - let deny_set = compile_globset(&config.network.denied_domains)?; - let allow_set = compile_globset(&config.network.allowed_domains)?; + let deny_set = compile_denylist_globset(&config.network.denied_domains)?; + let allow_set = compile_allowlist_globset(&config.network.allowed_domains)?; let mitm = if config.network.mitm { Some(Arc::new(MitmState::new( config.network.allow_upstream_proxy, @@ -107,8 +106,7 @@ pub fn validate_policy_against_constraints( } let enabled = config.network.enabled; - validate_domain_patterns("network.allowed_domains", &config.network.allowed_domains)?; - validate_domain_patterns("network.denied_domains", &config.network.denied_domains)?; + validate_denylist_domain_patterns("network.denied_domains", &config.network.denied_domains)?; if let Some(max_enabled) = constraints.enabled { validate(enabled, move |candidate| { if *candidate && !max_enabled { @@ -208,7 +206,6 @@ pub fn validate_policy_against_constraints( } if let Some(allowed_domains) = &constraints.allowed_domains { - validate_domain_patterns("network.allowed_domains", allowed_domains)?; match constraints.allowlist_expansion_enabled { Some(true) => { let required_set: HashSet = allowed_domains @@ -288,7 +285,7 @@ pub fn validate_policy_against_constraints( } if let Some(denied_domains) = &constraints.denied_domains { - validate_domain_patterns("network.denied_domains", denied_domains)?; + validate_denylist_domain_patterns("network.denied_domains", denied_domains)?; let required_set: HashSet = denied_domains .iter() .map(|s| s.to_ascii_lowercase()) @@ -364,7 +361,7 @@ pub fn validate_policy_against_constraints( Ok(()) } -fn validate_domain_patterns( +fn validate_denylist_domain_patterns( field_name: &'static str, patterns: &[String], ) -> Result<(), NetworkProxyConstraintError> { diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 58a82afea5..d4f5760d5c 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -1,5 +1,6 @@ pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; +pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec"; pub const API_CALL_COUNT_METRIC: &str = "codex.api_request"; pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms"; pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event"; diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 11efa9d376..ebfdaddabb 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -13,9 +13,10 @@ workspace = true [dependencies] codex-execpolicy = { workspace = true } -codex-git = { workspace = true } +codex-git-utils = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-image = { workspace = true } +codex-utils-string = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } icu_provider = { workspace = true, features = ["sync"] } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 5c68d3c3e3..6fa8162bf7 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -23,7 +23,7 @@ use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; use crate::user_input::UserInput; use codex_execpolicy::Policy; -use codex_git::GhostCommit; +use codex_git_utils::GhostCommit; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_image::error::ImageProcessingError; use schemars::JsonSchema; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c88c4ecee8..6856f19417 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; use std::fmt; +use std::ops::Mul; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -38,6 +39,7 @@ use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::MessagePhase; +use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -48,6 +50,7 @@ use crate::request_permissions::RequestPermissionsEvent; use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; use crate::user_input::UserInput; +use codex_git_utils::GitSha; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; @@ -254,6 +257,11 @@ pub enum Op { /// Policy to use for command approval. approval_policy: AskForApproval, + /// Reviewer to use for approval requests raised during this turn. + /// + /// When omitted, the session keeps the current setting + approvals_reviewer: Option, + /// Policy to use for tool calls such as `local_shell`. sandbox_policy: SandboxPolicy, @@ -293,6 +301,12 @@ pub enum Op { personality: Option, }, + /// Inter-agent communication that should be recorded as assistant history + /// while still using the normal thread submission lifecycle. + InterAgentCommunication { + communication: InterAgentCommunication, + }, + /// Override parts of the persistent turn context for subsequent turns. /// /// All fields are optional; when omitted, the existing value is preserved. @@ -499,6 +513,56 @@ pub enum Op { ListModels, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +pub struct InterAgentCommunication { + pub author: AgentPath, + pub recipient: AgentPath, + #[serde(default)] + pub other_recipients: Vec, + pub content: String, + pub trigger_turn: bool, +} + +impl InterAgentCommunication { + pub fn new( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + content: String, + trigger_turn: bool, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content, + trigger_turn, + } + } + + pub fn to_response_input_item(&self) -> ResponseInputItem { + ResponseInputItem::Message { + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: serde_json::to_string(self).unwrap_or_default(), + }], + } + } + + pub fn is_message_content(content: &[ContentItem]) -> bool { + Self::from_message_content(content).is_some() + } + + pub fn from_message_content(content: &[ContentItem]) -> Option { + match content { + [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { + serde_json::from_str(text).ok() + } + _ => None, + } + } +} + impl Op { pub fn kind(&self) -> &'static str { match self { @@ -510,6 +574,7 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::UserInput { .. } => "user_input", Self::UserTurn { .. } => "user_turn", + Self::InterAgentCommunication { .. } => "inter_agent_communication", Self::OverrideTurnContext { .. } => "override_turn_context", Self::ExecApproval { .. } => "exec_approval", Self::PatchApproval { .. } => "patch_approval", @@ -1342,6 +1407,7 @@ pub enum EventMsg { #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookEventName { + PreToolUse, SessionStart, UserPromptSubmit, Stop, @@ -2564,6 +2630,51 @@ pub enum TruncationPolicy { Tokens(usize), } +impl From for TruncationPolicy { + fn from(config: crate::openai_models::TruncationPolicyConfig) -> Self { + match config.mode { + crate::openai_models::TruncationMode::Bytes => Self::Bytes(config.limit as usize), + crate::openai_models::TruncationMode::Tokens => Self::Tokens(config.limit as usize), + } + } +} + +impl TruncationPolicy { + pub fn token_budget(&self) -> usize { + match self { + TruncationPolicy::Bytes(bytes) => { + usize::try_from(codex_utils_string::approx_tokens_from_byte_count(*bytes)) + .unwrap_or(usize::MAX) + } + TruncationPolicy::Tokens(tokens) => *tokens, + } + } + + pub fn byte_budget(&self) -> usize { + match self { + TruncationPolicy::Bytes(bytes) => *bytes, + TruncationPolicy::Tokens(tokens) => { + codex_utils_string::approx_bytes_for_tokens(*tokens) + } + } + } +} + +impl Mul for TruncationPolicy { + type Output = Self; + + fn mul(self, multiplier: f64) -> Self::Output { + match self { + TruncationPolicy::Bytes(bytes) => { + TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) + } + TruncationPolicy::Tokens(tokens) => { + TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) + } + } + } +} + #[derive(Serialize, Deserialize, Clone, JsonSchema)] pub struct RolloutLine { pub timestamp: String, @@ -2575,7 +2686,7 @@ pub struct RolloutLine { pub struct GitInfo { /// Current commit hash (SHA) #[serde(skip_serializing_if = "Option::is_none")] - pub commit_hash: Option, + pub commit_hash: Option, /// Current branch name #[serde(skip_serializing_if = "Option::is_none")] pub branch: Option, 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 cd08307767..d9202babae 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -22,6 +22,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde_json::json; use tokio::task; @@ -85,11 +86,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed(name), Cow::Borrowed(description), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn image_tool() -> Tool { @@ -101,11 +104,13 @@ impl TestToolServer { })) .expect("image tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image"), Cow::Borrowed("Return a single image content block."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } /// Tool intended for manual testing of Codex TUI rendering for MCP image tool results. @@ -154,13 +159,15 @@ impl TestToolServer { })) .expect("image_scenario tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image_scenario"), Cow::Borrowed( "Return content blocks for manual testing of MCP image rendering scenarios.", ), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { 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 284e1194c3..50ea06c7fa 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 @@ -38,6 +38,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use rmcp::transport::StreamableHttpServerConfig; use rmcp::transport::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -84,11 +85,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("echo"), Cow::Borrowed("Echo back the provided message and include environment data."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 0edd0f1527..627b9f2e71 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -21,6 +21,7 @@ pub use perform_oauth_login::OAuthProviderError; pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; pub use perform_oauth_login::perform_oauth_login_return_url; +pub use perform_oauth_login::perform_oauth_login_silent; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 938f51b053..821fbf2750 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -80,6 +80,61 @@ pub async fn perform_oauth_login( oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, +) -> Result<()> { + perform_oauth_login_with_browser_output( + server_name, + server_url, + store_mode, + http_headers, + env_http_headers, + scopes, + oauth_resource, + callback_port, + callback_url, + /*emit_browser_url*/ true, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn perform_oauth_login_silent( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + oauth_resource: Option<&str>, + callback_port: Option, + callback_url: Option<&str>, +) -> Result<()> { + perform_oauth_login_with_browser_output( + server_name, + server_url, + store_mode, + http_headers, + env_http_headers, + scopes, + oauth_resource, + callback_port, + callback_url, + /*emit_browser_url*/ false, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn perform_oauth_login_with_browser_output( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + oauth_resource: Option<&str>, + callback_port: Option, + callback_url: Option<&str>, + emit_browser_url: bool, ) -> Result<()> { let headers = OauthHeaders { http_headers, @@ -98,7 +153,7 @@ pub async fn perform_oauth_login( /*timeout_secs*/ None, ) .await? - .finish() + .finish(emit_browser_url) .await } @@ -415,16 +470,23 @@ impl OauthLoginFlow { self.auth_url.clone() } - async fn finish(mut self) -> Result<()> { + async fn finish(mut self, emit_browser_url: bool) -> Result<()> { if self.launch_browser { let server_name = &self.server_name; let auth_url = &self.auth_url; - println!( - "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" - ); + if emit_browser_url { + println!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + } if webbrowser::open(auth_url).is_err() { - println!("(Browser launch failed; please copy the URL above manually.)"); + if !emit_browser_url { + eprintln!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + } + eprintln!("(Browser launch failed; please copy the URL above manually.)"); } } @@ -477,7 +539,7 @@ impl OauthLoginFlow { let (tx, rx) = oneshot::channel(); tokio::spawn(async move { - let result = self.finish().await; + let result = self.finish(/*emit_browser_url*/ false).await; if let Err(err) = &result { eprintln!( diff --git a/codex-rs/rmcp-client/src/program_resolver.rs b/codex-rs/rmcp-client/src/program_resolver.rs index e62336e263..d9be2741c7 100644 --- a/codex-rs/rmcp-client/src/program_resolver.rs +++ b/codex-rs/rmcp-client/src/program_resolver.rs @@ -13,18 +13,13 @@ use std::collections::HashMap; use std::ffi::OsString; -#[cfg(windows)] -use std::env; -#[cfg(windows)] -use tracing::debug; - /// Resolves a program to its executable path on Unix systems. /// /// Unix systems handle PATH resolution and script execution natively through /// the kernel's shebang (`#!`) mechanism, so this function simply returns /// the program name unchanged. #[cfg(unix)] -pub fn resolve(program: OsString, _env: &HashMap) -> std::io::Result { +pub fn resolve(program: OsString, _env: &HashMap) -> std::io::Result { Ok(program) } @@ -38,25 +33,22 @@ pub fn resolve(program: OsString, _env: &HashMap) -> std::io::Re /// This enables tools like `npx`, `pnpm`, and `yarn` to work correctly on Windows /// without requiring users to specify full paths or extensions in their configuration. #[cfg(windows)] -pub fn resolve(program: OsString, env: &HashMap) -> std::io::Result { +pub fn resolve(program: OsString, env: &HashMap) -> std::io::Result { // Get current directory for relative path resolution - let cwd = env::current_dir() + let cwd = std::env::current_dir() .map_err(|e| std::io::Error::other(format!("Failed to get current directory: {e}")))?; // Extract PATH from environment for search locations - let search_path = env.get("PATH"); + let search_path = env.get(std::ffi::OsStr::new("PATH")); // Attempt resolution via which crate match which::which_in(&program, search_path, &cwd) { Ok(resolved) => { - debug!("Resolved {:?} to {:?}", program, resolved); + tracing::debug!("Resolved {program:?} to {resolved:?}"); Ok(resolved.into_os_string()) } Err(e) => { - debug!( - "Failed to resolve {:?}: {}. Using original path", - program, e - ); + tracing::debug!("Failed to resolve {program:?}: {e}. Using original path"); // Fallback to original program - let Command::new() handle the error Ok(program) } @@ -146,7 +138,7 @@ mod tests { // Held to prevent the temporary directory from being deleted. _temp_dir: TempDir, program_name: String, - mcp_env: HashMap, + mcp_env: HashMap, } impl TestExecutableEnv { @@ -160,10 +152,10 @@ mod tests { // Build a clean environment with the temp dir in the PATH. let mut extra_env = HashMap::new(); - extra_env.insert("PATH".to_string(), Self::build_path(dir_path)); + extra_env.insert(OsString::from("PATH"), Self::build_path_env_var(dir_path)); #[cfg(windows)] - extra_env.insert("PATHEXT".to_string(), Self::ensure_cmd_extension()); + extra_env.insert(OsString::from("PATHEXT"), Self::ensure_cmd_extension()); let mcp_env = create_env_for_mcp_server(Some(extra_env), &[]); @@ -202,20 +194,30 @@ mod tests { } /// Prepends the given directory to the system's PATH variable. - fn build_path(dir: &Path) -> String { - let current = std::env::var("PATH").unwrap_or_default(); - let sep = if cfg!(windows) { ";" } else { ":" }; - format!("{}{sep}{current}", dir.to_string_lossy()) + fn build_path_env_var(dir: &Path) -> OsString { + let mut path = OsString::from(dir.as_os_str()); + if let Some(current) = std::env::var_os("PATH") { + let sep = if cfg!(windows) { ";" } else { ":" }; + path.push(sep); + path.push(current); + } + path } /// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery. #[cfg(windows)] - fn ensure_cmd_extension() -> String { - let current = std::env::var("PATHEXT").unwrap_or_default(); - if current.to_uppercase().contains(".CMD") { + fn ensure_cmd_extension() -> OsString { + let current = std::env::var_os("PATHEXT").unwrap_or_default(); + if current + .to_string_lossy() + .to_ascii_uppercase() + .contains(".CMD") + { current } else { - format!(".CMD;{current}") + let mut path_ext = OsString::from(".CMD;"); + path_ext.push(current); + path_ext } } } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index f13472a21b..2ef0acf700 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -392,7 +392,7 @@ enum TransportRecipe { Stdio { program: OsString, args: Vec, - env: Option>, + env: Option>, env_vars: Vec, cwd: Option, }, @@ -480,7 +480,7 @@ impl RmcpClient { pub async fn new_stdio_client( program: OsString, args: Vec, - env: Option>, + env: Option>, env_vars: &[String], cwd: Option, ) -> io::Result { diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index df09b0f8be..cb906aaaa9 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -5,16 +5,17 @@ use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use std::collections::HashMap; use std::env; +use std::ffi::OsString; pub(crate) fn create_env_for_mcp_server( - extra_env: Option>, + extra_env: Option>, env_vars: &[String], -) -> HashMap { +) -> HashMap { DEFAULT_ENV_VARS .iter() .copied() .chain(env_vars.iter().map(String::as_str)) - .filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value))) + .filter_map(|var| env::var_os(var).map(|value| (OsString::from(var), value))) .chain(extra_env.unwrap_or_default()) .collect() } @@ -140,7 +141,7 @@ mod tests { use pretty_assertions::assert_eq; use serial_test::serial; - use std::ffi::OsString; + use std::ffi::OsStr; struct EnvVarGuard { key: String, @@ -148,10 +149,10 @@ mod tests { } impl EnvVarGuard { - fn set(key: &str, value: &str) -> Self { + fn set(key: &str, value: impl AsRef) -> Self { let original = std::env::var_os(key); unsafe { - std::env::set_var(key, value); + std::env::set_var(key, value.as_ref()); } Self { key: key.to_string(), @@ -177,9 +178,12 @@ mod tests { #[tokio::test] async fn create_env_honors_overrides() { let value = "custom".to_string(); - let env = - create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]); - assert_eq!(env.get("TZ"), Some(&value)); + let expected = OsString::from(&value); + let env = create_env_for_mcp_server( + Some(HashMap::from([(OsString::from("TZ"), expected.clone())])), + &[], + ); + assert_eq!(env.get(OsStr::new("TZ")), Some(&expected)); } #[test] @@ -187,8 +191,24 @@ mod tests { fn create_env_includes_additional_whitelisted_variables() { let custom_var = "EXTRA_RMCP_ENV"; let value = "from-env"; + let expected = OsString::from(value); let _guard = EnvVarGuard::set(custom_var, value); let env = create_env_for_mcp_server(None, &[custom_var.to_string()]); - assert_eq!(env.get(custom_var), Some(&value.to_string())); + assert_eq!(env.get(OsStr::new(custom_var)), Some(&expected)); + } + + #[cfg(unix)] + #[test] + #[serial(extra_rmcp_env)] + fn create_env_preserves_path_when_it_is_not_utf8() { + use std::os::unix::ffi::OsStrExt; + + let raw_path = std::ffi::OsStr::from_bytes(b"/tmp/codex-\xFF/bin"); + let expected = raw_path.to_os_string(); + let _guard = EnvVarGuard::set("PATH", raw_path); + + let env = create_env_for_mcp_server(None, &[]); + + assert_eq!(env.get(OsStr::new("PATH")), Some(&expected)); } } diff --git a/codex-rs/rmcp-client/tests/process_group_cleanup.rs b/codex-rs/rmcp-client/tests/process_group_cleanup.rs index bb033af875..2d6c220ce8 100644 --- a/codex-rs/rmcp-client/tests/process_group_cleanup.rs +++ b/codex-rs/rmcp-client/tests/process_group_cleanup.rs @@ -73,8 +73,8 @@ async fn drop_kills_wrapper_process_group() -> Result<()> { ), ], Some(HashMap::from([( - "CHILD_PID_FILE".to_string(), - child_pid_file_str, + OsString::from("CHILD_PID_FILE"), + OsString::from(child_pid_file_str), )])), &[], None, diff --git a/codex-rs/utils/git/BUILD.bazel b/codex-rs/rollout/BUILD.bazel similarity index 52% rename from codex-rs/utils/git/BUILD.bazel rename to codex-rs/rollout/BUILD.bazel index 332cdc8d67..a91a3dd507 100644 --- a/codex-rs/utils/git/BUILD.bazel +++ b/codex-rs/rollout/BUILD.bazel @@ -1,6 +1,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( - name = "git", - crate_name = "codex_git", + name = "rollout", + crate_name = "codex_rollout", ) diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml new file mode 100644 index 0000000000..ef5a8dc22a --- /dev/null +++ b/codex-rs/rollout/Cargo.toml @@ -0,0 +1,49 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-rollout" +version.workspace = true + +[lib] +doctest = false +name = "codex_rollout" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-file-search = { workspace = true } +codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +codex-state = { workspace = true } +codex-utils-path = { workspace = true } +codex-utils-string = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +time = { workspace = true, features = [ + "formatting", + "local-offset", + "macros", + "parsing", +] } +tokio = { workspace = true, features = [ + "fs", + "io-util", + "macros", + "process", + "rt", + "sync", + "time", +] } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/rollout/src/config.rs b/codex-rs/rollout/src/config.rs new file mode 100644 index 0000000000..420949bfbd --- /dev/null +++ b/codex-rs/rollout/src/config.rs @@ -0,0 +1,100 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +pub trait RolloutConfigView { + fn codex_home(&self) -> &Path; + fn sqlite_home(&self) -> &Path; + fn cwd(&self) -> &Path; + fn model_provider_id(&self) -> &str; + fn generate_memories(&self) -> bool; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RolloutConfig { + pub codex_home: PathBuf, + pub sqlite_home: PathBuf, + pub cwd: PathBuf, + pub model_provider_id: String, + pub generate_memories: bool, +} + +pub type Config = RolloutConfig; + +impl RolloutConfig { + pub fn from_view(view: &impl RolloutConfigView) -> Self { + Self { + codex_home: view.codex_home().to_path_buf(), + sqlite_home: view.sqlite_home().to_path_buf(), + cwd: view.cwd().to_path_buf(), + model_provider_id: view.model_provider_id().to_string(), + generate_memories: view.generate_memories(), + } + } +} + +impl RolloutConfigView for RolloutConfig { + fn codex_home(&self) -> &Path { + self.codex_home.as_path() + } + + fn sqlite_home(&self) -> &Path { + self.sqlite_home.as_path() + } + + fn cwd(&self) -> &Path { + self.cwd.as_path() + } + + fn model_provider_id(&self) -> &str { + self.model_provider_id.as_str() + } + + fn generate_memories(&self) -> bool { + self.generate_memories + } +} + +impl RolloutConfigView for &T { + fn codex_home(&self) -> &Path { + (*self).codex_home() + } + + fn sqlite_home(&self) -> &Path { + (*self).sqlite_home() + } + + fn cwd(&self) -> &Path { + (*self).cwd() + } + + fn model_provider_id(&self) -> &str { + (*self).model_provider_id() + } + + fn generate_memories(&self) -> bool { + (*self).generate_memories() + } +} + +impl RolloutConfigView for Arc { + fn codex_home(&self) -> &Path { + self.as_ref().codex_home() + } + + fn sqlite_home(&self) -> &Path { + self.as_ref().sqlite_home() + } + + fn cwd(&self) -> &Path { + self.as_ref().cwd() + } + + fn model_provider_id(&self) -> &str { + self.as_ref().model_provider_id() + } + + fn generate_memories(&self) -> bool { + self.as_ref().generate_memories() + } +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/rollout/src/lib.rs similarity index 68% rename from codex-rs/core/src/rollout/mod.rs rename to codex-rs/rollout/src/lib.rs index 3b8ad9b412..160792a390 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/rollout/src/lib.rs @@ -1,9 +1,23 @@ -//! Rollout module: persistence and discovery of session rollout files. +//! Rollout persistence and discovery for Codex session files. use std::sync::LazyLock; use codex_protocol::protocol::SessionSource; +pub mod config; +pub mod list; +pub mod metadata; +pub mod policy; +pub mod recorder; +pub mod session_index; +pub mod state_db; + +pub(crate) mod default_client { + pub use codex_login::default_client::*; +} + +pub(crate) use codex_protocol::protocol; + pub const SESSIONS_SUBDIR: &str = "sessions"; pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions"; pub static INTERACTIVE_SESSION_SOURCES: LazyLock> = LazyLock::new(|| { @@ -15,26 +29,22 @@ pub static INTERACTIVE_SESSION_SOURCES: LazyLock> = LazyLock: ] }); -pub(crate) mod error; -pub mod list; -pub(crate) mod metadata; -pub(crate) mod policy; -pub mod recorder; -pub(crate) mod session_index; -pub(crate) mod truncation; - pub use codex_protocol::protocol::SessionMeta; -pub(crate) use error::map_session_init_error; +pub use config::RolloutConfig; +pub use config::RolloutConfigView; pub use list::find_archived_thread_path_by_id_str; pub use list::find_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; pub use list::rollout_date_parts; +pub use policy::EventPersistenceMode; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; pub use session_index::append_thread_name; pub use session_index::find_thread_name_by_id; +pub use session_index::find_thread_names_by_ids; pub use session_index::find_thread_path_by_name_str; +pub use state_db::StateDbHandle; #[cfg(test)] -pub mod tests; +mod tests; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/rollout/src/list.rs similarity index 98% rename from codex-rs/core/src/rollout/list.rs rename to codex-rs/rollout/src/list.rs index 8a3e41006c..e7d3dae5de 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -1,7 +1,9 @@ +#![allow(warnings, clippy::all)] + use async_trait::async_trait; use std::cmp::Reverse; use std::ffi::OsStr; -use std::io::{self}; +use std::io; use std::num::NonZero; use std::ops::ControlFlow; use std::path::Path; @@ -111,16 +113,16 @@ pub enum ThreadSortKey { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ThreadListLayout { +pub enum ThreadListLayout { NestedByDate, Flat, } -pub(crate) struct ThreadListConfig<'a> { - pub(crate) allowed_sources: &'a [SessionSource], - pub(crate) model_providers: Option<&'a [String]>, - pub(crate) default_provider: &'a str, - pub(crate) layout: ThreadListLayout, +pub struct ThreadListConfig<'a> { + pub allowed_sources: &'a [SessionSource], + pub model_providers: Option<&'a [String]>, + pub default_provider: &'a str, + pub layout: ThreadListLayout, } /// Pagination cursor identifying a file by timestamp and UUID. @@ -300,7 +302,7 @@ impl From for Cursor { /// can be supplied on the next call to resume after the last returned item, resilient to /// concurrent new sessions being appended. Ordering is stable by the requested sort key /// (timestamp desc, then UUID desc). -pub(crate) async fn get_threads( +pub async fn get_threads( codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, @@ -325,7 +327,7 @@ pub(crate) async fn get_threads( .await } -pub(crate) async fn get_threads_in_root( +pub async fn get_threads_in_root( root: PathBuf, page_size: usize, cursor: Option<&Cursor>, @@ -1039,7 +1041,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result Option { @@ -93,7 +95,7 @@ pub(crate) fn builder_from_items( )) } -pub(crate) async fn extract_metadata_from_rollout( +pub async fn extract_metadata_from_rollout( rollout_path: &Path, default_provider: &str, ) -> anyhow::Result { @@ -131,7 +133,10 @@ pub(crate) async fn extract_metadata_from_rollout( }) } -pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, config: &Config) { +pub(crate) async fn backfill_sessions( + runtime: &codex_state::StateRuntime, + config: &impl RolloutConfigView, +) { let metric_client = codex_otel::metrics::global(); let timer = metric_client .as_ref() @@ -141,7 +146,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi Err(err) => { warn!( "failed to read backfill state at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); BackfillState::default() } @@ -154,7 +159,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi Err(err) => { warn!( "failed to claim backfill worker at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); return; } @@ -162,7 +167,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi if !claimed { info!( "state db backfill already running at {}; skipping duplicate worker", - config.codex_home.display() + config.codex_home().display() ); return; } @@ -171,7 +176,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi Err(err) => { warn!( "failed to read claimed backfill state at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); BackfillState { status: BackfillStatus::Running, @@ -183,15 +188,15 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi if let Err(err) = runtime.mark_backfill_running().await { warn!( "failed to mark backfill running at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); } else { backfill_state.status = BackfillStatus::Running; } } - let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR); - let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR); + let sessions_root = config.codex_home().join(SESSIONS_SUBDIR); + let archived_root = config.codex_home().join(ARCHIVED_SESSIONS_SUBDIR); let mut rollout_paths: Vec = Vec::new(); for (root, archived) in [(sessions_root, false), (archived_root, true)] { if !tokio::fs::try_exists(&root).await.unwrap_or(false) { @@ -200,7 +205,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi match collect_rollout_paths(&root).await { Ok(paths) => { rollout_paths.extend(paths.into_iter().map(|path| BackfillRolloutPath { - watermark: backfill_watermark_for_path(config.codex_home.as_path(), &path), + watermark: backfill_watermark_for_path(config.codex_home(), &path), path, archived, })); @@ -227,9 +232,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi for batch in rollout_paths.chunks(BACKFILL_BATCH_SIZE) { for rollout in batch { stats.scanned = stats.scanned.saturating_add(1); - match extract_metadata_from_rollout(&rollout.path, config.model_provider_id.as_str()) - .await - { + match extract_metadata_from_rollout(&rollout.path, config.model_provider_id()).await { Ok(outcome) => { if outcome.parse_errors > 0 && let Some(ref metric_client) = metric_client @@ -268,9 +271,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi continue; } stats.upserted = stats.upserted.saturating_add(1); - if let Ok(meta_line) = - rollout::list::read_session_meta_line(&rollout.path).await - { + 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, @@ -308,7 +309,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi { warn!( "failed to checkpoint backfill at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); } else { last_watermark = Some(last_entry.watermark.clone()); @@ -321,7 +322,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi { warn!( "failed to mark backfill complete at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); } diff --git a/codex-rs/core/src/rollout/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs similarity index 95% rename from codex-rs/core/src/rollout/metadata_tests.rs rename to codex-rs/rollout/src/metadata_tests.rs index dacd9e67b8..e086806628 100644 --- a/codex-rs/core/src/rollout/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -1,4 +1,7 @@ +#![allow(warnings, clippy::all)] + use super::*; +use crate::config::RolloutConfig; use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; @@ -21,6 +24,16 @@ use std::path::PathBuf; use tempfile::tempdir; use uuid::Uuid; +fn test_config(codex_home: PathBuf) -> RolloutConfig { + RolloutConfig { + sqlite_home: codex_home.clone(), + cwd: codex_home.clone(), + codex_home, + model_provider_id: "test-provider".to_string(), + generate_memories: true, + } +} + #[tokio::test] async fn extract_metadata_from_rollout_uses_session_meta() { let dir = tempdir().expect("tempdir"); @@ -197,9 +210,7 @@ async fn backfill_sessions_resumes_from_watermark_and_marks_complete() { )) .await; - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); + let config = test_config(codex_home.clone()); backfill_sessions(runtime.as_ref(), &config).await; let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id"); @@ -245,7 +256,7 @@ async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_f "2026-01-27T12:34:56Z", thread_uuid, Some(GitInfo { - commit_hash: Some("rollout-sha".to_string()), + commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")), branch: Some("rollout-branch".to_string()), repository_url: Some("git@example.com:openai/codex.git".to_string()), }), @@ -267,9 +278,7 @@ async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_f .await .expect("existing metadata upsert"); - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); + let config = test_config(codex_home.clone()); backfill_sessions(runtime.as_ref(), &config).await; let persisted = runtime @@ -304,9 +313,7 @@ async fn backfill_sessions_normalizes_cwd_before_upsert() { .await .expect("initialize runtime"); - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); + let config = test_config(codex_home.clone()); backfill_sessions(runtime.as_ref(), &config).await; let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/rollout/src/policy.rs similarity index 95% rename from codex-rs/core/src/rollout/policy.rs rename to codex-rs/rollout/src/policy.rs index 8b1f94dbd5..c4b4b8c339 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -11,8 +11,7 @@ pub enum EventPersistenceMode { /// Whether a rollout `item` should be persisted in rollout files for the /// provided persistence `mode`. -#[inline] -pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool { +pub fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool { match item { RolloutItem::ResponseItem(item) => should_persist_response_item(item), RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, mode), @@ -25,7 +24,7 @@ pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersiste /// Whether a `ResponseItem` should be persisted in rollout files. #[inline] -pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { +pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } | ResponseItem::Reasoning { .. } @@ -46,7 +45,7 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { /// Whether a `ResponseItem` should be persisted for the memories. #[inline] -pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { +pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => role != "developer", ResponseItem::LocalShellCall { .. } @@ -68,7 +67,7 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> /// Whether an `EventMsg` should be persisted in rollout files for the /// provided persistence `mode`. #[inline] -pub(crate) fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool { +pub fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool { match mode { EventPersistenceMode::Limited => should_persist_event_msg_limited(ev), EventPersistenceMode::Extended => should_persist_event_msg_extended(ev), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/rollout/src/recorder.rs similarity index 96% rename from codex-rs/core/src/rollout/recorder.rs rename to codex-rs/rollout/src/recorder.rs index 72a3e3c637..f39c38af60 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -1,7 +1,7 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. +use std::fs; use std::fs::File; -use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; @@ -11,14 +11,15 @@ use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; +use codex_utils_string::truncate_middle_chars; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; use time::format_description::well_known::Rfc3339; use time::macros::format_description; use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc; use tokio::sync::mpsc::Sender; -use tokio::sync::mpsc::{self}; use tokio::sync::oneshot; use tracing::info; use tracing::trace; @@ -39,15 +40,13 @@ use super::list::parse_timestamp_uuid_from_filename; use super::metadata; use super::policy::EventPersistenceMode; use super::policy::is_persisted_response_item; -use crate::config::Config; +use crate::config::RolloutConfigView; use crate::default_client::originator; -use crate::git_info::collect_git_info; -use crate::path_utils; use crate::state_db; use crate::state_db::StateDbHandle; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; +use codex_git_utils::collect_git_info; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::GitInfo as ProtocolGitInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; @@ -57,6 +56,7 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; +use codex_utils_path as path_utils; /// Records all [`ResponseItem`]s for a session and flushes them to disk after /// every update. @@ -145,9 +145,9 @@ fn sanitize_rollout_item_for_persistence( match item { RolloutItem::EventMsg(EventMsg::ExecCommandEnd(mut event)) => { // Persist only a bounded aggregated summary of command output. - event.aggregated_output = truncate_text( + event.aggregated_output = truncate_middle_chars( &event.aggregated_output, - TruncationPolicy::Bytes(PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES), + PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES, ); // Drop unnecessary fields from rollout storage since aggregated_output is all we need. event.stdout.clear(); @@ -163,7 +163,7 @@ impl RolloutRecorder { /// List threads (rollout files) under the provided Codex home directory. #[allow(clippy::too_many_arguments)] pub async fn list_threads( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -189,7 +189,7 @@ impl RolloutRecorder { /// List archived threads (rollout files) under the archived sessions directory. #[allow(clippy::too_many_arguments)] pub async fn list_archived_threads( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -214,7 +214,7 @@ impl RolloutRecorder { #[allow(clippy::too_many_arguments)] async fn list_threads_with_db_fallback( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -224,7 +224,7 @@ impl RolloutRecorder { archived: bool, search_term: Option<&str>, ) -> std::io::Result { - let codex_home = config.codex_home.as_path(); + let codex_home = config.codex_home(); // Filesystem-first listing intentionally overfetches so we can repair stale/missing // SQLite rollout paths before the final DB-backed page is returned. let fs_page_size = page_size.saturating_mul(2).max(page_size); @@ -298,7 +298,7 @@ impl RolloutRecorder { /// Find the newest recorded thread path, optionally filtering to a matching cwd. #[allow(clippy::too_many_arguments)] pub async fn find_latest_thread_path( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -307,7 +307,7 @@ impl RolloutRecorder { default_provider: &str, filter_cwd: Option<&Path>, ) -> std::io::Result> { - let codex_home = config.codex_home.as_path(); + let codex_home = config.codex_home(); let state_db_ctx = state_db::get_state_db(config).await; if state_db_ctx.is_some() { let mut db_cursor = cursor.cloned(); @@ -368,7 +368,7 @@ impl RolloutRecorder { /// /// For resumed sessions, this immediately opens the existing rollout file. pub async fn new( - config: &Config, + config: &impl RolloutConfigView, params: RolloutRecorderParams, state_db_ctx: Option, state_builder: Option, @@ -400,21 +400,21 @@ impl RolloutRecorder { id: session_id, forked_from_id, timestamp, - cwd: config.cwd.clone(), + cwd: config.cwd().to_path_buf(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), source, - model_provider: Some(config.model_provider_id.clone()), + model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), dynamic_tools: if dynamic_tools.is_empty() { None } else { Some(dynamic_tools) }, - memory_mode: (!config.memories.generate_memories) + memory_mode: (!config.generate_memories()) .then_some("disabled".to_string()), }; @@ -444,7 +444,7 @@ impl RolloutRecorder { }; // Clone the cwd for the spawned task to collect git info asynchronously - let cwd = config.cwd.clone(); + let cwd = config.cwd().to_path_buf(); // A reasonably-sized bounded channel. If the buffer fills up the send // future will yield, which is fine – we only need to ensure we do not @@ -462,8 +462,8 @@ impl RolloutRecorder { rollout_path.clone(), state_db_ctx.clone(), state_builder, - config.model_provider_id.clone(), - config.memories.generate_memories, + config.model_provider_id().to_string(), + config.generate_memories(), )); Ok(Self { @@ -482,7 +482,7 @@ impl RolloutRecorder { self.state_db.clone() } - pub(crate) async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { + pub async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { let mut filtered = Vec::new(); for item in items { // Note that function calls may look a bit strange if they are @@ -528,7 +528,7 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}"))) } - pub(crate) async fn load_rollout_items( + pub async fn load_rollout_items( path: &Path, ) -> std::io::Result<(Vec, Option, usize)> { trace!("Resuming rollout from {path:?}"); @@ -660,13 +660,13 @@ struct LogFileInfo { } fn precompute_log_file_info( - config: &Config, + config: &impl RolloutConfigView, conversation_id: ThreadId, ) -> std::io::Result { // Resolve ~/.codex/sessions/YYYY/MM/DD path. let timestamp = OffsetDateTime::now_local() .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?; - let mut dir = config.codex_home.clone(); + let mut dir = config.codex_home().to_path_buf(); dir.push(SESSIONS_SUBDIR); dir.push(timestamp.year().to_string()); dir.push(format!("{:02}", u8::from(timestamp.month()))); @@ -846,7 +846,11 @@ async fn write_session_meta( default_provider: &str, generate_memories: bool, ) -> std::io::Result<()> { - let git_info = collect_git_info(cwd).await; + let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo { + commit_hash: info.commit_hash, + branch: info.branch, + repository_url: info.repository_url, + }); let session_meta_line = SessionMetaLine { meta: session_meta, git: git_info, diff --git a/codex-rs/core/src/rollout/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs similarity index 91% rename from codex-rs/core/src/rollout/recorder_tests.rs rename to codex-rs/rollout/src/recorder_tests.rs index 8ca7b58a6b..5a868b1623 100644 --- a/codex-rs/core/src/rollout/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1,7 +1,8 @@ +#![allow(warnings, clippy::all)] + use super::*; -use crate::config::ConfigBuilder; +use crate::config::RolloutConfig; use chrono::TimeZone; -use codex_features::Feature; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AskForApproval; @@ -10,8 +11,8 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; +use std::fs; use std::fs::File; -use std::fs::{self}; use std::io::Write; use std::path::Path; use std::path::PathBuf; @@ -19,6 +20,16 @@ use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; +fn test_config(codex_home: &Path) -> RolloutConfig { + RolloutConfig { + codex_home: codex_home.to_path_buf(), + sqlite_home: codex_home.to_path_buf(), + cwd: codex_home.to_path_buf(), + model_provider_id: "test-provider".to_string(), + generate_memories: true, + } +} + fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result { let day_dir = root.join("sessions/2025/01/03"); fs::create_dir_all(&day_dir)?; @@ -54,10 +65,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; + let config = test_config(home.path()); let thread_id = ThreadId::new(); let recorder = RolloutRecorder::new( &config, @@ -141,14 +149,7 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result< #[tokio::test] async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) .await @@ -229,14 +230,7 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) .await @@ -280,14 +274,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> #[tokio::test] async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .disable(Feature::Sqlite) - .expect("test config should allow sqlite to be disabled"); + let config = test_config(home.path()); let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?; let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?; @@ -328,14 +315,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re #[tokio::test] async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let uuid = Uuid::from_u128(9010); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); @@ -396,14 +376,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul #[tokio::test] async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let uuid = Uuid::from_u128(9011); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); diff --git a/codex-rs/core/src/rollout/session_index.rs b/codex-rs/rollout/src/session_index.rs similarity index 100% rename from codex-rs/core/src/rollout/session_index.rs rename to codex-rs/rollout/src/session_index.rs diff --git a/codex-rs/core/src/rollout/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs similarity index 99% rename from codex-rs/core/src/rollout/session_index_tests.rs rename to codex-rs/rollout/src/session_index_tests.rs index 864c4c5cf8..6c434ffebf 100644 --- a/codex-rs/core/src/rollout/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -1,3 +1,5 @@ +#![allow(warnings, clippy::all)] + use super::*; use pretty_assertions::assert_eq; use std::collections::HashMap; diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/rollout/src/state_db.rs similarity index 95% rename from codex-rs/core/src/state_db.rs rename to codex-rs/rollout/src/state_db.rs index 7230186204..6367a27ca9 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -1,8 +1,8 @@ -use crate::config::Config; -use crate::path_utils::normalize_for_path_comparison; -use crate::rollout::list::Cursor; -use crate::rollout::list::ThreadSortKey; -use crate::rollout::metadata; +use crate::config::RolloutConfig; +use crate::config::RolloutConfigView; +use crate::list::Cursor; +use crate::list::ThreadSortKey; +use crate::metadata; use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; @@ -13,6 +13,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; pub use codex_state::LogEntry; use codex_state::ThreadMetadataBuilder; +use codex_utils_path::normalize_for_path_comparison; use serde_json::Value; use std::path::Path; use std::path::PathBuf; @@ -23,9 +24,9 @@ use uuid::Uuid; /// Core-facing handle to the SQLite-backed state runtime. pub type StateDbHandle = Arc; -/// Initialize the state runtime for thread state persistence and backfill checks. To only be used -/// inside `core`. The initialization should not be done anywhere else. -pub(crate) async fn init(config: &Config) -> Option { +/// Initialize the state runtime for thread state persistence and backfill checks. +pub async fn init(config: &impl RolloutConfigView) -> Option { + let config = RolloutConfig::from_view(config); let runtime = match codex_state::StateRuntime::init( config.sqlite_home.clone(), config.model_provider_id.clone(), @@ -62,18 +63,18 @@ pub(crate) async fn init(config: &Config) -> Option { } /// Get the DB if the feature is enabled and the DB exists. -pub async fn get_state_db(config: &Config) -> Option { - let state_path = codex_state::state_db_path(config.sqlite_home.as_path()); +pub async fn get_state_db(config: &impl RolloutConfigView) -> Option { + let state_path = codex_state::state_db_path(config.sqlite_home()); if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) { return None; } let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), + config.sqlite_home().to_path_buf(), + config.model_provider_id().to_string(), ) .await .ok()?; - require_backfill_complete(runtime, config.sqlite_home.as_path()).await + require_backfill_complete(runtime, config.sqlite_home()).await } /// Open the state runtime when the SQLite file exists, without feature gating. @@ -135,7 +136,7 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { Some(codex_state::Anchor { ts, id }) } -pub(crate) fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { +pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { normalize_for_path_comparison(cwd).unwrap_or_else(|_| cwd.to_path_buf()) } @@ -398,7 +399,7 @@ pub async fn reconcile_rollout( ); return; } - if let Ok(meta_line) = crate::rollout::list::read_session_meta_line(rollout_path).await { + if let Ok(meta_line) = crate::list::read_session_meta_line(rollout_path).await { persist_dynamic_tools( Some(ctx), meta_line.meta.id, @@ -463,7 +464,7 @@ pub async fn read_repair_rollout_path( if !saw_existing_metadata { warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (slow path)"); } - let default_provider = crate::rollout::list::read_session_meta_line(rollout_path) + let default_provider = crate::list::read_session_meta_line(rollout_path) .await .ok() .and_then(|meta| meta.meta.model_provider) diff --git a/codex-rs/core/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs similarity index 80% rename from codex-rs/core/src/state_db_tests.rs rename to codex-rs/rollout/src/state_db_tests.rs index adf08197d6..e5e3211768 100644 --- a/codex-rs/core/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -1,6 +1,13 @@ +#![allow(warnings, clippy::all)] + use super::*; -use crate::rollout::list::parse_cursor; +use crate::list::parse_cursor; +use chrono::DateTime; +use chrono::NaiveDateTime; +use chrono::Timelike; +use chrono::Utc; use pretty_assertions::assert_eq; +use uuid::Uuid; #[test] fn cursor_to_anchor_normalizes_timestamp_format() { diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/rollout/src/tests.rs similarity index 98% rename from codex-rs/core/src/rollout/tests.rs rename to codex-rs/rollout/src/tests.rs index 44e536e50e..6a5f25286d 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1,9 +1,10 @@ +#![allow(warnings, clippy::all)] #![allow(clippy::unwrap_used, clippy::expect_used)] use std::ffi::OsStr; +use std::fs; use std::fs::File; use std::fs::FileTimes; -use std::fs::{self}; use std::io::Write; use std::path::Path; @@ -17,14 +18,15 @@ use time::format_description::FormatItem; use time::macros::format_description; use uuid::Uuid; -use crate::rollout::INTERACTIVE_SESSION_SOURCES; -use crate::rollout::list::Cursor; -use crate::rollout::list::ThreadItem; -use crate::rollout::list::ThreadSortKey; -use crate::rollout::list::ThreadsPage; -use crate::rollout::list::get_threads; -use crate::rollout::list::read_head_for_summary; -use crate::rollout::rollout_date_parts; +use crate::INTERACTIVE_SESSION_SOURCES; +use crate::find_thread_path_by_id_str; +use crate::list::Cursor; +use crate::list::ThreadItem; +use crate::list::ThreadSortKey; +use crate::list::ThreadsPage; +use crate::list::get_threads; +use crate::list::read_head_for_summary; +use crate::rollout_date_parts; use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; @@ -229,7 +231,7 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() { )); insert_state_db_thread(home, thread_id, stale_db_path.as_path(), false).await; - let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string()) + let found = find_thread_path_by_id_str(home, &uuid.to_string()) .await .expect("lookup should succeed"); assert_eq!(found, Some(fs_rollout_path.clone())); @@ -255,7 +257,7 @@ async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() { .await .expect("backfill should be complete"); - let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string()) + let found = find_thread_path_by_id_str(home, &uuid.to_string()) .await .expect("lookup should succeed"); assert_eq!(found, Some(fs_rollout_path.clone())); diff --git a/codex-rs/sandboxing/BUILD.bazel b/codex-rs/sandboxing/BUILD.bazel new file mode 100644 index 0000000000..57837fa809 --- /dev/null +++ b/codex-rs/sandboxing/BUILD.bazel @@ -0,0 +1,11 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "sandboxing", + crate_name = "codex_sandboxing", + compile_data = [ + "src/restricted_read_only_platform_defaults.sbpl", + "src/seatbelt_base_policy.sbpl", + "src/seatbelt_network_policy.sbpl", + ], +) diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml new file mode 100644 index 0000000000..fc340d6502 --- /dev/null +++ b/codex-rs/sandboxing/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-sandboxing" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_sandboxing" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-network-proxy = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +libc = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true, features = ["log"] } +url = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs new file mode 100644 index 0000000000..8636f9ed04 --- /dev/null +++ b/codex-rs/sandboxing/src/landlock.rs @@ -0,0 +1,112 @@ +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use std::path::Path; +pub fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { + // When managed network requirements are active, request proxy-only + // networking from the Linux sandbox helper. Without managed requirements, + // preserve existing behavior. + enforce_managed_network +} + +/// Converts the sandbox policies into the CLI invocation for +/// `codex-linux-sandbox`. +/// +/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after +/// parsing these arguments. Policy JSON flags are emitted before helper feature +/// flags so the argv order matches the helper's CLI shape. See +/// `docs/linux_sandbox.md` for the Linux semantics. +#[allow(clippy::too_many_arguments)] +pub fn create_linux_sandbox_command_args_for_policies( + command: Vec, + command_cwd: &Path, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + use_legacy_landlock: bool, + allow_network_for_proxy: bool, +) -> Vec { + let sandbox_policy_json = serde_json::to_string(sandbox_policy) + .unwrap_or_else(|err| panic!("failed to serialize sandbox policy: {err}")); + let file_system_policy_json = serde_json::to_string(file_system_sandbox_policy) + .unwrap_or_else(|err| panic!("failed to serialize filesystem sandbox policy: {err}")); + let network_policy_json = serde_json::to_string(&network_sandbox_policy) + .unwrap_or_else(|err| panic!("failed to serialize network sandbox policy: {err}")); + let sandbox_policy_cwd = sandbox_policy_cwd + .to_str() + .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) + .to_string(); + let command_cwd = command_cwd + .to_str() + .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) + .to_string(); + + let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), + sandbox_policy_cwd, + "--command-cwd".to_string(), + command_cwd, + "--sandbox-policy".to_string(), + sandbox_policy_json, + "--file-system-sandbox-policy".to_string(), + file_system_policy_json, + "--network-sandbox-policy".to_string(), + network_policy_json, + ]; + if use_legacy_landlock { + linux_cmd.push("--use-legacy-landlock".to_string()); + } + if allow_network_for_proxy { + linux_cmd.push("--allow-network-for-proxy".to_string()); + } + linux_cmd.push("--".to_string()); + linux_cmd.extend(command); + linux_cmd +} + +/// Converts the sandbox cwd and execution options into the CLI invocation for +/// `codex-linux-sandbox`. +#[cfg_attr(not(test), allow(dead_code))] +fn create_linux_sandbox_command_args( + command: Vec, + command_cwd: &Path, + sandbox_policy_cwd: &Path, + use_legacy_landlock: bool, + allow_network_for_proxy: bool, +) -> Vec { + let command_cwd = command_cwd + .to_str() + .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) + .to_string(); + let sandbox_policy_cwd = sandbox_policy_cwd + .to_str() + .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) + .to_string(); + + let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), + sandbox_policy_cwd, + "--command-cwd".to_string(), + command_cwd, + ]; + if use_legacy_landlock { + linux_cmd.push("--use-legacy-landlock".to_string()); + } + if allow_network_for_proxy { + linux_cmd.push("--allow-network-for-proxy".to_string()); + } + + // Separator so that command arguments starting with `-` are not parsed as + // options of the helper itself. + linux_cmd.push("--".to_string()); + + // Append the original tool command. + linux_cmd.extend(command); + + linux_cmd +} + +#[cfg(test)] +#[path = "landlock_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs similarity index 100% rename from codex-rs/core/src/landlock_tests.rs rename to codex-rs/sandboxing/src/landlock_tests.rs diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs new file mode 100644 index 0000000000..5eca9d480c --- /dev/null +++ b/codex-rs/sandboxing/src/lib.rs @@ -0,0 +1,17 @@ +pub mod landlock; +pub mod macos_permissions; +mod manager; +pub mod policy_transforms; +#[cfg(target_os = "macos")] +pub mod seatbelt; +#[cfg(target_os = "macos")] +mod seatbelt_permissions; + +pub use manager::SandboxCommand; +pub use manager::SandboxExecRequest; +pub use manager::SandboxManager; +pub use manager::SandboxTransformError; +pub use manager::SandboxTransformRequest; +pub use manager::SandboxType; +pub use manager::SandboxablePreference; +pub use manager::get_platform_sandbox; diff --git a/codex-rs/core/src/sandboxing/macos_permissions.rs b/codex-rs/sandboxing/src/macos_permissions.rs similarity index 98% rename from codex-rs/core/src/sandboxing/macos_permissions.rs rename to codex-rs/sandboxing/src/macos_permissions.rs index 1a409d4bdf..22314937ef 100644 --- a/codex-rs/core/src/sandboxing/macos_permissions.rs +++ b/codex-rs/sandboxing/src/macos_permissions.rs @@ -7,7 +7,7 @@ use codex_protocol::models::MacOsSeatbeltProfileExtensions; /// Merges macOS seatbelt profile extensions by taking the permissive union of /// each permission field. -pub(crate) fn merge_macos_seatbelt_profile_extensions( +pub fn merge_macos_seatbelt_profile_extensions( base: Option<&MacOsSeatbeltProfileExtensions>, permissions: Option<&MacOsSeatbeltProfileExtensions>, ) -> Option { @@ -38,7 +38,7 @@ pub(crate) fn merge_macos_seatbelt_profile_extensions( } } -pub(crate) fn intersect_macos_seatbelt_profile_extensions( +pub fn intersect_macos_seatbelt_profile_extensions( requested: Option, granted: Option, ) -> Option { diff --git a/codex-rs/core/src/sandboxing/macos_permissions_tests.rs b/codex-rs/sandboxing/src/macos_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/sandboxing/macos_permissions_tests.rs rename to codex-rs/sandboxing/src/macos_permissions_tests.rs diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs new file mode 100644 index 0000000000..f55f53cb13 --- /dev/null +++ b/codex-rs/sandboxing/src/manager.rs @@ -0,0 +1,276 @@ +use crate::landlock::allow_network_for_proxy; +use crate::landlock::create_linux_sandbox_command_args_for_policies; +use crate::policy_transforms::EffectiveSandboxPermissions; +use crate::policy_transforms::effective_file_system_sandbox_policy; +use crate::policy_transforms::effective_network_sandbox_policy; +use crate::policy_transforms::should_require_platform_sandbox; +#[cfg(target_os = "macos")] +use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +#[cfg(target_os = "macos")] +use crate::seatbelt::create_seatbelt_command_args_for_policies_with_extensions; +use codex_network_proxy::NetworkProxy; +use codex_protocol::config_types::WindowsSandboxLevel; +#[cfg(target_os = "macos")] +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SandboxType { + None, + MacosSeatbelt, + LinuxSeccomp, + WindowsRestrictedToken, +} + +impl SandboxType { + pub fn as_metric_tag(self) -> &'static str { + match self { + SandboxType::None => "none", + SandboxType::MacosSeatbelt => "seatbelt", + SandboxType::LinuxSeccomp => "seccomp", + SandboxType::WindowsRestrictedToken => "windows_sandbox", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SandboxablePreference { + Auto, + Require, + Forbid, +} + +pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option { + if cfg!(target_os = "macos") { + Some(SandboxType::MacosSeatbelt) + } else if cfg!(target_os = "linux") { + Some(SandboxType::LinuxSeccomp) + } else if cfg!(target_os = "windows") { + if windows_sandbox_enabled { + Some(SandboxType::WindowsRestrictedToken) + } else { + None + } + } else { + None + } +} + +#[derive(Debug)] +pub struct SandboxCommand { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub additional_permissions: Option, +} + +#[derive(Debug)] +pub struct SandboxExecRequest { + pub command: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub network: Option, + pub sandbox: SandboxType, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, + pub sandbox_policy: SandboxPolicy, + pub file_system_sandbox_policy: FileSystemSandboxPolicy, + pub network_sandbox_policy: NetworkSandboxPolicy, + pub arg0: Option, +} + +/// Bundled arguments for sandbox transformation. +/// +/// This keeps call sites self-documenting when several fields are optional. +pub struct SandboxTransformRequest<'a> { + pub command: SandboxCommand, + pub policy: &'a SandboxPolicy, + pub file_system_policy: &'a FileSystemSandboxPolicy, + pub network_policy: NetworkSandboxPolicy, + pub sandbox: SandboxType, + pub enforce_managed_network: bool, + // TODO(viyatb): Evaluate switching this to Option> + // to make shared ownership explicit across runtime/sandbox plumbing. + pub network: Option<&'a NetworkProxy>, + pub sandbox_policy_cwd: &'a Path, + #[cfg(target_os = "macos")] + pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>, + pub codex_linux_sandbox_exe: Option<&'a PathBuf>, + pub use_legacy_landlock: bool, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, +} + +#[derive(Debug)] +pub enum SandboxTransformError { + MissingLinuxSandboxExecutable, + #[cfg(not(target_os = "macos"))] + SeatbeltUnavailable, +} + +impl std::fmt::Display for SandboxTransformError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingLinuxSandboxExecutable => { + write!(f, "missing codex-linux-sandbox executable path") + } + #[cfg(not(target_os = "macos"))] + Self::SeatbeltUnavailable => write!(f, "seatbelt sandbox is only available on macOS"), + } + } +} + +impl std::error::Error for SandboxTransformError {} + +#[derive(Default)] +pub struct SandboxManager; + +impl SandboxManager { + pub fn new() -> Self { + Self + } + + pub fn select_initial( + &self, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + pref: SandboxablePreference, + windows_sandbox_level: WindowsSandboxLevel, + has_managed_network_requirements: bool, + ) -> SandboxType { + match pref { + SandboxablePreference::Forbid => SandboxType::None, + SandboxablePreference::Require => { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } + SandboxablePreference::Auto => { + if should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ) { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } + } + } + } + + pub fn transform( + &self, + request: SandboxTransformRequest<'_>, + ) -> Result { + let SandboxTransformRequest { + mut command, + policy, + file_system_policy, + network_policy, + sandbox, + enforce_managed_network, + network, + sandbox_policy_cwd, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions, + codex_linux_sandbox_exe, + use_legacy_landlock, + windows_sandbox_level, + windows_sandbox_private_desktop, + } = request; + #[cfg(not(target_os = "macos"))] + let macos_seatbelt_profile_extensions = None; + let additional_permissions = command.additional_permissions.take(); + let EffectiveSandboxPermissions { + sandbox_policy: effective_policy, + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: effective_macos_seatbelt_profile_extensions, + #[cfg(not(target_os = "macos"))] + macos_seatbelt_profile_extensions: _, + } = EffectiveSandboxPermissions::new( + policy, + macos_seatbelt_profile_extensions, + additional_permissions.as_ref(), + ); + let effective_file_system_policy = effective_file_system_sandbox_policy( + file_system_policy, + additional_permissions.as_ref(), + ); + let effective_network_policy = + effective_network_sandbox_policy(network_policy, additional_permissions.as_ref()); + let mut argv = Vec::with_capacity(1 + command.args.len()); + argv.push(command.program); + argv.append(&mut command.args); + + let (argv, arg0_override) = match sandbox { + SandboxType::None => (argv, None), + #[cfg(target_os = "macos")] + SandboxType::MacosSeatbelt => { + let mut args = create_seatbelt_command_args_for_policies_with_extensions( + argv.clone(), + &effective_file_system_policy, + effective_network_policy, + sandbox_policy_cwd, + enforce_managed_network, + network, + effective_macos_seatbelt_profile_extensions.as_ref(), + ); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string()); + full_command.append(&mut args); + (full_command, None) + } + #[cfg(not(target_os = "macos"))] + SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable), + SandboxType::LinuxSeccomp => { + let exe = codex_linux_sandbox_exe + .ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?; + let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); + let mut args = create_linux_sandbox_command_args_for_policies( + argv.clone(), + command.cwd.as_path(), + &effective_policy, + &effective_file_system_policy, + effective_network_policy, + sandbox_policy_cwd, + use_legacy_landlock, + allow_proxy_network, + ); + let mut full_command = Vec::with_capacity(1 + args.len()); + full_command.push(exe.to_string_lossy().to_string()); + full_command.append(&mut args); + (full_command, Some("codex-linux-sandbox".to_string())) + } + #[cfg(target_os = "windows")] + SandboxType::WindowsRestrictedToken => (argv, None), + #[cfg(not(target_os = "windows"))] + SandboxType::WindowsRestrictedToken => (argv, None), + }; + + Ok(SandboxExecRequest { + command: argv, + cwd: command.cwd, + env: command.env, + network: network.cloned(), + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy: effective_policy, + file_system_sandbox_policy: effective_file_system_policy, + network_sandbox_policy: effective_network_policy, + arg0: arg0_override, + }) + } +} + +#[cfg(test)] +#[path = "manager_tests.rs"] +mod tests; diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs new file mode 100644 index 0000000000..17921b1914 --- /dev/null +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -0,0 +1,251 @@ +use super::SandboxCommand; +use super::SandboxManager; +use super::SandboxTransformRequest; +use super::SandboxType; +use super::SandboxablePreference; +use super::get_platform_sandbox; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::NetworkPermissions; +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::protocol::NetworkAccess; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use tempfile::TempDir; + +#[test] +fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { + let manager = SandboxManager::new(); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, SandboxType::None); +} + +#[test] +fn danger_full_access_uses_platform_sandbox_with_network_requirements() { + let manager = SandboxManager::new(); + let expected = get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::unrestricted(), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + true, + ); + assert_eq!(sandbox, expected); +} + +#[test] +fn restricted_file_system_uses_platform_sandbox_without_managed_network() { + let manager = SandboxManager::new(); + let expected = get_platform_sandbox(false).unwrap_or(SandboxType::None); + let sandbox = manager.select_initial( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Enabled, + SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + false, + ); + assert_eq!(sandbox, expected); +} + +#[test] +fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let exec_request = manager + .transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + additional_permissions: None, + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::unrestricted() + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} + +#[test] +fn transform_additional_permissions_enable_network_for_external_sandbox() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let path = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let exec_request = manager + .transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + additional_permissions: Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: Some(FileSystemPermissions { + read: Some(vec![path]), + write: Some(Vec::new()), + }), + ..Default::default() + }), + }, + policy: &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + file_system_policy: &FileSystemSandboxPolicy::unrestricted(), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .expect("transform"); + + assert_eq!( + exec_request.sandbox_policy, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Enabled + ); +} + +#[test] +fn transform_additional_permissions_preserves_denied_entries() { + let manager = SandboxManager::new(); + let cwd = std::env::current_dir().expect("current dir"); + let temp_dir = TempDir::new().expect("create temp dir"); + let workspace_root = AbsolutePathBuf::from_absolute_path( + canonicalize(temp_dir.path()).expect("canonicalize temp dir"), + ) + .expect("absolute temp dir"); + let allowed_path = workspace_root.join("allowed").expect("allowed path"); + let denied_path = workspace_root.join("denied").expect("denied path"); + let exec_request = manager + .transform(SandboxTransformRequest { + command: SandboxCommand { + program: "true".to_string(), + args: Vec::new(), + cwd: cwd.clone(), + env: HashMap::new(), + additional_permissions: Some(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: None, + write: Some(vec![allowed_path.clone()]), + }), + ..Default::default() + }), + }, + policy: &SandboxPolicy::ReadOnly { + access: ReadOnlyAccess::FullAccess, + network_access: false, + }, + file_system_policy: &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: denied_path.clone(), + }, + access: FileSystemAccessMode::None, + }, + ]), + network_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: cwd.as_path(), + #[cfg(target_os = "macos")] + macos_seatbelt_profile_extensions: None, + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .expect("transform"); + + assert_eq!( + exec_request.file_system_sandbox_policy, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: denied_path }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: allowed_path }, + access: FileSystemAccessMode::Write, + }, + ]) + ); + assert_eq!( + exec_request.network_sandbox_policy, + NetworkSandboxPolicy::Restricted + ); +} diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs new file mode 100644 index 0000000000..045c7e3f90 --- /dev/null +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -0,0 +1,463 @@ +use crate::macos_permissions::intersect_macos_seatbelt_profile_extensions; +use crate::macos_permissions::merge_macos_seatbelt_profile_extensions; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use dunce::canonicalize; +use std::collections::HashSet; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveSandboxPermissions { + pub sandbox_policy: SandboxPolicy, + pub macos_seatbelt_profile_extensions: Option, +} + +impl EffectiveSandboxPermissions { + pub fn new( + sandbox_policy: &SandboxPolicy, + macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>, + additional_permissions: Option<&PermissionProfile>, + ) -> Self { + let Some(additional_permissions) = additional_permissions else { + return Self { + sandbox_policy: sandbox_policy.clone(), + macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions.cloned(), + }; + }; + + Self { + sandbox_policy: effective_sandbox_policy(sandbox_policy, Some(additional_permissions)), + macos_seatbelt_profile_extensions: merge_macos_seatbelt_profile_extensions( + macos_seatbelt_profile_extensions, + additional_permissions.macos.as_ref(), + ), + } + } +} + +pub fn normalize_additional_permissions( + additional_permissions: PermissionProfile, +) -> Result { + let network = additional_permissions + .network + .filter(|network| !network.is_empty()); + let file_system = additional_permissions + .file_system + .map(|file_system| { + let read = file_system + .read + .map(|paths| normalize_permission_paths(paths, "file_system.read")); + let write = file_system + .write + .map(|paths| normalize_permission_paths(paths, "file_system.write")); + FileSystemPermissions { read, write } + }) + .filter(|file_system| !file_system.is_empty()); + let macos = additional_permissions.macos; + + Ok(PermissionProfile { + network, + file_system, + macos, + }) +} + +pub fn merge_permission_profiles( + base: Option<&PermissionProfile>, + permissions: Option<&PermissionProfile>, +) -> Option { + let Some(permissions) = permissions else { + return base.cloned(); + }; + + match base { + Some(base) => { + let network = match (base.network.as_ref(), permissions.network.as_ref()) { + ( + Some(NetworkPermissions { + enabled: Some(true), + }), + _, + ) + | ( + _, + Some(NetworkPermissions { + enabled: Some(true), + }), + ) => Some(NetworkPermissions { + enabled: Some(true), + }), + _ => None, + }; + let file_system = match (base.file_system.as_ref(), permissions.file_system.as_ref()) { + (Some(base), Some(permissions)) => Some(FileSystemPermissions { + read: merge_permission_paths(base.read.as_ref(), permissions.read.as_ref()), + write: merge_permission_paths(base.write.as_ref(), permissions.write.as_ref()), + }) + .filter(|file_system| !file_system.is_empty()), + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + }; + let macos = merge_macos_seatbelt_profile_extensions( + base.macos.as_ref(), + permissions.macos.as_ref(), + ); + + Some(PermissionProfile { + network, + file_system, + macos, + }) + .filter(|permissions| !permissions.is_empty()) + } + None => Some(permissions.clone()).filter(|permissions| !permissions.is_empty()), + } +} + +pub fn intersect_permission_profiles( + requested: PermissionProfile, + granted: PermissionProfile, +) -> PermissionProfile { + let file_system = requested + .file_system + .map(|requested_file_system| { + let granted_file_system = granted.file_system.unwrap_or_default(); + let read = requested_file_system + .read + .map(|requested_read| { + let granted_read = granted_file_system.read.unwrap_or_default(); + requested_read + .into_iter() + .filter(|path| granted_read.contains(path)) + .collect() + }) + .filter(|paths: &Vec<_>| !paths.is_empty()); + let write = requested_file_system + .write + .map(|requested_write| { + let granted_write = granted_file_system.write.unwrap_or_default(); + requested_write + .into_iter() + .filter(|path| granted_write.contains(path)) + .collect() + }) + .filter(|paths: &Vec<_>| !paths.is_empty()); + FileSystemPermissions { read, write } + }) + .filter(|file_system| !file_system.is_empty()); + let network = match (requested.network, granted.network) { + ( + Some(NetworkPermissions { + enabled: Some(true), + }), + Some(NetworkPermissions { + enabled: Some(true), + }), + ) => Some(NetworkPermissions { + enabled: Some(true), + }), + _ => None, + }; + + let macos = intersect_macos_seatbelt_profile_extensions(requested.macos, granted.macos); + + PermissionProfile { + network, + file_system, + macos, + } +} + +fn normalize_permission_paths( + paths: Vec, + _permission_kind: &str, +) -> Vec { + let mut out = Vec::with_capacity(paths.len()); + let mut seen = HashSet::new(); + + for path in paths { + let canonicalized = canonicalize(path.as_path()) + .ok() + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .unwrap_or(path); + if seen.insert(canonicalized.clone()) { + out.push(canonicalized); + } + } + + out +} + +fn merge_permission_paths( + base: Option<&Vec>, + permissions: Option<&Vec>, +) -> Option> { + match (base, permissions) { + (Some(base), Some(permissions)) => { + let mut merged = Vec::with_capacity(base.len() + permissions.len()); + let mut seen = HashSet::with_capacity(base.len() + permissions.len()); + + for path in base.iter().chain(permissions.iter()) { + if seen.insert(path.clone()) { + merged.push(path.clone()); + } + } + + Some(merged).filter(|paths| !paths.is_empty()) + } + (Some(base), None) => Some(base.clone()), + (None, Some(permissions)) => Some(permissions.clone()), + (None, None) => None, + } +} + +fn dedup_absolute_paths(paths: Vec) -> Vec { + let mut out = Vec::with_capacity(paths.len()); + let mut seen = HashSet::new(); + for path in paths { + if seen.insert(path.to_path_buf()) { + out.push(path); + } + } + out +} + +fn additional_permission_roots( + additional_permissions: &PermissionProfile, +) -> (Vec, Vec) { + ( + dedup_absolute_paths( + additional_permissions + .file_system + .as_ref() + .and_then(|file_system| file_system.read.clone()) + .unwrap_or_default(), + ), + dedup_absolute_paths( + additional_permissions + .file_system + .as_ref() + .and_then(|file_system| file_system.write.clone()) + .unwrap_or_default(), + ), + ) +} + +fn merge_file_system_policy_with_additional_permissions( + file_system_policy: &FileSystemSandboxPolicy, + extra_reads: Vec, + extra_writes: Vec, +) -> FileSystemSandboxPolicy { + match file_system_policy.kind { + FileSystemSandboxKind::Restricted => { + let mut merged_policy = file_system_policy.clone(); + for path in extra_reads { + let entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Read, + }; + if !merged_policy.entries.contains(&entry) { + merged_policy.entries.push(entry); + } + } + for path in extra_writes { + let entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: FileSystemAccessMode::Write, + }; + if !merged_policy.entries.contains(&entry) { + merged_policy.entries.push(entry); + } + } + merged_policy + } + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => { + file_system_policy.clone() + } + } +} + +pub fn effective_file_system_sandbox_policy( + file_system_policy: &FileSystemSandboxPolicy, + additional_permissions: Option<&PermissionProfile>, +) -> FileSystemSandboxPolicy { + let Some(additional_permissions) = additional_permissions else { + return file_system_policy.clone(); + }; + + let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); + if extra_reads.is_empty() && extra_writes.is_empty() { + file_system_policy.clone() + } else { + merge_file_system_policy_with_additional_permissions( + file_system_policy, + extra_reads, + extra_writes, + ) + } +} + +fn merge_read_only_access_with_additional_reads( + read_only_access: &ReadOnlyAccess, + extra_reads: Vec, +) -> ReadOnlyAccess { + match read_only_access { + ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, + ReadOnlyAccess::Restricted { + include_platform_defaults, + readable_roots, + } => { + let mut merged = readable_roots.clone(); + merged.extend(extra_reads); + ReadOnlyAccess::Restricted { + include_platform_defaults: *include_platform_defaults, + readable_roots: dedup_absolute_paths(merged), + } + } + } +} + +fn merge_network_access( + base_network_access: bool, + additional_permissions: &PermissionProfile, +) -> bool { + base_network_access + || additional_permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) +} + +pub fn effective_network_sandbox_policy( + network_policy: NetworkSandboxPolicy, + additional_permissions: Option<&PermissionProfile>, +) -> NetworkSandboxPolicy { + if additional_permissions + .is_some_and(|permissions| merge_network_access(network_policy.is_enabled(), permissions)) + { + NetworkSandboxPolicy::Enabled + } else if additional_permissions.is_some() { + NetworkSandboxPolicy::Restricted + } else { + network_policy + } +} + +fn sandbox_policy_with_additional_permissions( + sandbox_policy: &SandboxPolicy, + additional_permissions: &PermissionProfile, +) -> SandboxPolicy { + if additional_permissions.is_empty() { + return sandbox_policy.clone(); + } + + let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); + + match sandbox_policy { + SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess, + SandboxPolicy::ExternalSandbox { network_access } => SandboxPolicy::ExternalSandbox { + network_access: if merge_network_access( + network_access.is_enabled(), + additional_permissions, + ) { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + }, + }, + SandboxPolicy::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + let mut merged_writes = writable_roots.clone(); + merged_writes.extend(extra_writes); + SandboxPolicy::WorkspaceWrite { + writable_roots: dedup_absolute_paths(merged_writes), + read_only_access: merge_read_only_access_with_additional_reads( + read_only_access, + extra_reads, + ), + network_access: merge_network_access(*network_access, additional_permissions), + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + } + } + SandboxPolicy::ReadOnly { + access, + network_access, + } => { + if extra_writes.is_empty() { + SandboxPolicy::ReadOnly { + access: merge_read_only_access_with_additional_reads(access, extra_reads), + network_access: merge_network_access(*network_access, additional_permissions), + } + } else { + // todo(dylan) - for now, this grants more access than the request. We should restrict this, + // but we should add a new SandboxPolicy variant to handle this. While the feature is still + // UnderDevelopment, it's a useful approximation of the desired behavior. + SandboxPolicy::WorkspaceWrite { + writable_roots: dedup_absolute_paths(extra_writes), + read_only_access: merge_read_only_access_with_additional_reads( + access, + extra_reads, + ), + network_access: merge_network_access(*network_access, additional_permissions), + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + } + } + } +} + +fn effective_sandbox_policy( + sandbox_policy: &SandboxPolicy, + additional_permissions: Option<&PermissionProfile>, +) -> SandboxPolicy { + additional_permissions.map_or_else( + || sandbox_policy.clone(), + |permissions| sandbox_policy_with_additional_permissions(sandbox_policy, permissions), + ) +} + +pub fn should_require_platform_sandbox( + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + has_managed_network_requirements: bool, +) -> bool { + if has_managed_network_requirements { + return true; + } + + if !network_policy.is_enabled() { + return !matches!( + file_system_policy.kind, + FileSystemSandboxKind::ExternalSandbox + ); + } + + match file_system_policy.kind { + FileSystemSandboxKind::Restricted => !file_system_policy.has_full_disk_write_access(), + FileSystemSandboxKind::Unrestricted | FileSystemSandboxKind::ExternalSandbox => false, + } +} + +#[cfg(test)] +#[path = "policy_transforms_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs similarity index 64% rename from codex-rs/core/src/sandboxing/mod_tests.rs rename to codex-rs/sandboxing/src/policy_transforms_tests.rs index 9a7a34e49d..a9c759a205 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -1,6 +1,5 @@ #[cfg(target_os = "macos")] use super::EffectiveSandboxPermissions; -use super::SandboxManager; use super::effective_file_system_sandbox_policy; #[cfg(target_os = "macos")] use super::intersect_permission_profiles; @@ -8,12 +7,6 @@ use super::merge_file_system_policy_with_additional_permissions; use super::normalize_additional_permissions; use super::sandbox_policy_with_additional_permissions; use super::should_require_platform_sandbox; -use crate::exec::SandboxType; -use crate::protocol::NetworkAccess; -use crate::protocol::ReadOnlyAccess; -use crate::protocol::SandboxPolicy; -use crate::tools::sandboxing::SandboxablePreference; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; #[cfg(target_os = "macos")] use codex_protocol::models::MacOsAutomationPermission; @@ -31,10 +24,12 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use pretty_assertions::assert_eq; -use std::collections::HashMap; #[cfg(unix)] use std::path::Path; use tempfile::TempDir; @@ -44,52 +39,6 @@ fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> { std::os::unix::fs::symlink(original, link) } -#[test] -fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { - let manager = SandboxManager::new(); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - false, - ); - assert_eq!(sandbox, SandboxType::None); -} - -#[test] -fn danger_full_access_uses_platform_sandbox_with_network_requirements() { - let manager = SandboxManager::new(); - let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::unrestricted(), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - true, - ); - assert_eq!(sandbox, expected); -} - -#[test] -fn restricted_file_system_uses_platform_sandbox_without_managed_network() { - let manager = SandboxManager::new(); - let expected = crate::safety::get_platform_sandbox(false).unwrap_or(SandboxType::None); - let sandbox = manager.select_initial( - &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]), - NetworkSandboxPolicy::Enabled, - SandboxablePreference::Auto, - WindowsSandboxLevel::Disabled, - false, - ); - assert_eq!(sandbox, expected); -} - #[test] fn full_access_restricted_policy_skips_platform_sandbox_when_network_is_enabled() { let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { @@ -146,51 +95,6 @@ fn full_access_restricted_policy_still_uses_platform_sandbox_for_restricted_netw ); } -#[test] -fn transform_preserves_unrestricted_file_system_policy_for_restricted_network() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - capture_policy: crate::exec::ExecCapturePolicy::ShellTool, - sandbox_permissions: super::SandboxPermissions::UseDefault, - additional_permissions: None, - justification: None, - }, - policy: &SandboxPolicy::ExternalSandbox { - network_access: crate::protocol::NetworkAccess::Restricted, - }, - file_system_policy: &FileSystemSandboxPolicy::unrestricted(), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - }) - .expect("transform"); - - assert_eq!( - exec_request.file_system_sandbox_policy, - FileSystemSandboxPolicy::unrestricted() - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Restricted - ); -} - #[test] fn normalize_additional_permissions_preserves_network() { let temp_dir = TempDir::new().expect("create temp dir"); @@ -407,6 +311,7 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { } ); } + #[cfg(target_os = "macos")] #[test] fn effective_permissions_merge_macos_extensions_with_additional_permissions() { @@ -502,154 +407,6 @@ fn external_sandbox_additional_permissions_can_enable_network() { ); } -#[test] -fn transform_additional_permissions_enable_network_for_external_sandbox() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let temp_dir = TempDir::new().expect("create temp dir"); - let path = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - capture_policy: crate::exec::ExecCapturePolicy::ShellTool, - sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, - additional_permissions: Some(PermissionProfile { - network: Some(NetworkPermissions { - enabled: Some(true), - }), - file_system: Some(FileSystemPermissions { - read: Some(vec![path]), - write: Some(Vec::new()), - }), - ..Default::default() - }), - justification: None, - }, - policy: &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - }, - file_system_policy: &FileSystemSandboxPolicy::unrestricted(), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - }) - .expect("transform"); - - assert_eq!( - exec_request.sandbox_policy, - SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - } - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Enabled - ); -} - -#[test] -fn transform_additional_permissions_preserves_denied_entries() { - let manager = SandboxManager::new(); - let cwd = std::env::current_dir().expect("current dir"); - let temp_dir = TempDir::new().expect("create temp dir"); - let workspace_root = AbsolutePathBuf::from_absolute_path( - canonicalize(temp_dir.path()).expect("canonicalize temp dir"), - ) - .expect("absolute temp dir"); - let allowed_path = workspace_root.join("allowed").expect("allowed path"); - let denied_path = workspace_root.join("denied").expect("denied path"); - let exec_request = manager - .transform(super::SandboxTransformRequest { - spec: super::CommandSpec { - program: "true".to_string(), - args: Vec::new(), - cwd: cwd.clone(), - env: HashMap::new(), - expiration: crate::exec::ExecExpiration::DefaultTimeout, - capture_policy: crate::exec::ExecCapturePolicy::ShellTool, - sandbox_permissions: super::SandboxPermissions::WithAdditionalPermissions, - additional_permissions: Some(PermissionProfile { - file_system: Some(FileSystemPermissions { - read: None, - write: Some(vec![allowed_path.clone()]), - }), - ..Default::default() - }), - justification: None, - }, - policy: &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, - }, - file_system_policy: &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: denied_path.clone(), - }, - access: FileSystemAccessMode::None, - }, - ]), - network_policy: NetworkSandboxPolicy::Restricted, - sandbox: SandboxType::None, - enforce_managed_network: false, - network: None, - sandbox_policy_cwd: cwd.as_path(), - #[cfg(target_os = "macos")] - macos_seatbelt_profile_extensions: None, - codex_linux_sandbox_exe: None, - use_legacy_landlock: false, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - }) - .expect("transform"); - - assert_eq!( - exec_request.file_system_sandbox_policy, - FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: denied_path }, - access: FileSystemAccessMode::None, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: allowed_path }, - access: FileSystemAccessMode::Write, - }, - ]) - ); - assert_eq!( - exec_request.network_sandbox_policy, - NetworkSandboxPolicy::Restricted - ); -} - #[test] fn merge_file_system_policy_with_additional_permissions_preserves_unreadable_roots() { let temp_dir = TempDir::new().expect("create temp dir"); diff --git a/codex-rs/core/src/restricted_read_only_platform_defaults.sbpl b/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl similarity index 100% rename from codex-rs/core/src/restricted_read_only_platform_defaults.sbpl rename to codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs new file mode 100644 index 0000000000..e7cca3d2ac --- /dev/null +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -0,0 +1,540 @@ +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::PROXY_URL_ENV_KEYS; +use codex_network_proxy::has_proxy_url_env_vars; +use codex_network_proxy::proxy_url_env_value; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::ffi::CStr; +use std::path::Path; +use std::path::PathBuf; +use tracing::warn; +use url::Url; + +use crate::seatbelt_permissions::build_seatbelt_extensions; + +const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl"); +const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl"); +const MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS: &str = + include_str!("restricted_read_only_platform_defaults.sbpl"); + +/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin` +/// to defend against an attacker trying to inject a malicious version on the +/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker +/// already has root access. +pub const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec"; + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" +} + +fn proxy_scheme_default_port(scheme: &str) -> u16 { + match scheme { + "https" => 443, + "socks5" | "socks5h" | "socks4" | "socks4a" => 1080, + _ => 80, + } +} + +fn proxy_loopback_ports_from_env(env: &HashMap) -> Vec { + let mut ports = BTreeSet::new(); + for key in PROXY_URL_ENV_KEYS { + let Some(proxy_url) = proxy_url_env_value(env, key) else { + continue; + }; + let trimmed = proxy_url.trim(); + if trimmed.is_empty() { + continue; + } + + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + let Ok(parsed) = Url::parse(&candidate) else { + continue; + }; + let Some(host) = parsed.host_str() else { + continue; + }; + if !is_loopback_host(host) { + continue; + } + + let scheme = parsed.scheme().to_ascii_lowercase(); + let port = parsed + .port() + .unwrap_or_else(|| proxy_scheme_default_port(scheme.as_str())); + ports.insert(port); + } + ports.into_iter().collect() +} + +#[derive(Debug, Default)] +struct ProxyPolicyInputs { + ports: Vec, + has_proxy_config: bool, + allow_local_binding: bool, + unix_domain_socket_policy: UnixDomainSocketPolicy, +} + +#[derive(Debug, Clone)] +// Keep allow-all and allowlist modes disjoint so we don't carry ignored state. +enum UnixDomainSocketPolicy { + AllowAll, + Restricted { allowed: Vec }, +} + +impl Default for UnixDomainSocketPolicy { + fn default() -> Self { + Self::Restricted { allowed: vec![] } + } +} + +#[derive(Debug, Clone)] +struct UnixSocketPathParam { + index: usize, + path: AbsolutePathBuf, +} + +fn proxy_policy_inputs(network: Option<&NetworkProxy>) -> ProxyPolicyInputs { + if let Some(network) = network { + let mut env = HashMap::new(); + network.apply_to_env(&mut env); + let unix_domain_socket_policy = if network.dangerously_allow_all_unix_sockets() { + UnixDomainSocketPolicy::AllowAll + } else { + let allowed = network + .allow_unix_sockets() + .iter() + .filter_map( + |socket_path| match normalize_path_for_sandbox(Path::new(socket_path)) { + Some(path) => Some((path.to_string_lossy().to_string(), path)), + None => { + warn!( + "ignoring network.allow_unix_sockets entry because it could not be normalized: {socket_path}" + ); + None + } + }, + ) + .collect::>() + .into_values() + .collect(); + UnixDomainSocketPolicy::Restricted { allowed } + }; + return ProxyPolicyInputs { + ports: proxy_loopback_ports_from_env(&env), + has_proxy_config: has_proxy_url_env_vars(&env), + allow_local_binding: network.allow_local_binding(), + unix_domain_socket_policy, + }; + } + + ProxyPolicyInputs::default() +} + +fn normalize_path_for_sandbox(path: &Path) -> Option { + // `AbsolutePathBuf::from_absolute_path()` normalizes relative paths against the current + // working directory, so keep the explicit check to avoid silently accepting relative entries. + if !path.is_absolute() { + return None; + } + + let absolute_path = AbsolutePathBuf::from_absolute_path(path).ok()?; + let normalized_path = absolute_path + .as_path() + .canonicalize() + .ok() + .and_then(|canonical_path| AbsolutePathBuf::from_absolute_path(canonical_path).ok()); + normalized_path.or(Some(absolute_path)) +} + +fn unix_socket_path_params(proxy: &ProxyPolicyInputs) -> Vec { + let mut deduped_paths: BTreeMap = BTreeMap::new(); + let UnixDomainSocketPolicy::Restricted { allowed } = &proxy.unix_domain_socket_policy else { + return vec![]; + }; + for path in allowed { + deduped_paths + .entry(path.to_string_lossy().to_string()) + .or_insert_with(|| path.clone()); + } + + deduped_paths + .into_values() + .enumerate() + .map(|(index, path)| UnixSocketPathParam { index, path }) + .collect() +} + +fn unix_socket_path_param_key(index: usize) -> String { + format!("UNIX_SOCKET_PATH_{index}") +} + +fn unix_socket_dir_params(proxy: &ProxyPolicyInputs) -> Vec<(String, PathBuf)> { + unix_socket_path_params(proxy) + .into_iter() + .map(|param| { + ( + unix_socket_path_param_key(param.index), + param.path.into_path_buf(), + ) + }) + .collect() +} + +/// Returns zero or more complete Seatbelt policy lines for unix socket rules. +/// When non-empty, the returned string is newline-terminated so callers can +/// append it directly to larger policy blocks. +fn unix_socket_policy(proxy: &ProxyPolicyInputs) -> String { + let socket_params = unix_socket_path_params(proxy); + let has_unix_socket_access = matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) || !socket_params.is_empty(); + if !has_unix_socket_access { + return String::new(); + } + + let mut policy = String::new(); + policy.push_str("(allow system-socket (socket-domain AF_UNIX))\n"); + if matches!( + proxy.unix_domain_socket_policy, + UnixDomainSocketPolicy::AllowAll + ) { + // Keep AllowAll genuinely broad here; path qualifiers look narrower + // without a clear macOS behavioral benefit. + policy.push_str("(allow network-bind (local unix-socket))\n"); + policy.push_str("(allow network-outbound (remote unix-socket))\n"); + return policy; + } + + for param in socket_params { + let key = unix_socket_path_param_key(param.index); + // Use subpath so allowlists cover sockets created beneath approved directories. + policy.push_str(&format!( + "(allow network-bind (local unix-socket (subpath (param \"{key}\"))))\n" + )); + policy.push_str(&format!( + "(allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))\n" + )); + } + policy +} + +#[cfg_attr(not(test), allow(dead_code))] +fn dynamic_network_policy( + sandbox_policy: &SandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + dynamic_network_policy_for_network( + NetworkSandboxPolicy::from(sandbox_policy), + enforce_managed_network, + proxy, + ) +} + +fn dynamic_network_policy_for_network( + network_policy: NetworkSandboxPolicy, + enforce_managed_network: bool, + proxy: &ProxyPolicyInputs, +) -> String { + let should_use_restricted_network_policy = + !proxy.ports.is_empty() || proxy.has_proxy_config || enforce_managed_network; + if should_use_restricted_network_policy { + let mut policy = String::new(); + if proxy.allow_local_binding { + policy.push_str("; allow loopback local binding and loopback traffic\n"); + policy.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); + policy.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n"); + } + for port in &proxy.ports { + policy.push_str(&format!( + "(allow network-outbound (remote ip \"localhost:{port}\"))\n" + )); + } + let unix_socket_policy = unix_socket_policy(proxy); + if !unix_socket_policy.is_empty() { + policy.push_str("; allow unix domain sockets for local IPC\n"); + policy.push_str(&unix_socket_policy); + } + return format!("{policy}{MACOS_SEATBELT_NETWORK_POLICY}"); + } + + if proxy.has_proxy_config { + // Proxy configuration is present but we could not infer any valid loopback endpoints. + // Fail closed to avoid silently widening network access in proxy-enforced sessions. + return String::new(); + } + + if enforce_managed_network { + // Managed network requirements are active but no usable proxy endpoints + // are available. Fail closed for network access. + return String::new(); + } + + if network_policy.is_enabled() { + // No proxy env is configured: retain the existing full-network behavior. + format!( + "(allow network-outbound)\n(allow network-inbound)\n{MACOS_SEATBELT_NETWORK_POLICY}" + ) + } else { + String::new() + } +} + +fn root_absolute_path() -> AbsolutePathBuf { + match AbsolutePathBuf::from_absolute_path(Path::new("/")) { + Ok(path) => path, + Err(err) => panic!("root path must be absolute: {err}"), + } +} + +#[derive(Debug, Clone)] +struct SeatbeltAccessRoot { + root: AbsolutePathBuf, + excluded_subpaths: Vec, +} + +fn build_seatbelt_access_policy( + action: &str, + param_prefix: &str, + roots: Vec, +) -> (String, Vec<(String, PathBuf)>) { + let mut policy_components = Vec::new(); + let mut params = Vec::new(); + + for (index, access_root) in roots.into_iter().enumerate() { + let root = + normalize_path_for_sandbox(access_root.root.as_path()).unwrap_or(access_root.root); + let root_param = format!("{param_prefix}_{index}"); + params.push((root_param.clone(), root.into_path_buf())); + + if access_root.excluded_subpaths.is_empty() { + policy_components.push(format!("(subpath (param \"{root_param}\"))")); + continue; + } + + let mut require_parts = vec![format!("(subpath (param \"{root_param}\"))")]; + for (excluded_index, excluded_subpath) in + access_root.excluded_subpaths.into_iter().enumerate() + { + let excluded_subpath = + normalize_path_for_sandbox(excluded_subpath.as_path()).unwrap_or(excluded_subpath); + let excluded_param = format!("{param_prefix}_{index}_RO_{excluded_index}"); + params.push((excluded_param.clone(), excluded_subpath.into_path_buf())); + require_parts.push(format!( + "(require-not (subpath (param \"{excluded_param}\")))" + )); + } + policy_components.push(format!("(require-all {} )", require_parts.join(" "))); + } + + if policy_components.is_empty() { + (String::new(), Vec::new()) + } else { + ( + format!("(allow {action}\n{}\n)", policy_components.join(" ")), + params, + ) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +fn create_seatbelt_command_args_with_extensions( + command: Vec, + sandbox_policy: &SandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + create_seatbelt_command_args_for_policies_with_extensions( + command, + &FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd), + NetworkSandboxPolicy::from(sandbox_policy), + sandbox_policy_cwd, + enforce_managed_network, + network, + extensions, + ) +} + +pub fn create_seatbelt_command_args_for_policies_with_extensions( + command: Vec, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + enforce_managed_network: bool, + network: Option<&NetworkProxy>, + extensions: Option<&MacOsSeatbeltProfileExtensions>, +) -> Vec { + let unreadable_roots = + file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); + let (file_write_policy, file_write_dir_params) = + if file_system_sandbox_policy.has_full_disk_write_access() { + if unreadable_roots.is_empty() { + // Allegedly, this is more permissive than `(allow file-write*)`. + ( + r#"(allow file-write* (regex #"^/"))"#.to_string(), + Vec::new(), + ) + } else { + build_seatbelt_access_policy( + "file-write*", + "WRITABLE_ROOT", + vec![SeatbeltAccessRoot { + root: root_absolute_path(), + excluded_subpaths: unreadable_roots.clone(), + }], + ) + } + } else { + build_seatbelt_access_policy( + "file-write*", + "WRITABLE_ROOT", + file_system_sandbox_policy + .get_writable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(|root| SeatbeltAccessRoot { + root: root.root, + excluded_subpaths: root.read_only_subpaths, + }) + .collect(), + ) + }; + + let (file_read_policy, file_read_dir_params) = + if file_system_sandbox_policy.has_full_disk_read_access() { + if unreadable_roots.is_empty() { + ( + "; allow read-only file operations\n(allow file-read*)".to_string(), + Vec::new(), + ) + } else { + let (policy, params) = build_seatbelt_access_policy( + "file-read*", + "READABLE_ROOT", + vec![SeatbeltAccessRoot { + root: root_absolute_path(), + excluded_subpaths: unreadable_roots, + }], + ); + ( + format!("; allow read-only file operations\n{policy}"), + params, + ) + } + } else { + let (policy, params) = build_seatbelt_access_policy( + "file-read*", + "READABLE_ROOT", + file_system_sandbox_policy + .get_readable_roots_with_cwd(sandbox_policy_cwd) + .into_iter() + .map(|root| SeatbeltAccessRoot { + excluded_subpaths: unreadable_roots + .iter() + .filter(|path| path.as_path().starts_with(root.as_path())) + .cloned() + .collect(), + root, + }) + .collect(), + ); + if policy.is_empty() { + (String::new(), params) + } else { + ( + format!("; allow read-only file operations\n{policy}"), + params, + ) + } + }; + + let proxy = proxy_policy_inputs(network); + let network_policy = + dynamic_network_policy_for_network(network_sandbox_policy, enforce_managed_network, &proxy); + let seatbelt_extensions = extensions.map_or_else( + || { + // Backward-compatibility default when no extension profile is provided. + build_seatbelt_extensions(&MacOsSeatbeltProfileExtensions::default()) + }, + build_seatbelt_extensions, + ); + + let include_platform_defaults = file_system_sandbox_policy.include_platform_defaults(); + let mut policy_sections = vec![ + MACOS_SEATBELT_BASE_POLICY.to_string(), + file_read_policy, + file_write_policy, + network_policy, + ]; + if include_platform_defaults { + policy_sections.push(MACOS_RESTRICTED_READ_ONLY_PLATFORM_DEFAULTS.to_string()); + } + if !seatbelt_extensions.policy.is_empty() { + policy_sections.push(seatbelt_extensions.policy.clone()); + } + + let full_policy = policy_sections.join("\n"); + + let dir_params = [ + file_read_dir_params, + file_write_dir_params, + macos_dir_params(), + unix_socket_dir_params(&proxy), + seatbelt_extensions.dir_params, + ] + .concat(); + + let mut seatbelt_args: Vec = vec!["-p".to_string(), full_policy]; + let definition_args = dir_params + .into_iter() + .map(|(key, value): (String, PathBuf)| { + format!("-D{key}={value}", value = value.to_string_lossy()) + }); + seatbelt_args.extend(definition_args); + seatbelt_args.push("--".to_string()); + seatbelt_args.extend(command); + seatbelt_args +} + +/// Wraps libc::confstr to return a String. +fn confstr(name: libc::c_int) -> Option { + let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1]; + let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) }; + if len == 0 { + return None; + } + // confstr guarantees NUL-termination when len > 0. + let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) }; + cstr.to_str().ok().map(ToString::to_string) +} + +/// Wraps confstr to return a canonicalized PathBuf. +fn confstr_path(name: libc::c_int) -> Option { + let s = confstr(name)?; + let path = PathBuf::from(s); + path.canonicalize().ok().or(Some(path)) +} + +fn macos_dir_params() -> Vec<(String, PathBuf)> { + if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) { + return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)]; + } + vec![] +} + +#[cfg(test)] +#[path = "seatbelt_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_base_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_base_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_base_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_network_policy.sbpl b/codex-rs/sandboxing/src/seatbelt_network_policy.sbpl similarity index 100% rename from codex-rs/core/src/seatbelt_network_policy.sbpl rename to codex-rs/sandboxing/src/seatbelt_network_policy.sbpl diff --git a/codex-rs/core/src/seatbelt_permissions.rs b/codex-rs/sandboxing/src/seatbelt_permissions.rs similarity index 96% rename from codex-rs/core/src/seatbelt_permissions.rs rename to codex-rs/sandboxing/src/seatbelt_permissions.rs index 5cbfd65094..d53e7496ad 100644 --- a/codex-rs/core/src/seatbelt_permissions.rs +++ b/codex-rs/sandboxing/src/seatbelt_permissions.rs @@ -1,12 +1,10 @@ -#![cfg(target_os = "macos")] - use std::collections::BTreeSet; use std::path::PathBuf; -pub use codex_protocol::models::MacOsAutomationPermission; -pub use codex_protocol::models::MacOsContactsPermission; -pub use codex_protocol::models::MacOsPreferencesPermission; -pub use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct SeatbeltExtensionPolicy { diff --git a/codex-rs/core/src/seatbelt_permissions_tests.rs b/codex-rs/sandboxing/src/seatbelt_permissions_tests.rs similarity index 100% rename from codex-rs/core/src/seatbelt_permissions_tests.rs rename to codex-rs/sandboxing/src/seatbelt_permissions_tests.rs diff --git a/codex-rs/core/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs similarity index 94% rename from codex-rs/core/src/seatbelt_tests.rs rename to codex-rs/sandboxing/src/seatbelt_tests.rs index bab1362017..925285763e 100644 --- a/codex-rs/core/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -1,7 +1,7 @@ +use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE; use super::MACOS_SEATBELT_BASE_POLICY; use super::ProxyPolicyInputs; use super::UnixDomainSocketPolicy; -use super::create_seatbelt_command_args; use super::create_seatbelt_command_args_for_policies_with_extensions; use super::create_seatbelt_command_args_with_extensions; use super::dynamic_network_policy; @@ -9,18 +9,18 @@ use super::macos_dir_params; use super::normalize_path_for_sandbox; use super::unix_socket_dir_params; use super::unix_socket_policy; -use crate::protocol::ReadOnlyAccess; -use crate::protocol::SandboxPolicy; -use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; -use crate::seatbelt_permissions::MacOsAutomationPermission; -use crate::seatbelt_permissions::MacOsContactsPermission; -use crate::seatbelt_permissions::MacOsPreferencesPermission; -use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::MacOsAutomationPermission; +use codex_protocol::models::MacOsContactsPermission; +use codex_protocol::models::MacOsPreferencesPermission; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; 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::protocol::ReadOnlyAccess; +use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -105,7 +105,7 @@ fn explicit_unreadable_paths_are_excluded_from_full_disk_read_and_write_access() let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { - value: crate::protocol::FileSystemSpecialPath::Root, + value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Write, }, @@ -277,12 +277,13 @@ sys.exit(0 if allowed else 13) #[test] fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() { let cwd = std::env::temp_dir(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["echo".to_string(), "ok".to_string()], &SandboxPolicy::new_read_only_policy(), cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let policy = &args[1]; assert!(policy.contains("(allow user-preference-read)")); @@ -295,7 +296,7 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { let cwd = tmp.path().join("workspace"); fs::create_dir_all(cwd.join("docs")).expect("create docs"); let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( vec!["/bin/true".to_string()], &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], @@ -308,8 +309,9 @@ fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { exclude_slash_tmp: true, }, cwd.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let docs_param = format!("-DWRITABLE_ROOT_0_RO_0={}", docs.as_path().display()); @@ -636,7 +638,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command.clone(), + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); // Build the expected policy text using a raw string for readability. // Note that the policy includes: @@ -740,8 +749,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_hooks_file_args = - create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None); + let write_hooks_file_args = create_seatbelt_command_args_with_extensions( + shell_command_git, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_hooks_file_args) .current_dir(&cwd) @@ -771,8 +786,14 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .iter() .map(std::string::ToString::to_string) .collect(); - let write_allowed_file_args = - create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None); + let write_allowed_file_args = create_seatbelt_command_args_with_extensions( + shell_command_allowed, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&write_allowed_file_args) .current_dir(&cwd) @@ -833,7 +854,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None); + let args = create_seatbelt_command_args_with_extensions( + shell_command, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&args) @@ -863,8 +891,14 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { .iter() .map(std::string::ToString::to_string) .collect(); - let gitdir_args = - create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None); + let gitdir_args = create_seatbelt_command_args_with_extensions( + shell_command_gitdir, + &policy, + &cwd, + /*enforce_managed_network*/ false, + None, + /*extensions*/ None, + ); let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) .args(&gitdir_args) .current_dir(&cwd) @@ -921,12 +955,13 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { .iter() .map(std::string::ToString::to_string) .collect(); - let args = create_seatbelt_command_args( + let args = create_seatbelt_command_args_with_extensions( shell_command.clone(), &policy, vulnerable_root.as_path(), - false, + /*enforce_managed_network*/ false, None, + /*extensions*/ None, ); let tmpdir_env_var = std::env::var("TMPDIR") diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml index eae8f9005e..7ca634b251 100644 --- a/codex-rs/secrets/Cargo.toml +++ b/codex-rs/secrets/Cargo.toml @@ -11,6 +11,7 @@ workspace = true age = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } +codex-git-utils = { workspace = true } codex-keyring-store = { workspace = true } rand = { workspace = true } regex = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs index 1ec925fd4f..fe482f7d21 100644 --- a/codex-rs/secrets/src/lib.rs +++ b/codex-rs/secrets/src/lib.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; +use codex_git_utils::get_git_repo_root; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; use schemars::JsonSchema; @@ -161,22 +162,6 @@ pub fn environment_id_from_cwd(cwd: &Path) -> String { format!("cwd-{short}") } -fn get_git_repo_root(base_dir: &Path) -> Option { - let mut dir = base_dir.to_path_buf(); - - loop { - if dir.join(".git").exists() { - return Some(dir); - } - - if !dir.pop() { - break; - } - } - - None -} - pub(crate) fn compute_keyring_account(codex_home: &Path) -> String { let canonical = codex_home .canonicalize() diff --git a/codex-rs/shell-escalation/README.md b/codex-rs/shell-escalation/README.md index cea59601f8..e4d3fecd66 100644 --- a/codex-rs/shell-escalation/README.md +++ b/codex-rs/shell-escalation/README.md @@ -15,14 +15,15 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi - `Deny`: the server has declared the proposed command to be forbidden, so `codex-execve-wrapper` prints an error to `stderr` and exits with `1`. -## Patched Bash +## Patched zsh -We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is β€œadd support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually: +We carry a small patch to `Src/exec.c` (see `patches/zsh-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The patch applies to `77045ef899e53b9598bebc5a41db93a548a40ca6` from https://git.code.sf.net/p/zsh/code. To rebuild manually: ```bash -git clone https://git.savannah.gnu.org/git/bash -git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b -git apply /path/to/patches/bash-exec-wrapper.patch -./configure --without-bash-malloc +git clone https://git.code.sf.net/p/zsh/code +git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 +git apply /path/to/patches/zsh-exec-wrapper.patch +./Util/preconfig +./configure make -j"$(nproc)" ``` diff --git a/shell-tool-mcp/patches/zsh-exec-wrapper.patch b/codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch similarity index 100% rename from shell-tool-mcp/patches/zsh-exec-wrapper.patch rename to codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch diff --git a/codex-rs/shell-escalation/src/unix/escalate_client.rs b/codex-rs/shell-escalation/src/unix/escalate_client.rs index 43ae05624a..0f98883865 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_client.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_client.rs @@ -11,7 +11,6 @@ use crate::unix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; use crate::unix::escalate_protocol::EscalateAction; use crate::unix::escalate_protocol::EscalateRequest; use crate::unix::escalate_protocol::EscalateResponse; -use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; use crate::unix::escalate_protocol::SuperExecMessage; use crate::unix::escalate_protocol::SuperExecResult; use crate::unix::socket::AsyncDatagramSocket; @@ -46,12 +45,7 @@ pub async fn run_shell_escalation_execve_wrapper( .await .context("failed to send handshake datagram")?; let env = std::env::vars() - .filter(|(k, _)| { - !matches!( - k.as_str(), - ESCALATE_SOCKET_ENV_VAR | EXEC_WRAPPER_ENV_VAR | LEGACY_BASH_EXEC_WRAPPER_ENV_VAR - ) - }) + .filter(|(k, _)| !matches!(k.as_str(), ESCALATE_SOCKET_ENV_VAR | EXEC_WRAPPER_ENV_VAR)) .collect(); client .send(EscalateRequest { diff --git a/codex-rs/shell-escalation/src/unix/escalate_protocol.rs b/codex-rs/shell-escalation/src/unix/escalate_protocol.rs index ea38c7b2a6..1892f181b9 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_protocol.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_protocol.rs @@ -13,9 +13,6 @@ pub const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET"; /// Patched shells use this to wrap exec() calls. pub const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER"; -/// Compatibility alias for older patched bash builds. -pub const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER"; - /// The client sends this to the server to request an exec() call. #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] pub struct EscalateRequest { diff --git a/codex-rs/shell-escalation/src/unix/escalate_server.rs b/codex-rs/shell-escalation/src/unix/escalate_server.rs index 9cd4dbfad1..4e7f09b9ad 100644 --- a/codex-rs/shell-escalation/src/unix/escalate_server.rs +++ b/codex-rs/shell-escalation/src/unix/escalate_server.rs @@ -20,7 +20,6 @@ use crate::unix::escalate_protocol::EscalateRequest; use crate::unix::escalate_protocol::EscalateResponse; use crate::unix::escalate_protocol::EscalationDecision; use crate::unix::escalate_protocol::EscalationExecution; -use crate::unix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; use crate::unix::escalate_protocol::SuperExecMessage; use crate::unix::escalate_protocol::SuperExecResult; use crate::unix::escalation_policy::EscalationPolicy; @@ -64,13 +63,13 @@ pub trait ShellCommandExecutor: Send + Sync { #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct ExecParams { - /// The the string of Zsh/shell to execute. + /// The command string to pass to the shell via `-c` or `-lc`. pub command: String, /// The working directory to execute the command in. Must be an absolute path. pub workdir: String, /// The timeout for the command in milliseconds. pub timeout_ms: Option, - /// Launch Bash with -lc instead of -c: defaults to true. + /// Launch the shell with -lc instead of -c: defaults to true. pub login: Option, } @@ -126,18 +125,18 @@ impl Drop for EscalationSession { } pub struct EscalateServer { - bash_path: PathBuf, + shell_path: PathBuf, execve_wrapper: PathBuf, policy: Arc, } impl EscalateServer { - pub fn new(bash_path: PathBuf, execve_wrapper: PathBuf, policy: Policy) -> Self + pub fn new(shell_path: PathBuf, execve_wrapper: PathBuf, policy: Policy) -> Self where Policy: EscalationPolicy + Send + Sync + 'static, { Self { - bash_path, + shell_path, execve_wrapper, policy: Arc::new(policy), } @@ -153,7 +152,7 @@ impl EscalateServer { let env_overlay = session.env().clone(); let client_socket = Arc::clone(&session.client_socket); let command = vec![ - self.bash_path.to_string_lossy().to_string(), + self.shell_path.to_string_lossy().to_string(), if params.login == Some(false) { "-c".to_string() } else { @@ -211,10 +210,6 @@ impl EscalateServer { EXEC_WRAPPER_ENV_VAR.to_string(), self.execve_wrapper.to_string_lossy().to_string(), ); - env.insert( - LEGACY_BASH_EXEC_WRAPPER_ENV_VAR.to_string(), - self.execve_wrapper.to_string_lossy().to_string(), - ); Ok(EscalationSession { env, task, @@ -595,7 +590,7 @@ mod tests { /// overlay and does not need to touch the configured shell or wrapper /// executable paths. /// - /// The `/bin/bash` and `/tmp/codex-execve-wrapper` values here are + /// The `/bin/zsh` and `/tmp/codex-execve-wrapper` values here are /// intentionally fake sentinels: this test asserts that the paths are /// copied into the exported environment and that the socket fd stays valid /// until `close_client_socket()` is called. @@ -605,7 +600,7 @@ mod tests { let execve_wrapper = PathBuf::from("/tmp/codex-execve-wrapper"); let execve_wrapper_str = execve_wrapper.to_string_lossy().to_string(); let server = EscalateServer::new( - PathBuf::from("/bin/bash"), + PathBuf::from("/bin/zsh"), execve_wrapper.clone(), DeterministicEscalationPolicy { decision: EscalationDecision::run(), @@ -618,10 +613,6 @@ mod tests { )?; let env = session.env(); assert_eq!(env.get(EXEC_WRAPPER_ENV_VAR), Some(&execve_wrapper_str)); - assert_eq!( - env.get(LEGACY_BASH_EXEC_WRAPPER_ENV_VAR), - Some(&execve_wrapper_str) - ); let socket_fd = env .get(ESCALATE_SOCKET_ENV_VAR) .expect("session should export shell escalation socket"); diff --git a/codex-rs/skills/src/assets/samples/imagegen/LICENSE.txt b/codex-rs/skills/src/assets/samples/imagegen/LICENSE.txt new file mode 100644 index 0000000000..13e25df86c --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don\'t include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/codex-rs/skills/src/assets/samples/imagegen/SKILL.md b/codex-rs/skills/src/assets/samples/imagegen/SKILL.md new file mode 100644 index 0000000000..88195882ef --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/SKILL.md @@ -0,0 +1,279 @@ +--- +name: "imagegen" +description: "Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas." +--- + +# Image Generation Skill + +Generates or edits images for the current project (for example website assets, game assets, UI mockups, product mockups, wireframes, logo design, photorealistic images, or infographics). + +## Top-level modes and rules + +This skill has exactly two top-level modes: + +- **Default built-in tool mode (preferred):** built-in `image_gen` tool for normal image generation and editing. Does not require `OPENAI_API_KEY`. +- **Fallback CLI mode (explicit-only):** `scripts/image_gen.py` CLI. Use only when the user explicitly asks for the CLI path. Requires `OPENAI_API_KEY`. + +Within the explicit CLI fallback only, the CLI exposes three subcommands: + +- `generate` +- `edit` +- `generate-batch` + +Rules: +- Use the built-in `image_gen` tool by default for all normal image generation and editing requests. +- Never switch to CLI fallback automatically. +- If the built-in tool fails or is unavailable, tell the user the CLI fallback exists and that it requires `OPENAI_API_KEY`. Proceed only if the user explicitly asks for that fallback. +- If the user explicitly asks for CLI mode, use the bundled `scripts/image_gen.py` workflow. Do not create one-off SDK runners. +- Never modify `scripts/image_gen.py`. If something is missing, ask the user before doing anything else. + +Built-in save-path policy: +- In built-in tool mode, Codex saves generated images under `$CODEX_HOME/*` by default. +- Do not describe or rely on OS temp as the default built-in destination. +- Do not describe or rely on a destination-path argument (if any) on the built-in `image_gen` tool. If a specific location is needed, generate first and then move or copy the selected output from `$CODEX_HOME/generated_images/...`. +- Save-path precedence in built-in mode: + 1. If the user names a destination, move or copy the selected output there. + 2. If the image is meant for the current project, move or copy the final selected image into the workspace before finishing. + 3. If the image is only for preview or brainstorming, render it inline; the underlying file can remain at the default `$CODEX_HOME/*` path. +- Never leave a project-referenced asset only at the default `$CODEX_HOME/*` path. +- Do not overwrite an existing asset unless the user explicitly asked for replacement; otherwise create a sibling versioned filename such as `hero-v2.png` or `item-icon-edited.png`. + +Shared prompt guidance for both modes lives in `references/prompting.md` and `references/sample-prompts.md`. + +Fallback-only docs/resources for CLI mode: +- `references/cli.md` +- `references/image-api.md` +- `references/codex-network.md` +- `scripts/image_gen.py` + +## When to use +- Generate a new image (concept art, product shot, cover, website hero) +- Generate a new image using one or more reference images for style, composition, or mood +- Edit an existing image (inpainting, lighting or weather transformations, background replacement, object removal, compositing, transparent background) +- Produce many assets or variants for one task + +## When not to use +- Extending or matching an existing SVG/vector icon set, logo system, or illustration library inside the repo +- Creating simple shapes, diagrams, wireframes, or icons that are better produced directly in SVG, HTML/CSS, or canvas +- Making a small project-local asset edit when the source file already exists in an editable native format +- Any task where the user clearly wants deterministic code-native output instead of a generated bitmap + +## Decision tree + +Think about two separate questions: + +1. **Intent:** is this a new image or an edit of an existing image? +2. **Execution strategy:** is this one asset or many assets/variants? + +Intent: +- If the user wants to modify an existing image while preserving parts of it, treat the request as **edit**. +- If the user provides images only as references for style, composition, mood, or subject guidance, treat the request as **generate**. +- If the user provides no images, treat the request as **generate**. + +Built-in edit semantics: +- Built-in edit mode is for images already visible in the conversation context, such as attached images or images generated earlier in the thread. +- If the user wants to edit a local image file with the built-in tool, first load it with built-in `view_image` tool so the image is visible in the conversation context, then proceed with the built-in edit flow. +- Do not promise arbitrary filesystem-path editing through the built-in tool. +- If a local file still needs direct file-path control, masks, or other explicit CLI-only parameters, use the explicit CLI fallback only when the user asks for it. +- For edits, preserve invariants aggressively and save non-destructively by default. + +Execution strategy: +- In the built-in default path, produce many assets or variants by issuing one `image_gen` call per requested asset or variant. +- In the explicit CLI fallback path, use the CLI `generate-batch` subcommand only when the user explicitly chose CLI mode and needs many prompts/assets. + +Assume the user wants a new image unless they clearly ask to change an existing one. + +## Workflow +1. Decide the top-level mode: built-in by default, fallback CLI only if explicitly requested. +2. Decide the intent: `generate` or `edit`. +3. Decide whether the output is preview-only or meant to be consumed by the current project. +4. Decide the execution strategy: single asset vs repeated built-in calls vs CLI `generate-batch`. +5. Collect inputs up front: prompt(s), exact text (verbatim), constraints/avoid list, and any input images. +6. For every input image, label its role explicitly: + - reference image + - edit target + - supporting insert/style/compositing input +7. If the edit target is only on the local filesystem and you are staying on the built-in path, inspect it with `view_image` first so the image is available in conversation context. +8. If the user asked for a photo, illustration, sprite, product image, banner, or other explicitly raster-style asset, use `image_gen` rather than substituting SVG/HTML/CSS placeholders. If the request is for an icon, logo, or UI graphic that should match existing repo-native SVG/vector/code assets, prefer editing those directly instead. +9. Augment the prompt based on specificity: + - If the user's prompt is already specific and detailed, normalize it into a clear spec without adding creative requirements. + - If the user's prompt is generic, add tasteful augmentation only when it materially improves output quality. +10. Use the built-in `image_gen` tool by default. +11. If the user explicitly chooses the CLI fallback, then and only then use the fallback-only docs for quality, `input_fidelity`, masks, output format, output paths, and network setup. +12. Inspect outputs and validate: subject, style, composition, text accuracy, and invariants/avoid items. +13. Iterate with a single targeted change, then re-check. +14. For preview-only work, render the image inline; the underlying file may remain at the default `$CODEX_HOME/generated_images/...` path. +15. For project-bound work, move or copy the selected artifact into the workspace and update any consuming code or references. Never leave a project-referenced asset only at the default `$CODEX_HOME/generated_images/...` path. +16. For batches, persist only the selected finals in the workspace unless the user explicitly asked to keep discarded variants. +17. Always report the final saved path for any workspace-bound asset, plus the final prompt and whether the built-in tool or fallback CLI mode was used. + +## Prompt augmentation + +Reformat user prompts into a structured, production-oriented spec. Make the user's goal clearer and more actionable, but do not blindly add detail. + +Treat this as prompt-shaping guidance, not a closed schema. Use only the lines that help, and add a short extra labeled line when it materially improves clarity. + +### Specificity policy + +Use the user's prompt specificity to decide how much augmentation is appropriate: + +- If the prompt is already specific and detailed, preserve that specificity and only normalize/structure it. +- If the prompt is generic, you may add tasteful augmentation when it will materially improve the result. + +Allowed augmentations: +- composition or framing hints +- polish level or intended-use hints +- practical layout guidance +- reasonable scene concreteness that supports the stated request + +Not allowed augmentations: +- extra characters or objects that are not implied by the request +- brand names, slogans, palettes, or narrative beats that are not implied +- arbitrary side-specific placement unless the surrounding layout supports it + +## Use-case taxonomy (exact slugs) + +Classify each request into one of these buckets and keep the slug consistent across prompts and references. + +Generate: +- photorealistic-natural β€” candid/editorial lifestyle scenes with real texture and natural lighting. +- product-mockup β€” product/packaging shots, catalog imagery, merch concepts. +- ui-mockup β€” app/web interface mockups and wireframes; specify the desired fidelity. +- infographic-diagram β€” diagrams/infographics with structured layout and text. +- logo-brand β€” logo/mark exploration, vector-friendly. +- illustration-story β€” comics, children’s book art, narrative scenes. +- stylized-concept β€” style-driven concept art, 3D/stylized renders. +- historical-scene β€” period-accurate/world-knowledge scenes. + +Edit: +- text-localization β€” translate/replace in-image text, preserve layout. +- identity-preserve β€” try-on, person-in-scene; lock face/body/pose. +- precise-object-edit β€” remove/replace a specific element (including interior swaps). +- lighting-weather β€” time-of-day/season/atmosphere changes only. +- background-extraction β€” transparent background / clean cutout. +- style-transfer β€” apply reference style while changing subject/scene. +- compositing β€” multi-image insert/merge with matched lighting/perspective. +- sketch-to-render β€” drawing/line art to photoreal render. + +## Shared prompt schema + +Use the following labeled spec as shared prompt scaffolding for both top-level modes: + +```text +Use case: +Asset type: +Primary request: +Input images: (optional) +Scene/backdrop: +Subject:
+Style/medium: +Composition/framing: +Lighting/mood: +Color palette: +Materials/textures: +Text (verbatim): "" +Constraints: +Avoid: +``` + +Notes: +- `Asset type` and `Input images` are prompt scaffolding, not dedicated CLI flags. +- `Scene/backdrop` refers to the visual setting. It is not the same as the fallback CLI `background` parameter, which controls output transparency behavior. +- Fallback-only execution notes such as `Quality:`, `Input fidelity:`, masks, output format, and output paths belong in the explicit CLI path only. Do not treat them as built-in `image_gen` tool arguments. + +Augmentation rules: +- Keep it short. +- Add only the details needed to improve the prompt materially. +- For edits, explicitly list invariants (`change only X; keep Y unchanged`). +- If any critical detail is missing and blocks success, ask a question; otherwise proceed. + +## Examples + +### Generation example (hero image) +```text +Use case: product-mockup +Asset type: landing page hero +Primary request: a minimal hero image of a ceramic coffee mug +Style/medium: clean product photography +Composition/framing: wide composition with usable negative space for page copy if needed +Lighting/mood: soft studio lighting +Constraints: no logos, no text, no watermark +``` + +### Edit example (invariants) +```text +Use case: precise-object-edit +Asset type: product photo background replacement +Primary request: replace only the background with a warm sunset gradient +Constraints: change only the background; keep the product and its edges unchanged; no text; no watermark +``` + +## Prompting best practices +- Structure prompt as scene/backdrop -> subject -> details -> constraints. +- Include intended use (ad, UI mock, infographic) to set the mode and polish level. +- Use camera/composition language for photorealism. +- Only use SVG/vector stand-ins when the user explicitly asked for vector output or a non-image placeholder. +- Quote exact text and specify typography + placement. +- For tricky words, spell them letter-by-letter and require verbatim rendering. +- For multi-image inputs, reference images by index and describe how they should be used. +- For edits, repeat invariants every iteration to reduce drift. +- Iterate with single-change follow-ups. +- If the prompt is generic, add only the extra detail that will materially help. +- If the prompt is already detailed, normalize it instead of expanding it. +- For explicit CLI fallback only, see `references/cli.md` and `references/image-api.md` for `quality`, `input_fidelity`, masks, output format, and output-path guidance. + +More principles shared by both modes: `references/prompting.md`. +Copy/paste specs shared by both modes: `references/sample-prompts.md`. + +## Guidance by asset type +Asset-type templates (website assets, game assets, wireframes, logo) are consolidated in `references/sample-prompts.md`. + +## Fallback CLI mode only + +### Temp and output conventions +These conventions apply only to the explicit CLI fallback. They do not describe built-in `image_gen` output behavior. +- Use `tmp/imagegen/` for intermediate files (for example JSONL batches); delete them when done. +- Write final artifacts under `output/imagegen/`. +- Use `--out` or `--out-dir` to control output paths; keep filenames stable and descriptive. + +### Dependencies +Prefer `uv` for dependency management in this repo. + +Required Python package: +```bash +uv pip install openai +``` + +Optional for downscaling only: +```bash +uv pip install pillow +``` + +Portability note: +- If you are using the installed skill outside this repo, install dependencies into that environment with its package manager. +- In uv-managed environments, `uv pip install ...` remains the preferred path. + +### Environment +- `OPENAI_API_KEY` must be set for live API calls. +- Do not ask the user for `OPENAI_API_KEY` when using the built-in `image_gen` tool. +- Never ask the user to paste the full key in chat. Ask them to set it locally and confirm when ready. + +If the key is missing, give the user these steps: +1. Create an API key in the OpenAI platform UI: https://platform.openai.com/api-keys +2. Set `OPENAI_API_KEY` as an environment variable in their system. +3. Offer to guide them through setting the environment variable for their OS/shell if needed. + +If installation is not possible in this environment, tell the user which dependency is missing and how to install it into their active environment. + +### Script-mode notes +- CLI commands + examples: `references/cli.md` +- API parameter quick reference: `references/image-api.md` +- Network approvals / sandbox settings for CLI mode: `references/codex-network.md` + +## Reference map +- `references/prompting.md`: shared prompting principles for both modes. +- `references/sample-prompts.md`: shared copy/paste prompt recipes for both modes. +- `references/cli.md`: fallback-only CLI usage via `scripts/image_gen.py`. +- `references/image-api.md`: fallback-only API/CLI parameter reference. +- `references/codex-network.md`: fallback-only network/sandbox troubleshooting for CLI mode. +- `scripts/image_gen.py`: fallback-only CLI implementation. Do not load or use it unless the user explicitly chooses CLI mode. diff --git a/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml b/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml new file mode 100644 index 0000000000..c9cfddb144 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Image Gen" + short_description: "Generate or edit images for websites, games, and more" + icon_small: "./assets/imagegen-small.svg" + icon_large: "./assets/imagegen.png" + default_prompt: "Generate or edit the visual assets for this task with the built-in `image_gen` tool by default. First confirm that the task actually calls for a raster image; if the project already has SVG/vector/code-native assets and the user wants to extend or match those, do not use this skill. If the task includes reference images, treat them as references unless the user clearly wants an existing image modified. For multi-asset requests, loop built-in calls rather than treating batch as a separate top-level mode. Only use the fallback CLI if the user explicitly asks for it, and keep CLI-only controls such as `generate-batch`, `quality`, `input_fidelity`, masks, and output paths on that fallback path." diff --git a/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen-small.svg b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen-small.svg new file mode 100644 index 0000000000..20128b2dba --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen-small.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen.png b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen.png new file mode 100644 index 0000000000..94b54541a9 Binary files /dev/null and b/codex-rs/skills/src/assets/samples/imagegen/assets/imagegen.png differ diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/cli.md b/codex-rs/skills/src/assets/samples/imagegen/references/cli.md new file mode 100644 index 0000000000..8cb0663c57 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/cli.md @@ -0,0 +1,160 @@ +# CLI reference (`scripts/image_gen.py`) + +This file is for the fallback CLI mode only. Read it only after the user explicitly asks to use `scripts/image_gen.py` instead of the built-in `image_gen` tool. + +`generate-batch` is a CLI subcommand in this fallback path. It is not a top-level mode of the skill. + +## What this CLI does +- `generate`: generate a new image from a prompt +- `edit`: edit one or more existing images +- `generate-batch`: run many generation jobs from a JSONL file + +Real API calls require **network access** + `OPENAI_API_KEY`. `--dry-run` does not. + +## Quick start (works from any repo) +Set a stable path to the skill CLI (default `CODEX_HOME` is `~/.codex`): + +``` +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export IMAGE_GEN="$CODEX_HOME/skills/imagegen/scripts/image_gen.py" +``` + +Install dependencies into that environment with its package manager. In uv-managed environments, `uv pip install ...` remains the preferred path. + +## Quick start + +Dry-run (no API call; no network required; does not require the `openai` package): + +```bash +python "$IMAGE_GEN" generate \ + --prompt "Test" \ + --out output/imagegen/test.png \ + --dry-run +``` + +Notes: +- One-off dry-runs print the API payload and the computed output path(s). +- Repo-local finals should live under `output/imagegen/`. + +Generate (requires `OPENAI_API_KEY` + network): + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A cozy alpine cabin at dawn" \ + --size 1024x1024 \ + --out output/imagegen/alpine-cabin.png +``` + +Edit: + +```bash +python "$IMAGE_GEN" edit \ + --image input.png \ + --prompt "Replace only the background with a warm sunset" \ + --out output/imagegen/sunset-edit.png +``` + +## Guardrails +- Use the bundled CLI directly (`python "$IMAGE_GEN" ...`) after activating the correct environment. +- Do **not** create one-off runners (for example `gen_images.py`) unless the user explicitly asks for a custom wrapper. +- **Never modify** `scripts/image_gen.py`. If something is missing, ask the user before doing anything else. + +## Defaults +- Model: `gpt-image-1.5` +- Supported model family for this CLI: GPT Image models (`gpt-image-*`) +- Size: `1024x1024` +- Quality: `auto` +- Output format: `png` +- Default one-off output path: `output/imagegen/output.png` +- Background: unspecified unless `--background` is set + +## Quality, input fidelity, and masks (CLI fallback only) +These are explicit CLI controls. They are not built-in `image_gen` tool arguments. + +- `--quality` works for `generate`, `edit`, and `generate-batch`: `low|medium|high|auto` +- `--input-fidelity` is **edit-only** and validated as `low|high` +- `--mask` is **edit-only** + +Example: + +```bash +python "$IMAGE_GEN" edit \ + --image input.png \ + --prompt "Change only the background" \ + --quality high \ + --input-fidelity high \ + --out output/imagegen/background-edit.png +``` + +Mask notes: +- For multi-image edits, pass repeated `--image` flags. Their order is meaningful, so describe each image by index and role in the prompt. +- The CLI accepts a single `--mask`. +- Use a PNG mask when possible; the script treats mask handling as best-effort and does not perform full preflight validation beyond file checks/warnings. +- In the edit prompt, repeat invariants (`change only the background; keep the subject unchanged`) to reduce drift. + +## Output handling +- Use `tmp/imagegen/` for temporary JSONL inputs or scratch files. +- Use `output/imagegen/` for final outputs. +- Reruns fail if a target file already exists unless you pass `--force`. +- `--out-dir` changes one-off naming to `image_1.`, `image_2.`, and so on. +- Downscaled copies use the default suffix `-web` unless you override it. + +## Common recipes + +Generate with augmentation fields: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A minimal hero image of a ceramic coffee mug" \ + --use-case "product-mockup" \ + --style "clean product photography" \ + --composition "wide product shot with usable negative space for page copy" \ + --constraints "no logos, no text" \ + --out output/imagegen/mug-hero.png +``` + +Generate + also write a downscaled copy for fast web loading: + +```bash +python "$IMAGE_GEN" generate \ + --prompt "A cozy alpine cabin at dawn" \ + --size 1024x1024 \ + --downscale-max-dim 1024 \ + --out output/imagegen/alpine-cabin.png +``` + +Generate multiple prompts concurrently (async batch): + +```bash +mkdir -p tmp/imagegen output/imagegen/batch +cat > tmp/imagegen/prompts.jsonl << 'EOF' +{"prompt":"Cavernous hangar interior with a compact shuttle parked near the center","use_case":"stylized-concept","composition":"wide-angle, low-angle","lighting":"volumetric light rays through drifting fog","constraints":"no logos or trademarks; no watermark","size":"1536x1024"} +{"prompt":"Gray wolf in profile in a snowy forest","use_case":"photorealistic-natural","composition":"eye-level","constraints":"no logos or trademarks; no watermark","size":"1024x1024"} +EOF + +python "$IMAGE_GEN" generate-batch \ + --input tmp/imagegen/prompts.jsonl \ + --out-dir output/imagegen/batch \ + --concurrency 5 + +rm -f tmp/imagegen/prompts.jsonl +``` + +Notes: +- `generate-batch` requires `--out-dir`. +- generate-batch requires --out-dir. +- Use `--concurrency` to control parallelism (default `5`). +- Per-job overrides are supported in JSONL (for example `size`, `quality`, `background`, `output_format`, `output_compression`, `moderation`, `n`, `model`, `out`, and prompt-augmentation fields). +- `--n` generates multiple variants for a single prompt; `generate-batch` is for many different prompts. +- In batch mode, per-job `out` is treated as a filename under `--out-dir`. + +## CLI notes +- Supported sizes: `1024x1024`, `1536x1024`, `1024x1536`, or `auto`. +- Transparent backgrounds require `output_format` to be `png` or `webp`. +- `--prompt-file`, `--output-compression`, `--moderation`, `--max-attempts`, `--fail-fast`, `--force`, and `--no-augment` are supported. +- This CLI is intended for GPT Image models. Do not assume older non-GPT image-model behavior applies here. + +## See also +- API parameter quick reference for fallback CLI mode: `references/image-api.md` +- Prompt examples shared across both top-level modes: `references/sample-prompts.md` +- Network/sandbox notes for fallback CLI mode: `references/codex-network.md` diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md b/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md new file mode 100644 index 0000000000..249d628262 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/codex-network.md @@ -0,0 +1,33 @@ +# Codex network approvals / sandbox notes + +This file is for the fallback CLI mode only. Read it only after the user explicitly asks to use `scripts/image_gen.py`. + +This guidance is intentionally isolated from `SKILL.md` because it can vary by environment and may become stale. Prefer the defaults in your environment when in doubt. + +## Why am I asked to approve image generation calls? +The fallback CLI uses the OpenAI Image API, so it needs outbound network access. In many Codex setups, network access is disabled by default and/or the approval policy requires confirmation before networked commands run. + +## Important note about approvals vs network +- `--ask-for-approval never` suppresses approval prompts. +- It does **not** by itself enable network access. +- In `workspace-write`, network access still depends on your Codex configuration (for example `[sandbox_workspace_write] network_access = true`). + +## How do I reduce repeated approval prompts? +If you trust the repo and want fewer prompts, use a configuration or profile that both: +- enables network for the sandbox mode you plan to use +- sets an approval policy that matches your risk tolerance + +Example `~/.codex/config.toml` pattern: + +```toml +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true +``` + +If you want quieter automation after network is enabled, you can choose a stricter approval policy, but do that intentionally and with care. + +## Safety note +Enabling network and reducing approvals lowers friction, but increases risk if you run untrusted code or work in an untrusted repository. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md b/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md new file mode 100644 index 0000000000..a2750c15c6 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/image-api.md @@ -0,0 +1,49 @@ +# Image API quick reference + +This file is for the fallback CLI mode only. Use it only after the user explicitly asks to use `scripts/image_gen.py` instead of the built-in `image_gen` tool. + +These parameters describe the Image API and bundled CLI fallback surface. Do not assume they are normal arguments on the built-in `image_gen` tool. + +## Scope +- This fallback CLI is intended for GPT Image models (`gpt-image-1.5`, `gpt-image-1`, and `gpt-image-1-mini`). +- The built-in `image_gen` tool and the fallback CLI do not expose the same controls. + +## Endpoints +- Generate: `POST /v1/images/generations` (`client.images.generate(...)`) +- Edit: `POST /v1/images/edits` (`client.images.edit(...)`) + +## Core parameters for GPT Image models +- `prompt`: text prompt +- `model`: image model +- `n`: number of images (1-10) +- `size`: `1024x1024`, `1536x1024`, `1024x1536`, or `auto` +- `quality`: `low`, `medium`, `high`, or `auto` +- `background`: output transparency behavior (`transparent`, `opaque`, or `auto`) for generated output; this is not the same thing as the prompt's visual scene/backdrop +- `output_format`: `png` (default), `jpeg`, `webp` +- `output_compression`: 0-100 (jpeg/webp only) +- `moderation`: `auto` (default) or `low` + +## Edit-specific parameters +- `image`: one or more input images. For GPT Image models, you can provide up to 16 images. +- `mask`: optional mask image +- `input_fidelity`: `low` (default) or `high` + +Model-specific note for `input_fidelity`: +- `gpt-image-1` and `gpt-image-1-mini` preserve all input images, but the first image gets richer textures and finer details. +- `gpt-image-1.5` preserves the first 5 input images with higher fidelity. + +## Output +- `data[]` list with `b64_json` per image +- The bundled `scripts/image_gen.py` CLI decodes `b64_json` and writes output files for you. + +## Limits and notes +- Input images and masks must be under 50MB. +- Use the edits endpoint when the user requests changes to an existing image. +- Masking is prompt-guided; exact shapes are not guaranteed. +- Large sizes and high quality increase latency and cost. +- High `input_fidelity` can materially increase input token usage. +- If a request fails because a specific option is unsupported by the selected GPT Image model, retry manually without that option. + +## Important boundary +- `quality`, `input_fidelity`, explicit masks, `background`, `output_format`, and related parameters are fallback-only execution controls. +- Do not assume they are built-in `image_gen` tool arguments. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md b/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md new file mode 100644 index 0000000000..8b6c684eef --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/prompting.md @@ -0,0 +1,98 @@ +# Prompting best practices + +These prompting principles are shared by both top-level modes of the skill: +- built-in `image_gen` tool (default) +- explicit `scripts/image_gen.py` CLI fallback + +This file is about prompt structure, specificity, and iteration. Fallback-only execution controls such as `quality`, `input_fidelity`, masks, output format, and output paths live in the fallback docs. + +## Contents +- [Structure](#structure) +- [Specificity policy](#specificity-policy) +- [Allowed and disallowed augmentation](#allowed-and-disallowed-augmentation) +- [Composition and layout](#composition-and-layout) +- [Constraints and invariants](#constraints-and-invariants) +- [Text in images](#text-in-images) +- [Input images and references](#input-images-and-references) +- [Iterate deliberately](#iterate-deliberately) +- [Fallback-only execution controls](#fallback-only-execution-controls) +- [Use-case tips](#use-case-tips) +- [Where to find copy/paste recipes](#where-to-find-copypaste-recipes) + +## Structure +- Use a consistent order: scene/backdrop -> subject -> key details -> constraints -> output intent. +- Include intended use (ad, UI mock, infographic) to set the level of polish. +- For complex requests, use short labeled lines instead of one long paragraph. + +## Specificity policy +- If the user prompt is already specific and detailed, normalize it into a clean spec without adding creative requirements. +- If the prompt is generic, you may add tasteful detail when it materially improves the output. +- Treat examples in `sample-prompts.md` as fully-authored recipes, not as the default amount of augmentation to add to every request. + +## Allowed and disallowed augmentation + +Allowed augmentation for generic prompts: +- composition and framing cues +- intended-use or polish-level hints +- practical layout guidance +- reasonable scene concreteness that supports the request + +Do not add: +- extra characters, props, or objects that are not implied +- brand palettes, slogans, or story beats that are not implied +- arbitrary side-specific placement unless the surrounding layout supports it + +## Composition and layout +- Specify framing and viewpoint (close-up, wide, top-down) and placement only when it materially helps. +- Call out negative space if the asset clearly needs room for UI or copy. +- Avoid making left/right layout decisions unless the user or surrounding layout supports them. + +## Constraints and invariants +- State what must not change (`keep background unchanged`). +- For edits, say `change only X; keep Y unchanged` and repeat invariants on every iteration to reduce drift. + +## Text in images +- Put literal text in quotes or ALL CAPS and specify typography (font style, size, color, placement). +- Spell uncommon words letter-by-letter if accuracy matters. +- For in-image copy, require verbatim rendering and no extra characters. + +## Input images and references +- Do not assume that every provided image is an edit target. +- Label each image by index and role (`Image 1: edit target`, `Image 2: style reference`). +- If the user provides images for style, composition, or mood guidance and does not ask to modify them, treat the request as generation with references. +- If the user asks to preserve an existing image while changing specific parts, treat the request as an edit. +- For compositing, describe how the images interact (`place the subject from Image 2 into Image 1`). + +## Iterate deliberately +- Start with a clean base prompt, then make small single-change edits. +- Re-specify critical constraints when you iterate. +- Prefer one targeted follow-up at a time over rewriting the whole prompt. + +## Fallback-only execution controls +- `quality`, `input_fidelity`, explicit masks, output format, and output paths are fallback-only execution controls. +- Do not assume they are built-in `image_gen` tool arguments. +- If the user explicitly chooses CLI fallback, see `references/cli.md` and `references/image-api.md` for those controls. + +## Use-case tips +Generate: +- photorealistic-natural: Prompt as if a real photo is captured in the moment; use photography language (lens, lighting, framing); call for real texture; avoid over-stylized polish unless requested. +- product-mockup: Describe the product/packaging and materials; ensure clean silhouette and label clarity; if in-image text is needed, require verbatim rendering and specify typography. +- ui-mockup: Describe the target fidelity first (shippable mockup or low-fi wireframe), then focus on layout, hierarchy, and practical UI elements; avoid concept-art language. +- infographic-diagram: Define the audience and layout flow; label parts explicitly; require verbatim text. +- logo-brand: Keep it simple and scalable; ask for a strong silhouette and balanced negative space; avoid decorative flourishes unless requested. +- illustration-story: Define panels or scene beats; keep each action concrete. +- stylized-concept: Specify style cues, material finish, and rendering approach (3D, painterly, clay) without inventing new story elements. +- historical-scene: State the location/date and required period accuracy; constrain clothing, props, and environment to match the era. + +Edit: +- text-localization: Change only the text; preserve layout, typography, spacing, and hierarchy; no extra words or reflow unless needed. +- identity-preserve: Lock identity (face, body, pose, hair, expression); change only the specified elements; match lighting and shadows. +- precise-object-edit: Specify exactly what to remove/replace; preserve surrounding texture and lighting; keep everything else unchanged. +- lighting-weather: Change only environmental conditions (light, shadows, atmosphere, precipitation); keep geometry, framing, and subject identity. +- background-extraction: Request a clean cutout; crisp silhouette; no halos; preserve label text exactly; no restyling. +- style-transfer: Specify style cues to preserve (palette, texture, brushwork) and what must change; add `no extra elements` to prevent drift. +- compositing: Reference inputs by index; specify what moves where; match lighting, perspective, and scale; keep the base framing unchanged. +- sketch-to-render: Preserve layout, proportions, and perspective; choose materials and lighting that support the supplied sketch without adding new elements. + +## Where to find copy/paste recipes +For copy/paste prompt specs (examples only), see `references/sample-prompts.md`. This file focuses on principles, specificity, and iteration patterns. diff --git a/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md b/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md new file mode 100644 index 0000000000..e4b2b01da0 --- /dev/null +++ b/codex-rs/skills/src/assets/samples/imagegen/references/sample-prompts.md @@ -0,0 +1,376 @@ +# Sample prompts (copy/paste) + +These prompt recipes are shared across both top-level modes of the skill: +- built-in `image_gen` tool (default) +- explicit `scripts/image_gen.py` CLI fallback + +Use these as starting points. They are intentionally complete prompt recipes, not the default amount of augmentation to add to every user request. + +When adapting a user's prompt: +- keep user-provided requirements +- only add detail according to the specificity policy in `SKILL.md` +- do not treat every example below as permission to invent extra story elements + +The labeled lines are prompt scaffolding, not a closed schema. `Asset type` and `Input images` are prompt-only scaffolding; the CLI does not expose them as dedicated flags. + +Execution details such as explicit CLI flags, `quality`, `input_fidelity`, masks, output formats, and local output paths depend on mode. Use the built-in tool by default; only apply CLI-specific controls after the user explicitly opts into fallback mode. + +For prompting principles (structure, specificity, invariants, iteration), see `references/prompting.md`. + +## Generate + +### photorealistic-natural +``` +Use case: photorealistic-natural +Primary request: candid photo of an elderly sailor on a small fishing boat adjusting a net +Scene/backdrop: coastal water with soft haze +Subject: weathered skin with wrinkles and sun texture +Style/medium: photorealistic candid photo +Composition/framing: medium close-up, eye-level +Lighting/mood: soft coastal daylight, shallow depth of field, subtle film grain +Materials/textures: real skin texture, worn fabric, salt-worn wood +Constraints: natural color balance; no heavy retouching; no glamorization; no watermark +Avoid: studio polish; staged look +``` + +### product-mockup +``` +Use case: product-mockup +Primary request: premium product photo of a matte black shampoo bottle with a minimal label +Scene/backdrop: clean studio gradient from light gray to white +Subject: single bottle centered with subtle reflection +Style/medium: premium product photography +Composition/framing: centered, slight three-quarter angle, generous padding +Lighting/mood: softbox lighting, clean highlights, controlled shadows +Materials/textures: matte plastic, crisp label printing +Constraints: no logos or trademarks; no watermark +``` + +### ui-mockup +``` +Use case: ui-mockup +Primary request: mobile app home screen for a local farmers market with vendors and daily specials +Asset type: mobile app screen +Style/medium: realistic product UI, not concept art +Composition/framing: clean vertical mobile layout with clear hierarchy +Constraints: practical layout, clear typography, no logos or trademarks, no watermark +``` + +### infographic-diagram +``` +Use case: infographic-diagram +Primary request: detailed infographic of an automatic coffee machine flow +Scene/backdrop: clean, light neutral background +Subject: bean hopper -> grinder -> brew group -> boiler -> water tank -> drip tray +Style/medium: clean vector-like infographic with clear callouts and arrows +Composition/framing: vertical poster layout, top-to-bottom flow +Text (verbatim): "Bean Hopper", "Grinder", "Brew Group", "Boiler", "Water Tank", "Drip Tray" +Constraints: clear labels, strong contrast, no logos or trademarks, no watermark +``` + +### logo-brand +``` +Use case: logo-brand +Primary request: original logo for "Field & Flour", a local bakery +Style/medium: vector logo mark; flat colors; minimal +Composition/framing: single centered logo on a plain background with generous padding +Constraints: strong silhouette, balanced negative space; original design only; no gradients unless essential; no trademarks; no watermark +``` + +### illustration-story +``` +Use case: illustration-story +Primary request: 4-panel comic about a pet left alone at home +Scene/backdrop: cozy living room across panels +Subject: pet reacting to the owner leaving, then relaxing, then returning to a composed pose +Style/medium: comic illustration with clear panels +Composition/framing: 4 equal-sized vertical panels, readable actions per panel +Constraints: no text; no logos or trademarks; no watermark +``` + +### stylized-concept +``` +Use case: stylized-concept +Primary request: cavernous hangar interior with tall support beams and drifting fog +Scene/backdrop: industrial hangar interior, deep scale, light haze +Subject: compact shuttle parked near the center +Style/medium: cinematic concept art, industrial realism +Composition/framing: wide-angle, low-angle +Lighting/mood: volumetric light rays cutting through fog +Constraints: no logos or trademarks; no watermark +``` + +### historical-scene +``` +Use case: historical-scene +Primary request: outdoor crowd scene in Bethel, New York on August 16, 1969 +Scene/backdrop: open field with period-appropriate staging +Subject: crowd in period-accurate clothing, authentic environment +Style/medium: photorealistic photo +Composition/framing: wide shot, eye-level +Constraints: period-accurate details; no modern objects; no logos or trademarks; no watermark +``` + +## Asset type templates (taxonomy-aligned) + +### Website assets template +``` +Use case: +Asset type: +Primary request: +Scene/backdrop: +Subject:
+Style/medium: +Composition/framing: +Lighting/mood: +Color palette: +Constraints: +``` + +### Website assets example: minimal hero background +``` +Use case: stylized-concept +Asset type: landing page hero background +Primary request: minimal abstract background with a soft gradient and subtle texture +Style/medium: matte illustration / soft-rendered abstract background +Composition/framing: wide composition with usable negative space for page copy +Lighting/mood: gentle studio glow +Color palette: restrained neutral palette +Constraints: no text; no logos; no watermark +``` + +### Website assets example: feature section illustration +``` +Use case: stylized-concept +Asset type: feature section illustration +Primary request: simple abstract shapes suggesting connection and flow +Scene/backdrop: subtle light-gray backdrop with faint texture +Style/medium: flat illustration; soft shadows; restrained contrast +Composition/framing: centered cluster; open margins for UI +Color palette: muted neutral palette +Constraints: no text; no logos; no watermark +``` + +### Website assets example: blog header image +``` +Use case: photorealistic-natural +Asset type: blog header image +Primary request: overhead desk scene with notebook, pen, and coffee cup +Scene/backdrop: warm wooden tabletop +Style/medium: photorealistic photo +Composition/framing: wide crop with clean room for page copy +Lighting/mood: soft morning light +Constraints: no text; no logos; no watermark +``` + +### Game assets template +``` +Use case: stylized-concept +Asset type: +Primary request: +Scene/backdrop: (if applicable) +Subject:
+Style/medium: ; +Composition/framing: ; ; +Lighting/mood: