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/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 f7f650b8c1..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 @@ -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 }} diff --git a/.github/workflows/shell-tool-mcp-ci.yml b/.github/workflows/shell-tool-mcp-ci.yml deleted file mode 100644 index 62a61efb93..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@v5 - 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 71d1725caf..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@v5 - 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 9ead5c6e17..935fc413a5 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,6 +1444,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-git-utils", "codex-login", "codex-otel", "codex-protocol", @@ -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", @@ -1768,6 +1770,7 @@ dependencies = [ "codex-client", "codex-cloud-tasks-client", "codex-core", + "codex-git-utils", "codex-login", "codex-tui", "codex-utils-cli", @@ -1794,7 +1797,7 @@ dependencies = [ "async-trait", "chrono", "codex-backend-client", - "codex-git", + "codex-git-utils", "diffy", "serde", "serde_json", @@ -1879,7 +1882,7 @@ dependencies = [ "codex-execpolicy", "codex-features", "codex-file-search", - "codex-git", + "codex-git-utils", "codex-hooks", "codex-login", "codex-network-proxy", @@ -1993,6 +1996,7 @@ dependencies = [ "codex-cloud-requirements", "codex-core", "codex-feedback", + "codex-git-utils", "codex-otel", "codex-protocol", "codex-utils-absolute-path", @@ -2133,10 +2137,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", @@ -2144,6 +2150,7 @@ dependencies = [ "serde", "tempfile", "thiserror 2.0.18", + "tokio", "ts-rs", "walkdir", ] @@ -2389,7 +2396,7 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-execpolicy", - "codex-git", + "codex-git-utils", "codex-utils-absolute-path", "codex-utils-image", "icu_decimal", @@ -2484,6 +2491,7 @@ dependencies = [ "age", "anyhow", "base64 0.22.1", + "codex-git-utils", "codex-keyring-store", "keyring", "pretty_assertions", @@ -2554,6 +2562,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "codex-git-utils", "codex-protocol", "dirs", "log", @@ -2620,6 +2629,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-git-utils", "codex-login", "codex-otel", "codex-protocol", @@ -2713,6 +2723,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-git-utils", "codex-login", "codex-otel", "codex-protocol", @@ -3836,7 +3847,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4081,7 +4092,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]] @@ -5526,7 +5537,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6289,7 +6300,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]] @@ -6933,7 +6944,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]] @@ -8355,7 +8366,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9788,7 +9799,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11234,7 +11245,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 e0c7633733..e071ec807e 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -50,7 +50,7 @@ members = [ "v8-poc", "utils/absolute-path", "utils/cargo-bin", - "utils/git", + "git-utils", "utils/cache", "utils/image", "utils/json-to-toml", @@ -117,7 +117,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" } 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/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a644549325..104a727f3c 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 @@ -8557,6 +8557,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -9515,6 +9530,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/v2/PluginMarketplaceEntry" 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 71e6034529..8f4f962167 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 @@ -5305,6 +5305,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -6263,6 +6278,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/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/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/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c649aec06a..1178f7c2ee 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -141,6 +141,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..d4a8c70dd5 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")] diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 81f3cc58a8..67e552dea3 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")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3e30f81323..09647e05cc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3146,11 +3146,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/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f9e069703e..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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a536e5c68b..c238cc73e3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -163,7 +163,7 @@ Example with notification opt-out: - `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`. - `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/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. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c4c2799ca8..32eb28c12b 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -213,7 +213,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 +242,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; @@ -409,6 +409,13 @@ 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, @@ -1338,13 +1345,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 +1380,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, @@ -1911,6 +1931,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; } @@ -5523,11 +5550,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 +5585,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 +5639,7 @@ impl CodexMessageProcessor { request_id, PluginListResponse { marketplaces: data, + marketplace_load_errors, remote_sync_error, featured_plugin_ids, }, @@ -8188,7 +8231,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(), } @@ -8871,6 +8914,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 e3f43b06c3..8f92cd0ba2 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -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(), @@ -807,6 +814,7 @@ mod tests { let OutgoingEnvelope::ToConnection { connection_id, message, + .. } = envelope else { panic!("expected connection-scoped outgoing message"); @@ -887,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/in_process.rs b/codex-rs/app-server/src/in_process.rs index b9695c482c..a1479f31b0 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; @@ -353,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())); @@ -457,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; @@ -547,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) { @@ -629,6 +632,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } } } + if let Some(write_complete_tx) = queued_message.write_complete_tx { + let _ = write_complete_tx.send(()); + } } } } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a67f68af15..0a5a4a9a78 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; @@ -103,7 +104,7 @@ enum OutboundControlEvent { /// Register a new writer for an opened connection. Opened { connection_id: ConnectionId, - writer: mpsc::Sender, + writer: mpsc::Sender, disconnect_sender: Option, initialized: Arc, experimental_api_enabled: Arc, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4f53bfc1a4..96fe437c05 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -451,6 +451,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() 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 51a89280a7..aa66f79f29 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -390,6 +390,7 @@ async fn read_response( let crate::outgoing_message::OutgoingEnvelope::ToConnection { connection_id, message, + .. } = envelope else { continue; @@ -420,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 5802eb44c9..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,6 +546,28 @@ impl OutgoingMessageSender { } } + pub(crate) async fn send_server_notification_to_connection_and_wait( + &self, + connection_id: ConnectionId, + notification: ServerNotification, + ) { + 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( &self, request_id: ConnectionRequestId, @@ -566,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 @@ -818,6 +860,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message, + .. } => { assert_eq!(connection_id, ConnectionId(42)); let OutgoingMessage::Response(response) = message else { @@ -880,6 +923,7 @@ mod tests { OutgoingEnvelope::ToConnection { connection_id, message, + .. } => { assert_eq!(connection_id, ConnectionId(9)); let OutgoingMessage::Error(outgoing_error) = message else { @@ -892,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 5fd9fa42cf..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,7 +188,7 @@ impl FromStr for AppServerTransport { pub(crate) enum TransportEvent { ConnectionOpened { connection_id: ConnectionId, - writer: mpsc::Sender, + writer: mpsc::Sender, disconnect_sender: Option, }, ConnectionClosed { @@ -225,13 +226,13 @@ pub(crate) struct OutboundConnectionState { pub(crate) initialized: Arc, pub(crate) experimental_api_enabled: Arc, pub(crate) opted_out_notification_methods: Arc>>, - 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>>, @@ -262,7 +263,7 @@ 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 { @@ -309,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'); @@ -318,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)"); })); @@ -364,7 +368,7 @@ 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 @@ -415,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, ) { @@ -432,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(()); + } } } } @@ -450,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, @@ -501,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 { @@ -518,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 { @@ -541,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)) => { @@ -607,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:?}"); @@ -618,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!( @@ -631,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 @@ -670,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 @@ -688,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; } } } @@ -800,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!({ @@ -904,13 +925,15 @@ mod tests { .expect("transport queue should accept first message"); writer_tx - .send(OutgoingMessage::AppServerNotification( - ServerNotification::ConfigWarning(ConfigWarningNotification { - summary: "queued".to_string(), - details: None, - path: None, - range: 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"); @@ -934,7 +957,8 @@ 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"); + let queued_json = + serde_json::to_value(queued_outgoing.message).expect("serialize queued message"); assert_eq!( queued_json, json!({ @@ -979,6 +1003,7 @@ mod tests { range: None, }, )), + write_complete_tx: None, }, ) .await; @@ -989,6 +1014,92 @@ mod tests { ); } + #[tokio::test] + 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); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::from(["configWarning".to_string()]))), + None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + 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(), + "opted-out notifications should not reach clients" + ); + } + + #[tokio::test] + 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); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { + summary: "task_started".to_string(), + details: None, + path: None, + range: None, + }, + )), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("notification should reach non-opted-out clients"); + assert!(matches!( + message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( + ConfigWarningNotification { summary, .. } + )) if summary == "task_started" + )); + } + #[tokio::test] async fn command_execution_request_approval_strips_experimental_fields_without_capability() { let connection_id = ConnectionId(8); @@ -1042,6 +1153,7 @@ mod tests { available_decisions: None, }, }), + write_complete_tx: None, }, ) .await; @@ -1050,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); } @@ -1108,6 +1220,7 @@ mod tests { available_decisions: None, }, }), + write_complete_tx: None, }, ) .await; @@ -1116,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"], @@ -1178,7 +1291,7 @@ mod tests { }), ); slow_writer_tx - .try_send(queued_message) + .try_send(QueuedOutgoingMessage::new(queued_message)) .expect("channel should have room"); let broadcast_message = OutgoingMessage::AppServerNotification( @@ -1207,7 +1320,7 @@ mod tests { .try_recv() .expect("fast connection should receive the broadcast notification"); assert!(matches!( - fast_message, + fast_message.message, OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( ConfigWarningNotification { summary, .. } )) if summary == "test" @@ -1217,7 +1330,7 @@ mod tests { .try_recv() .expect("slow connection should retain its original buffered message"); assert!(matches!( - slow_message, + slow_message.message, OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( ConfigWarningNotification { summary, .. } )) if summary == "already-buffered" @@ -1229,13 +1342,15 @@ mod tests { let connection_id = ConnectionId(3); let (writer_tx, mut writer_rx) = mpsc::channel(1); writer_tx - .send(OutgoingMessage::AppServerNotification( - ServerNotification::ConfigWarning(ConfigWarningNotification { - summary: "queued".to_string(), - details: None, - path: None, - range: 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"); @@ -1265,6 +1380,7 @@ mod tests { range: None, }), ), + write_complete_tx: None, }, ) .await @@ -1280,7 +1396,7 @@ mod tests { .expect("routing task should succeed"); assert!(matches!( - first, + first.message, OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( ConfigWarningNotification { summary, .. } )) if summary == "queued" @@ -1289,7 +1405,7 @@ mod tests { .try_recv() .expect("second notification should be delivered once the queue has room"); assert!(matches!( - second, + second.message, OutgoingMessage::AppServerNotification(ServerNotification::ConfigWarning( ConfigWarningNotification { summary, .. } )) if summary == "second" 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/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/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/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/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/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 9c20345170..82ea260055 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -41,7 +41,7 @@ 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 } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 7b2910db1d..a3b9897688 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -812,35 +812,32 @@ impl AgentControl { return; }; let child_thread = state.get_thread(child_thread_id).await.ok(); - if let Some(child_agent_path) = child_agent_path + 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 AgentStatus::Completed(Some(content)) = &status else { + let Some(child_agent_path) = child_agent_path.clone() else { return; }; - let Some((parent_path, _)) = child_agent_path.as_str().rsplit_once('/') else { - return; - }; - let Ok(parent_agent_path) = AgentPath::try_from(parent_path) else { - return; - }; - let Some(parent_thread_id) = control.state.agent_id_for_path(&parent_agent_path) + 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, - InterAgentCommunication::new( - child_agent_path, - parent_agent_path, - Vec::new(), - content.clone(), - ), - ) + .send_inter_agent_communication(parent_thread_id, communication) .await; return; } @@ -848,10 +845,7 @@ impl AgentControl { return; }; parent_thread - .inject_user_message_without_turn(format_subagent_notification_message( - child_reference.as_str(), - &status, - )) + .inject_user_message_without_turn(message) .await; }); } @@ -1111,7 +1105,7 @@ fn last_task_message_from_item(item: &ResponseItem) -> Option { } match item { - ResponseItem::Message { role, content, .. } if role == "user" => { + ResponseItem::Message { role, .. } if role == "user" => { let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else { return None; }; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 38fbcbed51..10e9821aea 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -29,8 +29,6 @@ use tempfile::TempDir; use tokio::time::Duration; use tokio::time::sleep; use tokio::time::timeout; - -const MULTI_AGENT_EVENTUAL_TIMEOUT: Duration = Duration::from_secs(5); use toml::Value as TomlValue; async fn test_config_with_cli_overrides( @@ -409,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; @@ -964,94 +1023,6 @@ 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_sends_inter_agent_message_to_direct_parent() { - let harness = AgentControlHarness::new().await; - let (root_thread_id, _) = 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"); - - 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; - - let expected = InterAgentCommunication::new( - tester_path.clone(), - worker_path.clone(), - Vec::new(), - "done".to_string(), - ); - - timeout(MULTI_AGENT_EVENTUAL_TIMEOUT, async { - loop { - let delivered = harness - .manager - .captured_ops() - .into_iter() - .any(|(thread_id, op)| { - thread_id == worker_thread_id - && matches!( - op, - Op::InterAgentCommunication { communication } - if communication == expected - ) - }); - if delivered { - break; - } - sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("completion watcher should send inter-agent communication"); -} - #[tokio::test] async fn multi_agent_v2_completion_ignores_dead_direct_parent() { let harness = AgentControlHarness::new().await; @@ -1147,11 +1118,109 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { 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; 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 d305a1e108..b299bec369 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -235,9 +235,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 +319,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 +349,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 +403,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 +456,7 @@ impl Codex { skills_manager, plugins_manager, mcp_manager, - file_watcher, + skills_watcher, conversation_history, session_source, agent_control, @@ -644,7 +644,7 @@ impl Codex { skills_manager, plugins_manager, mcp_manager.clone(), - file_watcher, + skills_watcher, agent_control, ) .await @@ -1297,13 +1297,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; }; @@ -1439,7 +1439,7 @@ impl Session { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, - file_watcher: Arc, + skills_watcher: Arc, agent_control: AgentControl, ) -> anyhow::Result> { debug!( @@ -1858,7 +1858,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), @@ -1937,7 +1937,7 @@ 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 { @@ -3537,7 +3537,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 @@ -4650,27 +4659,27 @@ 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 sess - .inject_response_items(vec![pending_item.clone()]) - .await - .is_ok() - { + if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await { return; } - 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.queue_response_items_for_next_turn(vec![pending_item]) .await; - sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) - .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) { diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 33fb5f9af7..906c72c92a 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -41,6 +41,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { AgentPath::root().join("worker").unwrap(), Vec::new(), text.to_string(), + true, ); ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index e560cd9c7f..a81970756b 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -80,7 +80,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(), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ba92bbae70..9f38540ec6 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; @@ -1166,6 +1167,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 = @@ -2494,7 +2524,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 +2623,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 +2657,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), @@ -3428,7 +3458,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( @@ -3462,7 +3492,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), diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index af0fccc9ac..e9050024eb 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -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/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17c45e26a1..14c7f6b04c 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 diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index f57457b669..cc0e0b6b9a 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; 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 57807e5f85..b518d8ce4f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,5 +1,7 @@ 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; @@ -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( diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 1a4dc0ed8e..0c92edcfb6 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -3,8 +3,9 @@ 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; @@ -18,12 +19,16 @@ 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 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; @@ -46,6 +51,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { AgentPath::root().join("worker").unwrap(), Vec::new(), text.to_string(), + true, ); ResponseItem::Message { id: None, @@ -90,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(), @@ -252,6 +308,15 @@ fn inter_agent_assistant_messages_are_turn_boundaries() { 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"); @@ -851,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/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/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 c49d81c9a1..90ceb7241e 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -1,10 +1,15 @@ -use super::*; - -use codex_exec_server::LOCAL_FS; +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 { @@ -192,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())); @@ -449,23 +454,6 @@ async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() { ); } -#[tokio::test] -async fn resolve_root_git_project_for_trust_with_fs_regular_repo_returns_repo_root() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - let expected = std::fs::canonicalize(&repo_path).unwrap(); - assert_eq!( - resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &repo_path).await, - Some(expected.clone()) - ); - let nested = repo_path.join("sub/dir"); - std::fs::create_dir_all(&nested).unwrap(); - assert_eq!( - resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &nested).await, - Some(expected) - ); -} - #[tokio::test] async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); @@ -498,37 +486,6 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro assert_eq!(got_nested, expected); } -#[tokio::test] -async fn resolve_root_git_project_for_trust_with_fs_detects_worktree_and_returns_main_root() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let repo_path = create_test_git_repo(&temp_dir).await; - - let wt_root = temp_dir.path().join("wt"); - let _ = std::process::Command::new("git") - .args([ - "worktree", - "add", - wt_root.to_str().unwrap(), - "-b", - "feature/x", - ]) - .current_dir(&repo_path) - .output() - .expect("git worktree add"); - - let expected = std::fs::canonicalize(&repo_path).ok(); - let got = resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &wt_root) - .await - .and_then(|path| std::fs::canonicalize(path).ok()); - assert_eq!(got, expected); - let nested = wt_root.join("nested/sub"); - std::fs::create_dir_all(&nested).unwrap(); - let got_nested = resolve_root_git_project_for_trust_with_fs(&LOCAL_FS, &nested) - .await - .and_then(|path| std::fs::canonicalize(path).ok()); - assert_eq!(got_nested, expected); -} - #[tokio::test] async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() { let tmp = TempDir::new().expect("tempdir"); @@ -620,7 +577,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/lib.rs b/codex-rs/core/src/lib.rs index 1c92080502..decebc84e0 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,9 +39,10 @@ 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; @@ -72,6 +73,7 @@ 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; @@ -173,6 +175,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; 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/manager.rs b/codex-rs/core/src/plugins/manager.rs index 571535b5f0..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; @@ -178,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, @@ -996,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(); @@ -1043,7 +1050,12 @@ impl PluginsManager { plugins, }) }) - .collect()) + .collect(); + + Ok(ConfiguredMarketplaceListOutcome { + marketplaces, + errors: marketplace_outcome.errors, + }) } pub fn read_plugin_for_config( @@ -1550,7 +1562,14 @@ fn load_plugin( }; 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( diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 630808dc0e..3d61c39375 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1035,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() @@ -1130,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()); } @@ -1177,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() @@ -1382,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() @@ -1487,7 +1491,8 @@ enabled = false AbsolutePathBuf::try_from(repo_b_root).unwrap(), ], ) - .unwrap(); + .unwrap() + .marketplaces; let repo_a_marketplace = marketplaces .iter() @@ -1590,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() @@ -1733,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(); 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/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index 280f8a9d6d..f96bdc6a45 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -1,12 +1,10 @@ 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::git_info::resolve_root_git_project_for_trust_with_fs; use crate::truncate::TruncationPolicy; use crate::truncate::truncate_text; use chrono::Utc; -use codex_exec_server::ExecutorFileSystem; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::models::ResponseItem; use codex_state::SortKey; use codex_state::ThreadMetadata; @@ -56,13 +54,11 @@ pub(crate) async fn build_realtime_startup_context( ) -> Option { let config = sess.get_config().await; let cwd = config.cwd.clone(); - let file_system = sess.services.environment.get_filesystem(); let history = sess.clone_history().await; let current_thread_section = build_current_thread_section(history.raw_items()); let recent_threads = load_recent_threads(sess).await; let recent_work_section = build_recent_work_section(&cwd, &recent_threads).await; - let workspace_section = - build_workspace_section_with_user_root(file_system.as_ref(), &cwd, home_dir()).await; + let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir()).await; if current_thread_section.is_none() && recent_work_section.is_none() @@ -286,11 +282,10 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option { } async fn build_workspace_section_with_user_root( - file_system: &dyn ExecutorFileSystem, cwd: &Path, user_root: Option, ) -> Option { - let git_root = resolve_root_git_project_for_trust_with_fs(file_system, cwd).await; + let git_root = resolve_root_git_project_for_trust(cwd).await; let cwd_tree = render_tree(cwd); let git_root_tree = git_root .as_ref() diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 5c61105722..d2c4fb90df 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -2,7 +2,6 @@ use super::build_recent_work_section; use super::build_workspace_section_with_user_root; use chrono::TimeZone; use chrono::Utc; -use codex_exec_server::LOCAL_FS; use codex_protocol::ThreadId; use codex_state::ThreadMetadata; use pretty_assertions::assert_eq; @@ -48,7 +47,7 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe async fn workspace_section_requires_meaningful_structure() { let cwd = TempDir::new().expect("tempdir"); assert_eq!( - build_workspace_section_with_user_root(&LOCAL_FS, cwd.path(), None).await, + build_workspace_section_with_user_root(cwd.path(), None).await, None ); } @@ -59,7 +58,7 @@ async fn workspace_section_includes_tree_when_entries_exist() { fs::create_dir(cwd.path().join("docs")).expect("create docs dir"); fs::write(cwd.path().join("README.md"), "hello").expect("write readme"); - let section = build_workspace_section_with_user_root(&LOCAL_FS, cwd.path(), None) + let section = build_workspace_section_with_user_root(cwd.path(), None) .await .expect("workspace section"); assert!(section.contains("Working directory tree:")); @@ -81,7 +80,7 @@ async fn workspace_section_includes_user_root_tree_when_distinct() { fs::create_dir_all(user_root.join("code")).expect("create user root child"); fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file"); - let section = build_workspace_section_with_user_root(&LOCAL_FS, cwd.as_path(), Some(user_root)) + let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root)) .await .expect("workspace section"); assert!(section.contains("User root tree:")); 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/list.rs b/codex-rs/core/src/rollout/list.rs index 8a3e41006c..bf32e23c8d 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -1039,7 +1039,7 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result 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/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/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/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 c737ad63a3..601f5cb27f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -30,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; @@ -192,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))); @@ -236,6 +230,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)), @@ -245,8 +254,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) { @@ -406,23 +415,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() 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/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 642def5559..6ce55c6229 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -12,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; @@ -24,6 +23,8 @@ 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; @@ -83,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, @@ -117,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 @@ -201,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, @@ -233,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())), @@ -248,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() @@ -299,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())), @@ -312,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() @@ -342,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() } @@ -838,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, .. @@ -849,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, 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 80e5e5c2e8..4a6aa26ff4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -21,8 +21,9 @@ 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::SendInputHandler as SendInputHandlerV2; +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; @@ -337,7 +338,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, @@ -400,18 +401,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( Some("/root/test_process") ); - SendInputHandlerV2 + 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"); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id @@ -422,6 +423,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( && communication.recipient.as_str() == "/root/test_process" && communication.other_recipients.is_empty() && communication.content == "continue" + && !communication.trigger_turn ) })); } @@ -647,7 +649,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { } #[tokio::test] -async fn multi_agent_v2_send_input_accepts_structured_items() { +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 @@ -683,7 +685,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { let invocation = invocation( session, turn, - "send_input", + "send_message", function_payload(json!({ "target": agent_id.to_string(), "items": [ @@ -693,33 +695,19 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { })), ); - SendInputHandlerV2 - .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))); + 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 @@ -771,19 +759,161 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( ) .await; - SendInputHandlerV2 + 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 + .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" + && !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 @@ -801,14 +931,6 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( && communication.content == "continue" ) })); - 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" - )) - ))); timeout(Duration::from_secs(5), async { loop { @@ -826,6 +948,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( AgentPath::try_from("/root/worker").expect("agent path"), Vec::new(), "continue".to_string(), + true, ), ); let saw_user_message = history_items.iter().any(|item| { @@ -846,7 +969,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 {}) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 6e0e517dde..00ee579764 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -30,12 +30,15 @@ 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_input::Handler as SendInputHandler; +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 send_input; +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/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_input.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs deleted file mode 100644 index c17e12cca8..0000000000 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::*; -use codex_protocol::protocol::InterAgentCommunication; - -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; - - 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: SendInputArgs = parse_arguments(&arguments)?; - let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = input_preview(&input_items); - 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 result = if 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 communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - ); - session - .services - .agent_control - .send_inter_agent_communication(receiver_thread_id, communication) - .await - } else { - session - .services - .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 - .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(SendInputResult { submission_id }) - } -} - -#[derive(Debug, Deserialize)] -struct SendInputArgs { - target: String, - message: Option, - items: Option>, - #[serde(default)] - interrupt: bool, -} - -#[derive(Debug, Serialize)] -pub(crate) struct SendInputResult { - submission_id: String, -} - -impl ToolOutput for SendInputResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "send_input") - } - - 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), "send_input") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "send_input") - } -} 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/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/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 2c700160d0..8c4f24ed05 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -889,7 +889,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()); } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index fdbbfa1eb7..77c2954067 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -389,7 +389,10 @@ impl ToolsConfig { 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_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(); @@ -1448,6 +1451,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( @@ -2695,8 +2772,9 @@ 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::SendInputHandler as SendInputHandlerV2; + 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; @@ -3085,10 +3163,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, @@ -3122,7 +3212,8 @@ pub(crate) fn build_specs_with_discoverable_tools( config.code_mode_enabled, ); builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); - builder.register_handler("send_input", Arc::new(SendInputHandlerV2)); + 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 { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index fbd3c1ae80..26172c10bd 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -550,7 +550,8 @@ async fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { &tools, &[ "spawn_agent", - "send_input", + "send_message", + "assign_task", "wait_agent", "close_agent", "list_agents", @@ -584,9 +585,9 @@ async 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, @@ -594,10 +595,33 @@ async 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 { @@ -652,6 +676,7 @@ async fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { 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"); } @@ -2015,6 +2040,7 @@ async fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2045,6 +2071,51 @@ async fn tool_suggest_is_not_registered_without_feature_flag() { ); } +#[tokio::test] +async fn tool_suggest_requires_apps_and_plugins_features() { + let model_info = search_capable_model_info().await; + 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::ToolSuggest); + for feature in [Feature::Apps, Feature::Plugins] { + if feature != disabled_feature { + features.enable(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" + ); + } +} #[tokio::test] async fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info().await; @@ -2189,6 +2260,7 @@ async fn tool_suggest_description_lists_discoverable_tools() { let model_info = search_capable_model_info().await; let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { 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/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/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index f4f89281db..7da4d4d774 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -310,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; @@ -349,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"]); @@ -382,20 +387,18 @@ 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); + 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, + 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(), - expected_after_second_compact_user_texts[4].clone(), - ]; + 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"); @@ -403,11 +406,16 @@ async fn compact_resume_after_second_compaction_preserves_history() -> Result<() .split_last() .unwrap_or_else(|| panic!("after-second-resume request missing user messages")); assert_eq!(final_last, AFTER_SECOND_RESUME); - let matched_prefix_len = if final_prefix.starts_with(&expected_after_second_compact_user_texts) + 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) { - expected_after_second_compact_user_texts.len() - } else if final_prefix.starts_with(&expected_fork_local_user_texts) { - expected_fork_local_user_texts.len() + 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"); }; @@ -522,8 +530,9 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu #[tokio::test(flavor = "multi_thread", worker_threads = 2)] /// Scenario: rolling back a turn that introduced persistent pre-turn context -/// diffs currently duplicates those context updates on the next request. -async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result<()> { +/// 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(()); @@ -602,14 +611,12 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result< let requests = request_log.requests(); assert_eq!(requests.len(), 3); - assert_eq!( - requests[1] - .message_input_texts("developer") - .iter() - .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) - .count(), - 1 - ); + 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") @@ -618,14 +625,13 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result< .count(), 1 ); - assert_eq!( - requests[2] - .message_input_texts("developer") - .iter() - .filter(|text| text.contains(ROLLED_BACK_DEV_INSTRUCTIONS)) - .count(), - 2 - ); + + 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!( @@ -633,7 +639,7 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result< .iter() .filter(|text| text.contains(PRETURN_CONTEXT_DIFF_CWD)) .count(), - 2 + 1 ); assert_eq!( after_rollback_user_texts.last().map(String::as_str), @@ -641,9 +647,9 @@ async fn snapshot_rollback_followup_turn_duplicates_context_updates() -> Result< ); insta::assert_snapshot!( - "rollback_followup_turn_duplicates_context_updates", + "rollback_followup_turn_trims_context_updates", context_snapshot::format_labeled_requests_snapshot( - "rollback currently duplicates pre-turn override context updates on the follow-up request", + "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]), @@ -749,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/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_trims_context_updates.snap similarity index 57% rename from codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap rename to codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_trims_context_updates.snap index 676421c373..925a810a43 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_duplicates_context_updates.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_resume_fork__rollback_followup_turn_trims_context_updates.snap @@ -1,8 +1,8 @@ --- source: core/tests/suite/compact_resume_fork.rs -expression: "context_snapshot::format_labeled_requests_snapshot(\"rollback currently duplicates pre-turn override context updates on 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 }),)" +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 currently duplicates pre-turn override context updates on the follow-up request +Scenario: rollback trims pre-turn override context updates before the follow-up request ## rolled-back turn request 00:message/developer: @@ -20,6 +20,4 @@ Scenario: rollback currently duplicates pre-turn override context updates on the 03:message/assistant:turn 1 assistant 04:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS 05:message/user: -06:message/developer:ROLLED_BACK_DEV_INSTRUCTIONS -07:message/user: -08:message/user:follow-up 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/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 73fc60c39c..be9cf5d750 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -29,6 +29,7 @@ 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 } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 29ae7bf256..c4f106b464 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -64,9 +64,9 @@ 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::config_types::SandboxMode; diff --git a/codex-rs/utils/git/BUILD.bazel b/codex-rs/git-utils/BUILD.bazel similarity index 50% rename from codex-rs/utils/git/BUILD.bazel rename to codex-rs/git-utils/BUILD.bazel index 332cdc8d67..346fd3f8f4 100644 --- a/codex-rs/utils/git/BUILD.bazel +++ b/codex-rs/git-utils/BUILD.bazel @@ -1,6 +1,6 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( - name = "git", - crate_name = "codex_git", + 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 90% rename from codex-rs/core/src/git_info.rs rename to codex-rs/git-utils/src/info.rs index f56e801371..b641a5692b 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -4,17 +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_exec_server::ExecutorFileSystem; -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. @@ -40,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, @@ -79,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 @@ -129,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; @@ -140,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)) } } @@ -618,7 +631,11 @@ pub async 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; @@ -629,77 +646,6 @@ pub async fn resolve_root_git_project_for_trust(cwd: &Path) -> Option { Some(canonicalize_or_raw(main_repo_root.to_path_buf())) } -pub async fn resolve_root_git_project_for_trust_with_fs( - file_system: &dyn ExecutorFileSystem, - cwd: &Path, -) -> Option { - let mut repo_root = match file_system - .get_metadata(&AbsolutePathBuf::try_from(cwd).ok()?) - .await - { - Ok(metadata) if metadata.is_directory => cwd.to_path_buf(), - _ => cwd.parent()?.to_path_buf(), - }; - - let (dot_git, dot_git_is_directory) = loop { - let dot_git = repo_root.join(".git"); - let metadata = match file_system - .get_metadata(&AbsolutePathBuf::try_from(dot_git.as_path()).ok()?) - .await - { - Ok(metadata) => metadata, - Err(_) => { - if !repo_root.pop() { - return None; - } - continue; - } - }; - - if metadata.is_directory { - break (dot_git, true); - } - if metadata.is_file { - break (dot_git, false); - } - return None; - }; - - if dot_git_is_directory { - return Some( - AbsolutePathBuf::try_from(repo_root.as_path()) - .map(AbsolutePathBuf::into_path_buf) - .unwrap_or(repo_root), - ); - } - - let git_dir_s = file_system - .read_to_string(&AbsolutePathBuf::try_from(dot_git.as_path()).ok()?) - .await - .ok()?; - let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim(); - if git_dir_rel.is_empty() { - return None; - } - - let git_dir_path = resolve_path(&repo_root, &PathBuf::from(git_dir_rel)); - let git_dir_path = AbsolutePathBuf::try_from(git_dir_path.as_path()) - .map(AbsolutePathBuf::into_path_buf) - .unwrap_or(git_dir_path); - let worktrees_dir = git_dir_path.parent()?; - if worktrees_dir.file_name() != Some(OsStr::new("worktrees")) { - return None; - } - - let common_dir = worktrees_dir.parent()?; - let main_repo_root = common_dir.parent()?; - Some( - AbsolutePathBuf::try_from(main_repo_root) - .map(AbsolutePathBuf::into_path_buf) - .unwrap_or_else(|_| main_repo_root.to_path_buf()), - ) -} - fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { let mut dir = base_dir.to_path_buf(); @@ -762,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/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/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 11efa9d376..eaaf791bb8 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -13,7 +13,7 @@ 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 } icu_decimal = { workspace = true } 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 b5c96f4d05..c40514e3db 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -49,6 +49,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; @@ -518,6 +519,7 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + pub trigger_turn: bool, } impl InterAgentCommunication { @@ -526,12 +528,14 @@ impl InterAgentCommunication { recipient: AgentPath, other_recipients: Vec, content: String, + trigger_turn: bool, ) -> Self { Self { author, recipient, other_recipients, content, + trigger_turn, } } @@ -548,7 +552,7 @@ impl InterAgentCommunication { Self::from_message_content(content).is_some() } - fn from_message_content(content: &[ContentItem]) -> Option { + pub fn from_message_content(content: &[ContentItem]) -> Option { match content { [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { serde_json::from_str(text).ok() @@ -2636,7 +2640,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/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/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/state/Cargo.toml b/codex-rs/state/Cargo.toml index bb80f60e32..a3b43f81d7 100644 --- a/codex-rs/state/Cargo.toml +++ b/codex-rs/state/Cargo.toml @@ -22,6 +22,7 @@ tracing-subscriber = { workspace = true } uuid = { workspace = true } [dev-dependencies] +codex-git-utils = { workspace = true } pretty_assertions = { workspace = true } [lints] diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 833938800f..8d35d393a8 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -61,7 +61,7 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi metadata.cwd = meta_line.meta.cwd.clone(); } if let Some(git) = meta_line.git.as_ref() { - metadata.git_sha = git.commit_hash.clone(); + metadata.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone()); metadata.git_branch = git.branch.clone(); metadata.git_origin_url = git.repository_url.clone(); } diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 0972f9d1fd..e3312c87d6 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1089,7 +1089,7 @@ mod tests { memory_mode: None, }, git: 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()), }), diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 8013b1325e..01a9c27a7a 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ codex-core = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } +codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bc3ff38599..7e5831ab5b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3787,7 +3787,7 @@ impl ChatComposer { #[cfg(not(target_os = "linux"))] fn schedule_space_hold_timer(flag: Arc, frame: Option) { - const HOLD_DELAY_MILLIS: u64 = 500; + const HOLD_DELAY_MILLIS: u64 = 1_000; if let Ok(handle) = Handle::try_current() { let flag_clone = flag; let frame_clone = frame; @@ -3875,7 +3875,7 @@ impl ChatComposer { } } - /// Called when the 500ms space hold timeout elapses. + /// Called when the 1s space hold timeout elapses. /// /// On terminals without key-release reporting, this only transitions into voice capture if we /// observed repeated Space events while pending; otherwise the keypress is treated as a typed @@ -5500,6 +5500,56 @@ mod tests { ); } + #[test] + fn mention_items_hide_plugin_owned_app_duplicates_when_provenance_matches() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "google-calendar@debug".to_string(), + display_name: "Google Calendar".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["google-calendar".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "google_calendar".to_string(), + )], + }])); + composer.set_connector_mentions(Some(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "google_calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Look up events and availability".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-calendar".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: vec!["Google Calendar".to_string()], + }], + })); + + let mentions = composer.mention_items(); + assert_eq!(mentions.len(), 1); + assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); + assert_eq!( + mentions[0].path, + Some("plugin://google-calendar@debug".to_string()) + ); + } + #[test] fn plugin_mention_popup_snapshot() { snapshot_composer_state("plugin_mention_popup", false, |composer| { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index e3b3287131..10e641655b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -371,11 +371,14 @@ impl ListSelectionView { "" }; let name_with_marker = format!("{name}{marker}"); + let is_disabled = item.is_disabled || item.disabled_reason.is_some(); let n = visible_idx + 1; let wrap_prefix = if self.is_searchable { // The number keys don't work when search is enabled (since we let the // numbers be used for the search query). format!("{prefix} ") + } else if is_disabled { + format!("{prefix} {}", " ".repeat(n.to_string().len() + 2)) } else { format!("{prefix} {n}. ") }; @@ -388,7 +391,6 @@ impl ListSelectionView { .flatten() .or_else(|| item.description.clone()); let wrap_indent = description.is_none().then_some(wrap_prefix_width); - let is_disabled = item.is_disabled || item.disabled_reason.is_some(); GenericDisplayRow { name: name_with_marker, name_prefix_spans, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 84dfc62fcc..4c3c30e81c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -69,9 +69,6 @@ use codex_core::config::types::Notifications; use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::find_thread_name_by_id; -use codex_core::git_info::current_branch_name; -use codex_core::git_info::get_git_repo_root; -use codex_core::git_info::local_git_branches; use codex_core::mcp::McpManager; use codex_core::models_manager::manager::ModelsManager; use codex_core::plugins::PluginsManager; @@ -81,6 +78,12 @@ use codex_core::skills::model::SkillMetadata; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_features::FEATURES; use codex_features::Feature; +#[cfg(test)] +use codex_git_utils::CommitLogEntry; +use codex_git_utils::current_branch_name; +use codex_git_utils::get_git_repo_root; +use codex_git_utils::local_git_branches; +use codex_git_utils::recent_commits; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -8946,6 +8949,10 @@ impl ChatWidget { fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { if self.connectors_enabled() { + let plugin_provenance = McpManager::new(Arc::new(PluginsManager::new( + self.config.codex_home.clone(), + ))) + .tool_plugin_provenance(&self.config); let mut connectors_by_id: HashMap = HashMap::new(); for tool in ev.tools.values() { let Some(meta) = tool.meta.as_ref().and_then(serde_json::Value::as_object) else { @@ -8988,7 +8995,9 @@ impl ChatWidget { install_url: None, is_accessible: true, is_enabled: true, - plugin_display_names: Vec::new(), + plugin_display_names: plugin_provenance + .plugin_display_names_for_connector_id(connector_id) + .to_vec(), } }); } @@ -9240,7 +9249,7 @@ impl ChatWidget { } pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = codex_core::git_info::recent_commits(cwd, /*limit*/ 100).await; + let commits = recent_commits(cwd, /*limit*/ 100).await; let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { @@ -9642,7 +9651,7 @@ async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Vec, + entries: Vec, ) { let mut items: Vec = Vec::with_capacity(entries.len()); for entry in entries { diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index a04cf249c5..079f70e226 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -355,9 +355,7 @@ impl ChatWidget { }; items.push(SelectionItem { name: install_label.to_string(), - description: Some( - "Open the same ChatGPT app management link used by /apps.".to_string(), - ), + description: Some("Open the ChatGPT app management page".to_string()), selected_description: Some("Open the app page in your browser.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenUrlInBrowser { @@ -368,7 +366,7 @@ impl ChatWidget { }); } else { items.push(SelectionItem { - name: "ChatGPT link unavailable".to_string(), + name: "ChatGPT apps link unavailable".to_string(), description: Some("This app did not provide an install/manage URL.".to_string()), is_disabled: true, ..Default::default() @@ -480,7 +478,7 @@ impl ChatWidget { header.push(Line::from("Plugins".bold())); header.push(Line::from("Loading available plugins...".dim())); header.push(Line::from( - "This first pass shows the ChatGPT marketplace only.".dim(), + "Available marketplaces will appear here when ready.".dim(), )); SelectionViewParams { @@ -508,9 +506,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), - description: Some( - "This updates when the plugin detail request finishes.".to_string(), - ), + description: Some("This updates when plugin details load.".to_string()), is_disabled: true, ..Default::default() }], @@ -556,7 +552,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Uninstalling plugin...".to_string(), - description: Some("This updates when plugin removal completes.".to_string()), + description: Some("This updates when the plugin removal completes.".to_string()), is_disabled: true, ..Default::default() }], @@ -673,14 +669,21 @@ impl ChatWidget { .then_with(|| left.1.name.cmp(&right.1.name)) .then_with(|| left.1.id.cmp(&right.1.id)) }); + let status_label_width = plugin_entries + .iter() + .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) + .max() + .unwrap_or(0); let mut items: Vec = Vec::new(); for (marketplace, plugin, display_name) in plugin_entries { let marketplace_label = marketplace_display_name(marketplace); let status_label = plugin_status_label(plugin); - let description = plugin_brief_description(plugin, &marketplace_label); + let description = + plugin_brief_description(plugin, &marketplace_label, status_label_width); + let selected_status_label = format!("{status_label: String { .unwrap_or_else(|| plugin.name.clone()) } -fn plugin_brief_description(plugin: &PluginSummary, marketplace_label: &str) -> String { +fn plugin_brief_description( + plugin: &PluginSummary, + marketplace_label: &str, + status_label_width: usize, +) -> String { let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), None => format!("{status_label} · {marketplace_label}"), @@ -894,7 +902,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { } else { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::Available => "Available", PluginInstallPolicy::InstalledByDefault => "Available by default", } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap index e75302e5c2..94d7aab6da 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apps_popup_loading_state.snap @@ -5,4 +5,4 @@ expression: before Apps Loading installed and available apps... -› 1. Loading apps... This updates when the full list is ready. +› Loading apps... This updates when the full list is ready. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index d2461ec72f..0388e3d161 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -4,13 +4,13 @@ expression: popup --- Plugins Figma · ChatGPT Marketplace - Can be installed + Available Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. - 3. Skills design-review, extract-copy - 4. Apps Figma, Slack - 5. MCP Servers figma-mcp, docs-mcp + Skills design-review, extract-copy + Apps Figma, Slack + MCP Servers figma-mcp, docs-mcp Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index c23daeea1b..1eca544063 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -9,12 +9,9 @@ expression: popup Using cached marketplace data: remote sync timed out Type to search plugins -› Alpha Sync · ChatGPT Marketplace Installed · Disabled. Press Enter to view plugin details. - Bravo Search · ChatGPT Marketplace Can be installed · ChatGPT Marketplace · Search docs and - tickets. - Hidden Repo Plugin · Repo Marketplace Can be installed · Repo Marketplace · Should not be shown - in /plugins. - Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by - default. +› Alpha Sync Installed · Disabled Press Enter to view plugin details. + Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. + Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. + Starter Available by default · ChatGPT Marketplace · Included by default. Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap index eddb869168..42eff7f81f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - This first pass shows the ChatGPT marketplace only. + Available marketplaces will appear here when ready. -› 1. Loading plugins... This updates when the marketplace list is ready. +› Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index 65a4c6b034..a9239b3797 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -8,6 +8,6 @@ expression: popup Installed 0 of 3 available plugins. sla -› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. +› Slack Available Press Enter to view plugin details. Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap index 3095e6da97..00392bc9d1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap @@ -7,7 +7,7 @@ expression: popup 1. System default Use your operating system default device. -› 2. Unavailable: Studio Mic (current) (disabled) Configured device is not +› Unavailable: Studio Mic (current) (disabled) Configured device is not currently available. (disabled: Reconnect the device or choose another diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 78bb4cf22d..fad59d5cbc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -51,6 +51,7 @@ use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; +use codex_git_utils::CommitLogEntry; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -6725,12 +6726,12 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { // Show commit picker with synthetic entries. let entries = vec![ - codex_core::git_info::CommitLogEntry { + CommitLogEntry { sha: "1111111deadbeef".to_string(), timestamp: 0, subject: "Add new feature X".to_string(), }, - codex_core::git_info::CommitLogEntry { + CommitLogEntry { sha: "2222222cafebabe".to_string(), timestamp: 0, subject: "Fix bug Y".to_string(), @@ -7162,6 +7163,7 @@ fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketpla fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { PluginListResponse { marketplaces, + marketplace_load_errors: Vec::new(), remote_sync_error: None, featured_plugin_ids: Vec::new(), } @@ -7411,7 +7413,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { "expected refresh to rebuild the popup from the new first row, got:\n{after}" ); assert!( - after.contains("Slack · ChatGPT Marketplace"), + after.contains("Slack"), "expected refreshed popup to include the updated plugin list, got:\n{after}" ); } @@ -7447,7 +7449,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected initial installed count before refresh, got:\n{before}" ); assert!( - before.contains("Can be installed"), + before.contains("Available"), "expected pre-install popup copy before refresh, got:\n{before}" ); @@ -7480,7 +7482,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected /plugins to refresh installed counts after install, got:\n{after}" ); assert!( - after.contains("Installed. Press Enter to view plugin details."), + after.contains("Installed Press Enter to view plugin details."), "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" ); } @@ -7528,8 +7530,7 @@ async fn plugins_popup_search_filters_visible_rows_snapshot() { let popup = render_bottom_popup(&chat, 100); assert_snapshot!("plugins_popup_search_filtered", popup); assert!( - !popup.contains("Calendar · ChatGPT Marketplace") - && !popup.contains("Drive · ChatGPT Marketplace"), + !popup.contains("Calendar") && !popup.contains("Drive"), "expected search to leave only matching rows visible, got:\n{popup}" ); } @@ -7581,8 +7582,7 @@ async fn plugins_popup_search_no_matches_and_backspace_restores_results() { let restored = render_bottom_popup(&chat, 100); assert!( - restored.contains("Calendar · ChatGPT Marketplace") - && restored.contains("Slack · ChatGPT Marketplace"), + restored.contains("Calendar") && restored.contains("Slack"), "expected clearing the query to restore the plugin rows, got:\n{restored}" ); assert!( diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 684d76b1e0..ede36abf96 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -92,7 +92,7 @@ use crate::terminal_palette::default_bg; use crate::terminal_palette::indexed_color; use crate::terminal_palette::rgb_color; use crate::terminal_palette::stdout_color_level; -use codex_core::git_info::get_git_repo_root; +use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::FileChange; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5416200691..039148a718 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -609,6 +609,7 @@ async fn run_ratatui_app( terminal.clear()?; let mut tui = Tui::new(terminal); + let mut terminal_restore_guard = TerminalRestoreGuard::new(); #[cfg(not(debug_assertions))] { @@ -619,7 +620,7 @@ async fn run_ratatui_app( match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { UpdatePromptOutcome::Continue => {} UpdatePromptOutcome::RunUpdate(action) => { - crate::tui::restore()?; + terminal_restore_guard.restore()?; return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, @@ -660,7 +661,7 @@ async fn run_ratatui_app( ) .await?; if onboarding_result.should_exit { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { @@ -701,7 +702,7 @@ async fn run_ratatui_app( let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); Ok(AppExitInfo { @@ -773,7 +774,7 @@ async fn run_ratatui_app( error!( "Error reading session metadata from latest rollout: {rollout_path}" ); - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { @@ -795,7 +796,7 @@ async fn run_ratatui_app( } else if cli.fork_picker { match resume_picker::run_fork_picker(&mut tui, &config, cli.fork_show_all).await? { resume_picker::SessionSelection::Exit => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), @@ -867,7 +868,7 @@ async fn run_ratatui_app( error!( "Error reading session metadata from latest rollout: {rollout_path}" ); - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { @@ -887,7 +888,7 @@ async fn run_ratatui_app( } else if cli.resume_picker { match resume_picker::run_resume_picker(&mut tui, &config, cli.resume_show_all).await? { resume_picker::SessionSelection::Exit => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), @@ -929,7 +930,7 @@ async fn run_ratatui_app( { ResolveCwdOutcome::Continue(cwd) => cwd, ResolveCwdOutcome::Exit => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), @@ -1003,7 +1004,7 @@ async fn run_ratatui_app( ) .await; - restore(); + terminal_restore_guard.restore_silently(); // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead @@ -1128,6 +1129,38 @@ fn restore() { } } +struct TerminalRestoreGuard { + active: bool, +} + +impl TerminalRestoreGuard { + fn new() -> Self { + Self { active: true } + } + + #[cfg_attr(debug_assertions, allow(dead_code))] + fn restore(&mut self) -> color_eyre::Result<()> { + if self.active { + crate::tui::restore()?; + self.active = false; + } + Ok(()) + } + + fn restore_silently(&mut self) { + if self.active { + restore(); + self.active = false; + } + } +} + +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + self.restore_silently(); + } +} + /// Determine whether to use the terminal's alternate screen buffer. /// /// The alternate screen buffer provides a cleaner fullscreen experience without polluting diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 3c3a9a13fd..eab9da178e 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,8 +1,8 @@ use codex_core::AuthManager; use codex_core::config::Config; -use codex_core::git_info::resolve_root_git_project_for_trust; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_git_utils::resolve_root_git_project_for_trust; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; use crossterm::event::KeyCode; diff --git a/codex-rs/tui_app_server/Cargo.toml b/codex-rs/tui_app_server/Cargo.toml index 8866042051..b05b498ac8 100644 --- a/codex-rs/tui_app_server/Cargo.toml +++ b/codex-rs/tui_app_server/Cargo.toml @@ -44,6 +44,7 @@ codex-core = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } +codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index 0da85bc617..ecdea588ee 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -80,6 +80,7 @@ use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use tracing::debug; use crate::bottom_pane::FeedbackAudience; use crate::status::StatusAccountDisplay; @@ -178,16 +179,6 @@ impl AppServerSession { }) .await .wrap_err("model/list failed during TUI bootstrap")?; - let rate_limit_request_id = self.next_request_id(); - let rate_limits: GetAccountRateLimitsResponse = self - .client - .request_typed(ClientRequest::GetAccountRateLimits { - request_id: rate_limit_request_id, - params: None, - }) - .await - .wrap_err("account/rateLimits/read failed during TUI bootstrap")?; - let available_models = models .data .into_iter() @@ -252,6 +243,25 @@ impl AppServerSession { false, ), }; + let rate_limit_snapshots = if account.requires_openai_auth && has_chatgpt_account { + let rate_limit_request_id = self.next_request_id(); + match self + .client + .request_typed(ClientRequest::GetAccountRateLimits { + request_id: rate_limit_request_id, + params: None, + }) + .await + { + Ok(rate_limits) => app_server_rate_limit_snapshots_to_core(rate_limits), + Err(error) => { + debug!(error = ?error, "failed to fetch rate limits during TUI bootstrap"); + Vec::new() + } + } + } else { + Vec::new() + }; Ok(AppServerBootstrap { account_auth_mode, @@ -263,7 +273,7 @@ impl AppServerSession { feedback_audience, has_chatgpt_account, available_models, - rate_limit_snapshots: app_server_rate_limit_snapshots_to_core(rate_limits), + rate_limit_snapshots, }) } diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 98452410bb..d7dc0ae096 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3802,7 +3802,7 @@ impl ChatComposer { #[cfg(not(target_os = "linux"))] fn schedule_space_hold_timer(flag: Arc, frame: Option) { - const HOLD_DELAY_MILLIS: u64 = 500; + const HOLD_DELAY_MILLIS: u64 = 1_000; if let Ok(handle) = Handle::try_current() { let flag_clone = flag; let frame_clone = frame; @@ -3890,7 +3890,7 @@ impl ChatComposer { } } - /// Called when the 500ms space hold timeout elapses. + /// Called when the 1s space hold timeout elapses. /// /// On terminals without key-release reporting, this only transitions into voice capture if we /// observed repeated Space events while pending; otherwise the keypress is treated as a typed diff --git a/codex-rs/tui_app_server/src/bottom_pane/list_selection_view.rs b/codex-rs/tui_app_server/src/bottom_pane/list_selection_view.rs index e3b3287131..10e641655b 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/list_selection_view.rs @@ -371,11 +371,14 @@ impl ListSelectionView { "" }; let name_with_marker = format!("{name}{marker}"); + let is_disabled = item.is_disabled || item.disabled_reason.is_some(); let n = visible_idx + 1; let wrap_prefix = if self.is_searchable { // The number keys don't work when search is enabled (since we let the // numbers be used for the search query). format!("{prefix} ") + } else if is_disabled { + format!("{prefix} {}", " ".repeat(n.to_string().len() + 2)) } else { format!("{prefix} {n}. ") }; @@ -388,7 +391,6 @@ impl ListSelectionView { .flatten() .or_else(|| item.description.clone()); let wrap_indent = description.is_none().then_some(wrap_prefix_width); - let is_disabled = item.is_disabled || item.disabled_reason.is_some(); GenericDisplayRow { name: name_with_marker, name_prefix_spans, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 95e5f27a77..b444dc13bc 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -89,9 +89,6 @@ use codex_core::config::types::Notifications; use codex_core::config::types::WindowsSandboxModeToml; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::find_thread_name_by_id; -use codex_core::git_info::current_branch_name; -use codex_core::git_info::get_git_repo_root; -use codex_core::git_info::local_git_branches; use codex_core::plugins::PluginsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::skills::model::SkillMetadata; @@ -99,6 +96,12 @@ use codex_core::skills::model::SkillMetadata; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_features::FEATURES; use codex_features::Feature; +#[cfg(test)] +use codex_git_utils::CommitLogEntry; +use codex_git_utils::current_branch_name; +use codex_git_utils::get_git_repo_root; +use codex_git_utils::local_git_branches; +use codex_git_utils::recent_commits; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -10414,7 +10417,7 @@ impl ChatWidget { } pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) { - let commits = codex_core::git_info::recent_commits(cwd, /*limit*/ 100).await; + let commits = recent_commits(cwd, /*limit*/ 100).await; let mut items: Vec = Vec::with_capacity(commits.len()); for entry in commits { @@ -10796,7 +10799,7 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st #[cfg(test)] pub(crate) fn show_review_commit_picker_with_entries( chat: &mut ChatWidget, - entries: Vec, + entries: Vec, ) { let mut items: Vec = Vec::with_capacity(entries.len()); for entry in entries { diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index a04cf249c5..079f70e226 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -355,9 +355,7 @@ impl ChatWidget { }; items.push(SelectionItem { name: install_label.to_string(), - description: Some( - "Open the same ChatGPT app management link used by /apps.".to_string(), - ), + description: Some("Open the ChatGPT app management page".to_string()), selected_description: Some("Open the app page in your browser.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenUrlInBrowser { @@ -368,7 +366,7 @@ impl ChatWidget { }); } else { items.push(SelectionItem { - name: "ChatGPT link unavailable".to_string(), + name: "ChatGPT apps link unavailable".to_string(), description: Some("This app did not provide an install/manage URL.".to_string()), is_disabled: true, ..Default::default() @@ -480,7 +478,7 @@ impl ChatWidget { header.push(Line::from("Plugins".bold())); header.push(Line::from("Loading available plugins...".dim())); header.push(Line::from( - "This first pass shows the ChatGPT marketplace only.".dim(), + "Available marketplaces will appear here when ready.".dim(), )); SelectionViewParams { @@ -508,9 +506,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), - description: Some( - "This updates when the plugin detail request finishes.".to_string(), - ), + description: Some("This updates when plugin details load.".to_string()), is_disabled: true, ..Default::default() }], @@ -556,7 +552,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Uninstalling plugin...".to_string(), - description: Some("This updates when plugin removal completes.".to_string()), + description: Some("This updates when the plugin removal completes.".to_string()), is_disabled: true, ..Default::default() }], @@ -673,14 +669,21 @@ impl ChatWidget { .then_with(|| left.1.name.cmp(&right.1.name)) .then_with(|| left.1.id.cmp(&right.1.id)) }); + let status_label_width = plugin_entries + .iter() + .map(|(_, plugin, _)| plugin_status_label(plugin).chars().count()) + .max() + .unwrap_or(0); let mut items: Vec = Vec::new(); for (marketplace, plugin, display_name) in plugin_entries { let marketplace_label = marketplace_display_name(marketplace); let status_label = plugin_status_label(plugin); - let description = plugin_brief_description(plugin, &marketplace_label); + let description = + plugin_brief_description(plugin, &marketplace_label, status_label_width); + let selected_status_label = format!("{status_label: String { .unwrap_or_else(|| plugin.name.clone()) } -fn plugin_brief_description(plugin: &PluginSummary, marketplace_label: &str) -> String { +fn plugin_brief_description( + plugin: &PluginSummary, + marketplace_label: &str, + status_label_width: usize, +) -> String { let status_label = plugin_status_label(plugin); + let status_label = format!("{status_label: format!("{status_label} · {marketplace_label} · {description}"), None => format!("{status_label} · {marketplace_label}"), @@ -894,7 +902,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { } else { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::Available => "Available", PluginInstallPolicy::InstalledByDefault => "Available by default", } } diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__apps_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__apps_popup_loading_state.snap index 9bc30a7220..b7a3be8884 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__apps_popup_loading_state.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__apps_popup_loading_state.snap @@ -5,4 +5,4 @@ expression: before Apps Loading installed and available apps... -› 1. Loading apps... This updates when the full list is ready. +› Loading apps... This updates when the full list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap index fe88135b86..399f160799 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap @@ -4,13 +4,13 @@ expression: popup --- Plugins Figma · ChatGPT Marketplace - Can be installed + Available Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. - 3. Skills design-review, extract-copy - 4. Apps Figma, Slack - 5. MCP Servers figma-mcp, docs-mcp + Skills design-review, extract-copy + Apps Figma, Slack + MCP Servers figma-mcp, docs-mcp Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap index f553f90747..70e6b9d40e 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -8,12 +8,9 @@ expression: popup Using cached marketplace data: remote sync timed out Type to search plugins -› Alpha Sync · ChatGPT Marketplace Installed · Disabled. Press Enter to view plugin details. - Bravo Search · ChatGPT Marketplace Can be installed · ChatGPT Marketplace · Search docs and - tickets. - Hidden Repo Plugin · Repo Marketplace Can be installed · Repo Marketplace · Should not be shown - in /plugins. - Starter · ChatGPT Marketplace Available by default · ChatGPT Marketplace · Included by - default. +› Alpha Sync Installed · Disabled Press Enter to view plugin details. + Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. + Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. + Starter Available by default · ChatGPT Marketplace · Included by default. Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap index 741c813fb1..846c73fe47 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - This first pass shows the ChatGPT marketplace only. + Available marketplaces will appear here when ready. -› 1. Loading plugins... This updates when the marketplace list is ready. +› Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap index 849db2c28a..ab0724d344 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_search_filtered.snap @@ -7,6 +7,6 @@ expression: popup Installed 0 of 3 available plugins. sla -› Slack · ChatGPT Marketplace Can be installed. Press Enter to view plugin details. +› Slack Available Press Enter to view plugin details. Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__realtime_microphone_picker_popup.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__realtime_microphone_picker_popup.snap index 8eadefc63a..93f64ad316 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__realtime_microphone_picker_popup.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__realtime_microphone_picker_popup.snap @@ -7,7 +7,7 @@ expression: popup 1. System default Use your operating system default device. -› 2. Unavailable: Studio Mic (current) (disabled) Configured device is not +› Unavailable: Studio Mic (current) (disabled) Configured device is not currently available. (disabled: Reconnect the device or choose another diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 3e73b4455c..a684806128 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -74,6 +74,7 @@ use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core::skills::model::SkillMetadata; use codex_features::FEATURES; use codex_features::Feature; +use codex_git_utils::CommitLogEntry; use codex_otel::RuntimeMetricsSummary; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -7322,12 +7323,12 @@ async fn review_commit_picker_shows_subjects_without_timestamps() { // Show commit picker with synthetic entries. let entries = vec![ - codex_core::git_info::CommitLogEntry { + CommitLogEntry { sha: "1111111deadbeef".to_string(), timestamp: 0, subject: "Add new feature X".to_string(), }, - codex_core::git_info::CommitLogEntry { + CommitLogEntry { sha: "2222222cafebabe".to_string(), timestamp: 0, subject: "Fix bug Y".to_string(), @@ -7759,6 +7760,7 @@ fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketpla fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { PluginListResponse { marketplaces, + marketplace_load_errors: Vec::new(), remote_sync_error: None, featured_plugin_ids: Vec::new(), } @@ -8008,7 +8010,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { "expected refresh to rebuild the popup from the new first row, got:\n{after}" ); assert!( - after.contains("Slack · ChatGPT Marketplace"), + after.contains("Slack"), "expected refreshed popup to include the updated plugin list, got:\n{after}" ); } @@ -8044,7 +8046,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected initial installed count before refresh, got:\n{before}" ); assert!( - before.contains("Can be installed"), + before.contains("Available"), "expected pre-install popup copy before refresh, got:\n{before}" ); @@ -8077,7 +8079,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { "expected /plugins to refresh installed counts after install, got:\n{after}" ); assert!( - after.contains("Installed. Press Enter to view plugin details."), + after.contains("Installed Press Enter to view plugin details."), "expected refreshed selected row copy to reflect the installed plugin state, got:\n{after}" ); } @@ -8125,8 +8127,7 @@ async fn plugins_popup_search_filters_visible_rows_snapshot() { let popup = render_bottom_popup(&chat, 100); assert_snapshot!("plugins_popup_search_filtered", popup); assert!( - !popup.contains("Calendar · ChatGPT Marketplace") - && !popup.contains("Drive · ChatGPT Marketplace"), + !popup.contains("Calendar") && !popup.contains("Drive"), "expected search to leave only matching rows visible, got:\n{popup}" ); } @@ -8178,8 +8179,7 @@ async fn plugins_popup_search_no_matches_and_backspace_restores_results() { let restored = render_bottom_popup(&chat, 100); assert!( - restored.contains("Calendar · ChatGPT Marketplace") - && restored.contains("Slack · ChatGPT Marketplace"), + restored.contains("Calendar") && restored.contains("Slack"), "expected clearing the query to restore the plugin rows, got:\n{restored}" ); assert!( diff --git a/codex-rs/tui_app_server/src/diff_render.rs b/codex-rs/tui_app_server/src/diff_render.rs index 684d76b1e0..ede36abf96 100644 --- a/codex-rs/tui_app_server/src/diff_render.rs +++ b/codex-rs/tui_app_server/src/diff_render.rs @@ -92,7 +92,7 @@ use crate::terminal_palette::default_bg; use crate::terminal_palette::indexed_color; use crate::terminal_palette::rgb_color; use crate::terminal_palette::stdout_color_level; -use codex_core::git_info::get_git_repo_root; +use codex_git_utils::get_git_repo_root; use codex_protocol::protocol::FileChange; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 17e309d5fb..790a7c6f55 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -938,6 +938,7 @@ async fn run_ratatui_app( terminal.clear()?; let mut tui = Tui::new(terminal); + let mut terminal_restore_guard = TerminalRestoreGuard::new(); #[cfg(not(debug_assertions))] { @@ -948,7 +949,7 @@ async fn run_ratatui_app( match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { UpdatePromptOutcome::Continue => {} UpdatePromptOutcome::RunUpdate(action) => { - crate::tui::restore()?; + terminal_restore_guard.restore()?; return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), thread_id: None, @@ -1016,7 +1017,7 @@ async fn run_ratatui_app( ) .await?; if onboarding_result.should_exit { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); return Ok(AppExitInfo { @@ -1062,7 +1063,7 @@ async fn run_ratatui_app( let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); let _ = tui.terminal.clear(); Ok(AppExitInfo { @@ -1137,7 +1138,7 @@ async fn run_ratatui_app( .await? { resume_picker::SessionSelection::Exit => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), @@ -1189,7 +1190,7 @@ async fn run_ratatui_app( .await? { resume_picker::SessionSelection::Exit => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), @@ -1235,7 +1236,7 @@ async fn run_ratatui_app( { ResolveCwdOutcome::Continue(cwd) => cwd, ResolveCwdOutcome::Exit => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Ok(AppExitInfo { token_usage: codex_protocol::protocol::TokenUsage::default(), @@ -1303,7 +1304,7 @@ async fn run_ratatui_app( { Ok(app_server) => app_server, Err(err) => { - restore(); + terminal_restore_guard.restore_silently(); session_log::log_session_end(); return Err(err); } @@ -1326,7 +1327,7 @@ async fn run_ratatui_app( ) .await; - restore(); + terminal_restore_guard.restore_silently(); // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead @@ -1468,6 +1469,38 @@ fn restore() { } } +struct TerminalRestoreGuard { + active: bool, +} + +impl TerminalRestoreGuard { + fn new() -> Self { + Self { active: true } + } + + #[cfg_attr(debug_assertions, allow(dead_code))] + fn restore(&mut self) -> color_eyre::Result<()> { + if self.active { + crate::tui::restore()?; + self.active = false; + } + Ok(()) + } + + fn restore_silently(&mut self) { + if self.active { + restore(); + self.active = false; + } + } +} + +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + self.restore_silently(); + } +} + /// Determine whether to use the terminal's alternate screen buffer. /// /// The alternate screen buffer provides a cleaner fullscreen experience without polluting diff --git a/codex-rs/tui_app_server/src/onboarding/auth.rs b/codex-rs/tui_app_server/src/onboarding/auth.rs index 612fdbe2ac..37f8819b62 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth.rs @@ -163,37 +163,7 @@ impl KeyboardHandler for AuthModeWidget { } KeyCode::Esc => { tracing::info!("Esc pressed"); - let mut sign_in_state = self.sign_in_state.write().unwrap(); - match &*sign_in_state { - SignInState::ChatGptContinueInBrowser(state) => { - let request_handle = self.app_server_request_handle.clone(); - let login_id = state.login_id.clone(); - tokio::spawn(async move { - let _ = request_handle - .request_typed::( - ClientRequest::CancelLoginAccount { - request_id: onboarding_request_id(), - params: CancelLoginAccountParams { login_id }, - }, - ) - .await; - }); - *sign_in_state = SignInState::PickMode; - drop(sign_in_state); - self.set_error(/*message*/ None); - self.request_frame.schedule_frame(); - } - SignInState::ChatGptDeviceCode(state) => { - if let Some(cancel) = &state.cancel { - cancel.notify_one(); - } - *sign_in_state = SignInState::PickMode; - drop(sign_in_state); - self.set_error(/*message*/ None); - self.request_frame.schedule_frame(); - } - _ => {} - } + self.cancel_active_attempt(); } _ => {} } @@ -221,6 +191,36 @@ pub(crate) struct AuthModeWidget { } impl AuthModeWidget { + pub(crate) fn cancel_active_attempt(&self) { + let mut sign_in_state = self.sign_in_state.write().unwrap(); + match &*sign_in_state { + SignInState::ChatGptContinueInBrowser(state) => { + let request_handle = self.app_server_request_handle.clone(); + let login_id = state.login_id.clone(); + tokio::spawn(async move { + let _ = request_handle + .request_typed::( + ClientRequest::CancelLoginAccount { + request_id: onboarding_request_id(), + params: CancelLoginAccountParams { login_id }, + }, + ) + .await; + }); + } + SignInState::ChatGptDeviceCode(state) => { + if let Some(cancel) = &state.cancel { + cancel.notify_one(); + } + } + _ => return, + } + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.set_error(/*message*/ None); + self.request_frame.schedule_frame(); + } + fn set_error(&self, message: Option) { *self.error.write().unwrap() = message; } @@ -771,6 +771,7 @@ impl AuthModeWidget { .await { Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { + maybe_open_auth_url_in_browser(&request_handle, &auth_url); *error.write().unwrap() = None; *sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { @@ -880,6 +881,16 @@ impl WidgetRef for AuthModeWidget { } } +pub(super) fn maybe_open_auth_url_in_browser(request_handle: &AppServerRequestHandle, url: &str) { + if !matches!(request_handle, AppServerRequestHandle::InProcess(_)) { + return; + } + + if let Err(err) = webbrowser::open(url) { + tracing::warn!("failed to open browser for login URL: {err}"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -990,6 +1001,50 @@ mod tests { )); } + #[tokio::test] + async fn cancel_active_attempt_resets_browser_login_state() { + let (widget, _tmp) = widget_forced_chatgpt().await; + *widget.error.write().unwrap() = Some("still logging in".to_string()); + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + login_id: "login-1".to_string(), + auth_url: "https://auth.example.com".to_string(), + }); + + widget.cancel_active_attempt(); + + assert_eq!(widget.error_message(), None); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + } + + #[tokio::test] + async fn cancel_active_attempt_notifies_device_code_login() { + let (widget, _tmp) = widget_forced_chatgpt().await; + let cancel = Arc::new(Notify::new()); + *widget.error.write().unwrap() = Some("still logging in".to_string()); + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + + widget.cancel_active_attempt(); + + assert_eq!(widget.error_message(), None); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::PickMode + )); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), cancel.notified()) + .await + .is_ok() + ); + } + /// Collects all buffer cell symbols that contain the OSC 8 open sequence /// for the given URL. Returns the concatenated "inner" characters. fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String { diff --git a/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs index 33afe740b8..d0834fe166 100644 --- a/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui_app_server/src/onboarding/auth/headless_chatgpt_login.rs @@ -29,6 +29,7 @@ use super::ContinueInBrowserState; use super::ContinueWithDeviceCodeState; use super::SignInState; use super::mark_url_hyperlink; +use super::maybe_open_auth_url_in_browser; use super::onboarding_request_id; pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) { @@ -289,6 +290,7 @@ async fn fallback_to_browser_login( .await { Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { + maybe_open_auth_url_in_browser(&request_handle, &auth_url); *error.write().unwrap() = None; let _updated = set_device_code_state_for_active_attempt( &sign_in_state, diff --git a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs index 15e4956f69..56ac181147 100644 --- a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs @@ -2,9 +2,9 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ServerNotification; use codex_core::config::Config; -use codex_core::git_info::resolve_root_git_project_for_trust; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_git_utils::resolve_root_git_project_for_trust; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; use crossterm::event::KeyCode; @@ -210,6 +210,14 @@ impl OnboardingScreen { self.should_exit } + fn cancel_auth_if_active(&self) { + for step in &self.steps { + if let Step::Auth(widget) = step { + widget.cancel_active_attempt(); + } + } + } + fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> { self.steps.iter_mut().find_map(|step| match step { Step::Auth(widget) => Some(widget), @@ -274,6 +282,7 @@ impl KeyboardHandler for OnboardingScreen { }; if should_quit { if self.is_auth_in_progress() { + self.cancel_auth_if_active(); // If the user cancels the auth menu, exit the app rather than // leave the user at a prompt in an unauthed state. self.should_exit = true; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 102869a683..6939665150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,30 +75,6 @@ importers: specifier: ^3.24.6 version: 3.24.6(zod@3.25.76) - shell-tool-mcp: - devDependencies: - '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 - '@types/node': - specifier: ^20.19.18 - version: 20.19.18 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)) - prettier: - specifier: ^3.6.2 - version: 3.6.2 - ts-jest: - specifier: ^29.3.4 - version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) - tsup: - specifier: ^8.5.0 - version: 8.5.0(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.1) - typescript: - specifier: ^5.9.2 - version: 5.9.2 - packages: '@babel/code-frame@7.27.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09a61536ab..a47e4bd8a1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,7 +2,6 @@ packages: - codex-cli - codex-rs/responses-api-proxy/npm - sdk/typescript - - shell-tool-mcp ignoredBuiltDependencies: - esbuild diff --git a/shell-tool-mcp/.gitignore b/shell-tool-mcp/.gitignore deleted file mode 100644 index c6958891dd..0000000000 --- a/shell-tool-mcp/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/bin/ -/node_modules/ diff --git a/shell-tool-mcp/README.md b/shell-tool-mcp/README.md deleted file mode 100644 index deadaed431..0000000000 --- a/shell-tool-mcp/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# @openai/codex-shell-tool-mcp - -**Note: This MCP server is still experimental. When using it with Codex CLI, ensure the CLI version matches the MCP server version.** - -`@openai/codex-shell-tool-mcp` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed instance of Bash. This special instance of Bash intercepts requests to spawn new processes (specifically, [`execve(2)`](https://man7.org/linux/man-pages/man2/execve.2.html) calls). For each call, it makes a request back to the MCP server to determine whether to allow the proposed command to execute. It also has the option of _escalating_ the command to run unprivileged outside of the sandbox governing the Bash process. - -The user can use [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview) files to define how a command should be handled. The action to take is determined by the `decision` parameter of a matching rule as follows: - -- `allow`: the command will be _escalated_ and run outside the sandbox -- `prompt`: the command will be subject to human approval via an [MCP elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation) (it will run _escalated_ if approved) -- `forbidden`: the command will fail with exit code `1` and an error message will be written to `stderr` - -Commands that do not match an explicit rule in `.rules` will be allowed to run as-is, though they will still be subject to the sandbox applied to the parent Bash process. - -## Motivation - -When a software agent asks if it is safe to run a command like `ls`, without more context, it is unclear whether it will result in executing `/bin/ls`. Consider: - -- There could be another executable named `ls` that appears before `/bin/ls` on the `$PATH`. -- `ls` could be mapped to a shell alias or function. - -Because `@openai/codex-shell-tool-mcp` intercepts `execve(2)` calls directly, it _always_ knows the full path to the program being executed. In turn, this makes it possible to provide stronger guarantees on how [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview) are enforced. - -## Usage - -First, verify that you can download and run the MCP executable: - -```bash -npx -y @openai/codex-shell-tool-mcp --version -``` - -To test out the MCP with a one-off invocation of Codex CLI, it is important to _disable_ the default shell tool in addition to enabling the MCP so Codex has exactly one shell-like tool available to it: - -```bash -codex --disable shell_tool \ - --config 'mcp_servers.bash={command = "npx", args = ["-y", "@openai/codex-shell-tool-mcp"]}' -``` - -To configure this permanently so you can use the MCP while running `codex` without additional command-line flags, add the following to your `~/.codex/config.toml`: - -```toml -[features] -shell_tool = false - -[mcp_servers.shell-tool] -command = "npx" -args = ["-y", "@openai/codex-shell-tool-mcp"] -``` - -Note when the `@openai/codex-shell-tool-mcp` launcher runs, it selects the appropriate native binary to run based on the host OS/architecture. For the Bash wrapper, it inspects `/etc/os-release` on Linux or the Darwin major version on macOS to try to find the best match it has available. See [`bashSelection.ts`](https://github.com/openai/codex/blob/main/shell-tool-mcp/src/bashSelection.ts) for details. - -## MCP Client Requirements - -This MCP server is designed to be used with [Codex](https://developers.openai.com/codex/cli), as it declares the following `capability` that Codex supports when acting as an MCP client: - -```json -{ - "capabilities": { - "experimental": { - "codex/sandbox-state": { - "version": "1.0.0" - } - } - } -} -``` - -This capability means the MCP server honors requests like the following to update the sandbox policy the MCP server uses when spawning Bash: - -```json -{ - "id": "req-42", - "method": "codex/sandbox-state/update", - "params": { - "sandboxPolicy": { - "type": "workspace-write", - "writable_roots": ["/home/user/code/codex"], - "network_access": false, - "exclude_tmpdir_env_var": false, - "exclude_slash_tmp": false - } - } -} -``` - -Once the server has processed the update, it sends an empty response to acknowledge the request: - -```json -{ - "id": "req-42", - "result": {} -} -``` - -The Codex harness (used by the CLI and the VS Code extension) sends such requests to MCP servers that declare the `codex/sandbox-state` capability. - -## Package Contents - -This package currently publishes shell binaries only. It bundles: - -- A patched Bash that honors `EXEC_WRAPPER`, built for multiple glibc baselines (Ubuntu 24.04/22.04/20.04, Debian 12/11, CentOS-like 9) and macOS (15/14/13). -- A patched zsh with `EXEC_WRAPPER` support for the same supported target triples. - -It does not currently include the Rust MCP server binaries. - -See [the README in the Codex repo](https://github.com/openai/codex/blob/main/codex-rs/shell-escalation/README.md) for details. diff --git a/shell-tool-mcp/jest.config.cjs b/shell-tool-mcp/jest.config.cjs deleted file mode 100644 index 8ade9496e7..0000000000 --- a/shell-tool-mcp/jest.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - preset: "ts-jest", - testEnvironment: "node", - roots: ["/tests"], -}; diff --git a/shell-tool-mcp/package.json b/shell-tool-mcp/package.json deleted file mode 100644 index 28918d7586..0000000000 --- a/shell-tool-mcp/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@openai/codex-shell-tool-mcp", - "version": "0.0.0-dev", - "description": "Patched Bash and Zsh binaries for Codex shell execution.", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - }, - "files": [ - "vendor", - "README.md" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/openai/codex.git", - "directory": "shell-tool-mcp" - }, - "scripts": { - "clean": "rm -rf bin", - "build": "tsup", - "build:watch": "tsup --watch", - "test": "jest", - "test:watch": "jest --watch", - "format": "prettier --check .", - "format:fix": "prettier --write ." - }, - "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^20.19.18", - "jest": "^29.7.0", - "prettier": "^3.6.2", - "ts-jest": "^29.3.4", - "tsup": "^8.5.0", - "typescript": "^5.9.2" - }, - "packageManager": "pnpm@10.29.3+sha512.498e1fb4cca5aa06c1dcf2611e6fafc50972ffe7189998c409e90de74566444298ffe43e6cd2acdc775ba1aa7cc5e092a8b7054c811ba8c5770f84693d33d2dc" -} diff --git a/shell-tool-mcp/patches/bash-exec-wrapper.patch b/shell-tool-mcp/patches/bash-exec-wrapper.patch deleted file mode 100644 index f4471f2c2d..0000000000 --- a/shell-tool-mcp/patches/bash-exec-wrapper.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/execute_cmd.c b/execute_cmd.c -index 070f5119..d20ad2b9 100644 ---- a/execute_cmd.c -+++ b/execute_cmd.c -@@ -6129,6 +6129,19 @@ shell_execve (char *command, char **args, char **env) - char sample[HASH_BANG_BUFSIZ]; - size_t larray; - -+ char* exec_wrapper = getenv("EXEC_WRAPPER"); -+ if (exec_wrapper && *exec_wrapper && !whitespace (*exec_wrapper)) -+ { -+ char *orig_command = command; -+ -+ larray = strvec_len (args); -+ -+ memmove (args + 2, args, (++larray) * sizeof (char *)); -+ args[0] = exec_wrapper; -+ args[1] = orig_command; -+ command = exec_wrapper; -+ } -+ - SETOSTYPE (0); /* Some systems use for USG/POSIX semantics */ - execve (command, args, env); - i = errno; /* error from execve() */ diff --git a/shell-tool-mcp/src/bashSelection.ts b/shell-tool-mcp/src/bashSelection.ts deleted file mode 100644 index 7da137f6a7..0000000000 --- a/shell-tool-mcp/src/bashSelection.ts +++ /dev/null @@ -1,115 +0,0 @@ -import path from "node:path"; -import os from "node:os"; -import { DARWIN_BASH_VARIANTS, LINUX_BASH_VARIANTS } from "./constants"; -import { BashSelection, OsReleaseInfo } from "./types"; - -function supportedDetail(variants: ReadonlyArray<{ name: string }>): string { - return `Supported variants: ${variants.map((variant) => variant.name).join(", ")}`; -} - -export function selectLinuxBash( - bashRoot: string, - info: OsReleaseInfo, -): BashSelection { - const versionId = info.versionId; - const candidates: Array<{ - variant: (typeof LINUX_BASH_VARIANTS)[number]; - matchesVersion: boolean; - }> = []; - for (const variant of LINUX_BASH_VARIANTS) { - const matchesId = - variant.ids.includes(info.id) || - variant.ids.some((id) => info.idLike.includes(id)); - if (!matchesId) { - continue; - } - const matchesVersion = Boolean( - versionId && - variant.versions.some((prefix) => versionId.startsWith(prefix)), - ); - candidates.push({ variant, matchesVersion }); - } - - const pickVariant = (list: typeof candidates) => - list.find((item) => item.variant)?.variant; - - const preferred = pickVariant( - candidates.filter((item) => item.matchesVersion), - ); - if (preferred) { - return { - path: path.join(bashRoot, preferred.name, "bash"), - variant: preferred.name, - }; - } - - const fallbackMatch = pickVariant(candidates); - if (fallbackMatch) { - return { - path: path.join(bashRoot, fallbackMatch.name, "bash"), - variant: fallbackMatch.name, - }; - } - - const fallback = LINUX_BASH_VARIANTS[0]; - if (fallback) { - return { - path: path.join(bashRoot, fallback.name, "bash"), - variant: fallback.name, - }; - } - - const detail = supportedDetail(LINUX_BASH_VARIANTS); - throw new Error( - `Unable to select a Bash variant for ${info.id || "unknown"} ${versionId || ""}. ${detail}`, - ); -} - -export function selectDarwinBash( - bashRoot: string, - darwinRelease: string, -): BashSelection { - const darwinMajor = Number.parseInt(darwinRelease.split(".")[0] || "0", 10); - const preferred = DARWIN_BASH_VARIANTS.find( - (variant) => darwinMajor >= variant.minDarwin, - ); - if (preferred) { - return { - path: path.join(bashRoot, preferred.name, "bash"), - variant: preferred.name, - }; - } - - const fallback = DARWIN_BASH_VARIANTS[0]; - if (fallback) { - return { - path: path.join(bashRoot, fallback.name, "bash"), - variant: fallback.name, - }; - } - - const detail = supportedDetail(DARWIN_BASH_VARIANTS); - throw new Error( - `Unable to select a macOS Bash build (darwin ${darwinMajor}). ${detail}`, - ); -} - -export function resolveBashPath( - targetRoot: string, - platform: NodeJS.Platform, - darwinRelease = os.release(), - osInfo: OsReleaseInfo | null = null, -): BashSelection { - const bashRoot = path.join(targetRoot, "bash"); - - if (platform === "linux") { - if (!osInfo) { - throw new Error("Linux OS info is required to select a Bash variant."); - } - return selectLinuxBash(bashRoot, osInfo); - } - if (platform === "darwin") { - return selectDarwinBash(bashRoot, darwinRelease); - } - throw new Error(`Unsupported platform for Bash selection: ${platform}`); -} diff --git a/shell-tool-mcp/src/constants.ts b/shell-tool-mcp/src/constants.ts deleted file mode 100644 index e2bbcbf5b2..0000000000 --- a/shell-tool-mcp/src/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DarwinBashVariant, LinuxBashVariant } from "./types"; - -export const LINUX_BASH_VARIANTS: ReadonlyArray = [ - { name: "ubuntu-24.04", ids: ["ubuntu"], versions: ["24.04"] }, - { name: "ubuntu-22.04", ids: ["ubuntu"], versions: ["22.04"] }, - { name: "ubuntu-20.04", ids: ["ubuntu"], versions: ["20.04"] }, - { name: "debian-12", ids: ["debian"], versions: ["12"] }, - { name: "debian-11", ids: ["debian"], versions: ["11"] }, - { - name: "centos-9", - ids: ["centos", "rhel", "rocky", "almalinux"], - versions: ["9"], - }, -]; - -export const DARWIN_BASH_VARIANTS: ReadonlyArray = [ - { name: "macos-15", minDarwin: 24 }, - { name: "macos-14", minDarwin: 23 }, - { name: "macos-13", minDarwin: 22 }, -]; diff --git a/shell-tool-mcp/src/index.ts b/shell-tool-mcp/src/index.ts deleted file mode 100644 index 6003040365..0000000000 --- a/shell-tool-mcp/src/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Reports the path to the appropriate Bash binary bundled in this package. - -import os from "node:os"; -import path from "node:path"; -import { resolveBashPath } from "./bashSelection"; -import { readOsRelease } from "./osRelease"; -import { resolveTargetTriple } from "./platform"; - -async function main(): Promise { - const targetTriple = resolveTargetTriple(process.platform, process.arch); - const vendorRoot = path.resolve(__dirname, "..", "vendor"); - const targetRoot = path.join(vendorRoot, targetTriple); - - const osInfo = process.platform === "linux" ? readOsRelease() : null; - const { path: bashPath } = resolveBashPath( - targetRoot, - process.platform, - os.release(), - osInfo, - ); - - console.log(`Platform Bash is: ${bashPath}`); -} - -void main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); -}); diff --git a/shell-tool-mcp/src/osRelease.ts b/shell-tool-mcp/src/osRelease.ts deleted file mode 100644 index 3e55bfb1da..0000000000 --- a/shell-tool-mcp/src/osRelease.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readFileSync } from "node:fs"; -import { OsReleaseInfo } from "./types"; - -export function parseOsRelease(contents: string): OsReleaseInfo { - const lines = contents.split("\n").filter(Boolean); - const info: Record = {}; - for (const line of lines) { - const [rawKey, rawValue] = line.split("=", 2); - if (!rawKey || rawValue === undefined) { - continue; - } - const key = rawKey.toLowerCase(); - const value = rawValue.replace(/^"/, "").replace(/"$/, ""); - info[key] = value; - } - const idLike = (info.id_like || "") - .split(/\s+/) - .map((item) => item.trim().toLowerCase()) - .filter(Boolean); - return { - id: (info.id || "").toLowerCase(), - idLike, - versionId: info.version_id || "", - }; -} - -export function readOsRelease(pathname = "/etc/os-release"): OsReleaseInfo { - try { - const contents = readFileSync(pathname, "utf8"); - return parseOsRelease(contents); - } catch { - return { id: "", idLike: [], versionId: "" }; - } -} diff --git a/shell-tool-mcp/src/platform.ts b/shell-tool-mcp/src/platform.ts deleted file mode 100644 index 177ba52b22..0000000000 --- a/shell-tool-mcp/src/platform.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function resolveTargetTriple( - platform: NodeJS.Platform, - arch: NodeJS.Architecture, -): string { - if (platform === "linux") { - if (arch === "x64") { - return "x86_64-unknown-linux-musl"; - } - if (arch === "arm64") { - return "aarch64-unknown-linux-musl"; - } - } else if (platform === "darwin") { - if (arch === "x64") { - return "x86_64-apple-darwin"; - } - if (arch === "arm64") { - return "aarch64-apple-darwin"; - } - } - throw new Error(`Unsupported platform: ${platform} (${arch})`); -} diff --git a/shell-tool-mcp/src/types.ts b/shell-tool-mcp/src/types.ts deleted file mode 100644 index 28748101c2..0000000000 --- a/shell-tool-mcp/src/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type LinuxBashVariant = { - name: string; - ids: Array; - versions: Array; -}; - -export type DarwinBashVariant = { - name: string; - minDarwin: number; -}; - -export type OsReleaseInfo = { - id: string; - idLike: Array; - versionId: string; -}; - -export type BashSelection = { - path: string; - variant: string; -}; diff --git a/shell-tool-mcp/tests/bashSelection.test.ts b/shell-tool-mcp/tests/bashSelection.test.ts deleted file mode 100644 index 90753e675b..0000000000 --- a/shell-tool-mcp/tests/bashSelection.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { selectDarwinBash, selectLinuxBash } from "../src/bashSelection"; -import { DARWIN_BASH_VARIANTS, LINUX_BASH_VARIANTS } from "../src/constants"; -import { OsReleaseInfo } from "../src/types"; -import path from "node:path"; - -describe("selectLinuxBash", () => { - const bashRoot = "/vendor/bash"; - - it("prefers exact version match when id is present", () => { - const info: OsReleaseInfo = { - id: "ubuntu", - idLike: ["debian"], - versionId: "24.04.1", - }; - const selection = selectLinuxBash(bashRoot, info); - expect(selection.variant).toBe("ubuntu-24.04"); - expect(selection.path).toBe(path.join(bashRoot, "ubuntu-24.04", "bash")); - }); - - it("falls back to first supported variant when no matches", () => { - const info: OsReleaseInfo = { id: "unknown", idLike: [], versionId: "1.0" }; - const selection = selectLinuxBash(bashRoot, info); - expect(selection.variant).toBe(LINUX_BASH_VARIANTS[0].name); - }); -}); - -describe("selectDarwinBash", () => { - const bashRoot = "/vendor/bash"; - - it("selects compatible darwin version", () => { - const darwinRelease = "24.0.0"; - const selection = selectDarwinBash(bashRoot, darwinRelease); - expect(selection.variant).toBe("macos-15"); - }); - - it("falls back to first darwin variant when release too old", () => { - const darwinRelease = "20.0.0"; - const selection = selectDarwinBash(bashRoot, darwinRelease); - expect(selection.variant).toBe(DARWIN_BASH_VARIANTS[0].name); - }); -}); diff --git a/shell-tool-mcp/tests/osRelease.test.ts b/shell-tool-mcp/tests/osRelease.test.ts deleted file mode 100644 index 2291129868..0000000000 --- a/shell-tool-mcp/tests/osRelease.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { parseOsRelease } from "../src/osRelease"; - -describe("parseOsRelease", () => { - it("parses basic fields", () => { - const contents = `ID="ubuntu" -ID_LIKE="debian" -VERSION_ID=24.04 -OTHER=ignored`; - - const info = parseOsRelease(contents); - expect(info).toEqual({ - id: "ubuntu", - idLike: ["debian"], - versionId: "24.04", - }); - }); - - it("handles missing fields", () => { - const contents = "SOMETHING=else"; - const info = parseOsRelease(contents); - expect(info).toEqual({ id: "", idLike: [], versionId: "" }); - }); - - it("normalizes id_like entries", () => { - const contents = `ID="rhel" -ID_LIKE="CentOS Rocky"`; - const info = parseOsRelease(contents); - expect(info.idLike).toEqual(["centos", "rocky"]); - }); -}); diff --git a/shell-tool-mcp/tsconfig.json b/shell-tool-mcp/tsconfig.json deleted file mode 100644 index 90bb3e0d78..0000000000 --- a/shell-tool-mcp/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", - "noEmit": true, - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true - }, - "include": ["src", "tests"] -} diff --git a/shell-tool-mcp/tsup.config.ts b/shell-tool-mcp/tsup.config.ts deleted file mode 100644 index 8e4e2d4e38..0000000000 --- a/shell-tool-mcp/tsup.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "tsup"; - -export default defineConfig({ - entry: { - "mcp-server": "src/index.ts", - }, - outDir: "bin", - format: ["cjs"], - target: "node18", - clean: true, - sourcemap: false, - banner: { - js: "#!/usr/bin/env node", - }, -});