diff --git a/.github/ISSUE_TEMPLATE/3-cli.yml b/.github/ISSUE_TEMPLATE/3-cli.yml index 37229c7f2b..cfd368c0ba 100644 --- a/.github/ISSUE_TEMPLATE/3-cli.yml +++ b/.github/ISSUE_TEMPLATE/3-cli.yml @@ -11,6 +11,8 @@ body: Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed. + If your version supports it, please run `codex doctor --json` and paste the output in the "Codex doctor report" field below. This helps us diagnose install, config, auth, terminal, MCP, network, and local state issues. + - type: input id: version attributes: @@ -43,6 +45,16 @@ body: description: | Also note any multiplexer in use (screen / tmux / zellij). E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell) + - type: textarea + id: doctor + attributes: + label: Codex doctor report + description: | + If available, run `codex doctor --json` and paste the full output here. + + The report is designed to redact secrets, but please review it before submitting. + If your Codex version does not support `doctor`, write `not available`. + render: json - type: textarea id: actual attributes: diff --git a/.github/dotslash-unsigned-config.json b/.github/dotslash-unsigned-config.json new file mode 100644 index 0000000000..65c44d5e8d --- /dev/null +++ b/.github/dotslash-unsigned-config.json @@ -0,0 +1,124 @@ +{ + "outputs": { + "codex-unsigned": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-aarch64-apple-darwin-unsigned\\.zst$", + "path": "codex" + }, + "macos-x86_64": { + "regex": "^codex-x86_64-apple-darwin-unsigned\\.zst$", + "path": "codex" + }, + "linux-x86_64": { + "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", + "path": "codex" + }, + "linux-aarch64": { + "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", + "path": "codex" + }, + "windows-x86_64": { + "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex.exe" + }, + "windows-aarch64": { + "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex.exe" + } + } + }, + "codex-app-server-unsigned": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-app-server-aarch64-apple-darwin-unsigned\\.zst$", + "path": "codex-app-server" + }, + "macos-x86_64": { + "regex": "^codex-app-server-x86_64-apple-darwin-unsigned\\.zst$", + "path": "codex-app-server" + }, + "linux-x86_64": { + "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" + }, + "linux-aarch64": { + "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" + }, + "windows-x86_64": { + "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" + }, + "windows-aarch64": { + "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" + } + } + }, + "codex-responses-api-proxy-unsigned": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-apple-darwin-unsigned\\.zst$", + "path": "codex-responses-api-proxy" + }, + "macos-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-apple-darwin-unsigned\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "linux-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-responses-api-proxy" + }, + "windows-x86_64": { + "regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + }, + "windows-aarch64": { + "regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-responses-api-proxy.exe" + } + } + }, + "bwrap": { + "platforms": { + "linux-x86_64": { + "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", + "path": "bwrap" + }, + "linux-aarch64": { + "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", + "path": "bwrap" + } + } + }, + "codex-command-runner": { + "platforms": { + "windows-x86_64": { + "regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-command-runner.exe" + }, + "windows-aarch64": { + "regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-command-runner.exe" + } + } + }, + "codex-windows-sandbox-setup": { + "platforms": { + "windows-x86_64": { + "regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-windows-sandbox-setup.exe" + }, + "windows-aarch64": { + "regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-windows-sandbox-setup.exe" + } + } + } + } +} diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index cc3968d306..11c0988ceb 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -17,10 +17,10 @@ concurrency: cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: test: - # PRs use a fast Windows cross-compiled test leg for pre-merge signal. - # Post-merge pushes to main also run the native Windows test job below for - # broader Windows signal without putting PR latency back on the critical - # path. Cargo CI owns V8/code-mode test coverage for now. + # PRs use the sharded Windows cross-compiled test jobs below. Post-merge + # pushes to main also run the native Windows test job for broader Windows + # signal without putting PR latency back on the critical path. Cargo CI + # owns V8/code-mode test coverage for now. timeout-minutes: 30 strategy: fail-fast: false @@ -44,12 +44,6 @@ jobs: # - os: ubuntu-24.04-arm # target: aarch64-unknown-linux-gnu - # Windows fast path: build the windows-gnullvm binaries with Linux - # RBE, then run the resulting Windows tests on the Windows runner. - # Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad - # non-code-mode signal. - - os: windows-latest - target: x86_64-pc-windows-gnullvm runs-on: ${{ matrix.os }} # Configure a human readable name for each job @@ -108,13 +102,6 @@ jobs: --test_verbose_timeout_warnings --build_metadata=COMMIT_SHA=${GITHUB_SHA} ) - if [[ "${RUNNER_OS}" == "Windows" ]]; then - bazel_wrapper_args+=( - --windows-cross-compile - --remote-download-toplevel - ) - fi - ./.github/scripts/run-bazel-ci.sh \ "${bazel_wrapper_args[@]}" \ -- \ @@ -141,6 +128,118 @@ jobs: path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} + test-windows-shard: + # Split the Windows Bazel test leg across separate Windows + # hosts. Each shard still uses Linux RBE for build actions, but the test + # execution itself happens on its own Windows runner. + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: + - 1 + - 2 + - 3 + - 4 + runs-on: windows-latest + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Prepare Bazel CI + id: prepare_bazel + uses: ./.github/actions/prepare-bazel-ci + with: + target: x86_64-pc-windows-gnullvm + # Reuse the former monolithic Windows test cache for restores. Do + # not save it from every shard below; duplicate uploads would sit on + # the PR-blocking critical path after the useful test work is done. + cache-scope: bazel-test + install-test-prereqs: "true" + + - name: bazel test shard + env: + BAZEL_TEST_SHARD: ${{ matrix.shard }} + BAZEL_TEST_SHARD_COUNT: 4 + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + set -euo pipefail + + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + mapfile -t bazel_targets < <( + MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ + | LC_ALL=C sort + ) + + selected_targets=() + for bazel_target in "${bazel_targets[@]}"; do + target_bucket="$( + printf '%s\n' "${bazel_target}" \ + | cksum \ + | awk -v shard_count="${BAZEL_TEST_SHARD_COUNT}" '{ print ($1 % shard_count) + 1 }' + )" + if [[ "${target_bucket}" == "${BAZEL_TEST_SHARD}" ]]; then + selected_targets+=("${bazel_target}") + fi + done + + if [[ ${#selected_targets[@]} -eq 0 ]]; then + echo "No Bazel test targets selected for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." >&2 + exit 1 + fi + + echo "Selected ${#selected_targets[@]} of ${#bazel_targets[@]} Bazel test targets for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." + + bazel_test_args=( + test + --skip_incompatible_explicit_targets + --test_tag_filters=-argument-comment-lint + --test_verbose_timeout_warnings + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + --build_metadata=TAG_windows_test_shard=${BAZEL_TEST_SHARD} + ) + + ./.github/scripts/run-bazel-ci.sh \ + --print-failed-action-summary \ + --print-failed-test-logs \ + --windows-cross-compile \ + --remote-download-toplevel \ + -- \ + "${bazel_test_args[@]}" \ + -- \ + "${selected_targets[@]}" + + - name: Upload Bazel execution logs + if: always() && !cancelled() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: bazel-execution-logs-test-x86_64-pc-windows-gnullvm-shard-${{ matrix.shard }} + path: ${{ runner.temp }}/bazel-execution-logs + if-no-files-found: ignore + + test-windows: + # Preserve the existing required-check surface while the real work happens + # in the sharded Windows jobs above. + if: always() + needs: test-windows-shard + runs-on: ubuntu-24.04 + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm + + steps: + - name: Confirm Windows Bazel test shards passed + shell: bash + run: | + if [[ "${{ needs.test-windows-shard.result }}" != "success" ]]; then + echo "Windows Bazel test shards finished with result: ${{ needs.test-windows-shard.result }}" >&2 + exit 1 + fi + test-windows-native-main: # Native Windows Bazel tests are slower and frequently approach the # 30-minute PR budget. Run this only for post-merge commits to main and give diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml deleted file mode 100644 index a0bba631cd..0000000000 --- a/.github/workflows/issue-labeler.yml +++ /dev/null @@ -1,153 +0,0 @@ -name: Issue Labeler - -on: - issues: - types: - - opened - - labeled - -jobs: - gather-labels: - name: Generate label suggestions - # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) - if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - codex_output: ${{ steps.codex.outputs.final-message }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - id: codex - uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that reviews GitHub issues for the repository. - - Your job is to choose the most appropriate labels for the issue described later in this prompt. - Follow these rules: - - - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. - 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). - 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. - 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). - - - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. - 1. CLI — the Codex command line interface. - 2. extension — VS Code (or other IDE) extension-specific issues. - 3. app - Issues related to the Codex desktop application. - 4. codex-web — Issues targeting the Codex web UI/Cloud experience. - 5. github-action — Issues with the Codex GitHub action. - 6. iOS — Issues with the Codex iOS app. - - - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. - - For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks. - 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). - 2. mcp — Topics involving Model Context Protocol servers/clients. - 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. - 4. azure — Problems or requests tied to Azure OpenAI deployments. - 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. - 6. code-review — Issues related to the code review feature or functionality. - 7. safety-check - Issues related to cyber risk detection or trusted access verification. - 8. auth - Problems related to authentication, login, or access tokens. - 9. exec - Problems related to the "codex exec" command or functionality. - 10. hooks - Problems related to event hooks - 11. context - Problems related to compaction, context windows, or available context reporting. - 12. skills - Problems related to skills or plugins - 13. custom-model - Problems that involve using custom model providers, local models, or OSS models. - 14. rate-limits - Problems related to token limits, rate limits, or token usage reporting. - 15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. - 16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. - 17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. - 18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior. - 19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior. - 20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration. - 21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state. - 22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior. - 23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior. - 24. computer-use - Issues involving agentic computer use or SkyComputerUseService. - 25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app. - 26. memory - Issues involving agentic memory storage and retrieval. - 27. imagen - Issues involving image generation. - 28. remote - Issues involving remote access, remote control, or SSH. - 29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks. - 30. automations - Issues involving scheduled automation tasks or heartbeats. - 31. pets - Issues involving pets avatars and animations. - 32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets. - - Issue number: ${{ github.event.issue.number }} - - Issue title: - ${{ github.event.issue.title }} - - Issue body: - ${{ github.event.issue.body }} - - Repository full name: - ${{ github.repository }} - - output-schema: | - { - "type": "object", - "properties": { - "labels": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["labels"], - "additionalProperties": false - } - - apply-labels: - name: Apply labels from Codex output - needs: gather-labels - if: ${{ needs.gather-labels.result != 'skipped' }} - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }} - steps: - - name: Apply labels - run: | - json=${CODEX_OUTPUT//$'\r'/} - if [ -z "$json" ]; then - echo "Codex produced no output. Skipping label application." - exit 0 - fi - - if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then - echo "Codex output did not include a labels array. Raw output: $json" - exit 0 - fi - - labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring') - if [ -z "$labels" ]; then - echo "Codex returned an empty array. Nothing to do." - exit 0 - fi - - cmd=(gh issue edit "$ISSUE_NUMBER") - while IFS= read -r label; do - cmd+=(--add-label "$label") - done <<< "$labels" - - "${cmd[@]}" || true - - - name: Remove codex-label trigger - if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }} - run: | - gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true - echo "Attempted to remove label: codex-label" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index f9c4b4655c..ca082812c6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -10,6 +10,13 @@ on: push: tags: - "rust-v*.*.*" + workflow_dispatch: + inputs: + sign_macos: + description: "Sign and notarize macOS release artifacts." + required: false + type: boolean + default: true concurrency: group: ${{ github.workflow }} @@ -25,10 +32,17 @@ jobs: - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - name: Validate tag matches Cargo.toml version shell: bash + env: + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} run: | set -euo pipefail echo "::group::Tag validation" + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${SIGN_MACOS}" == "true" ]]; then + echo "❌ Manual rust-release runs must set sign_macos=false" + exit 1 + fi + # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } @@ -64,11 +78,36 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} strategy: fail-fast: false matrix: include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - runner: macos-15-xlarge + target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl @@ -95,7 +134,7 @@ jobs: binaries: "codex-app-server" build_dmg: "false" - steps: &rust_release_build_steps + steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -271,6 +310,39 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn + - if: ${{ runner.os == 'macOS' }} + name: Stage unsigned macOS artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + release_dir="target/${target}/release" + dest="unsigned-dist/${target}" + mkdir -p "$dest" + + for binary in ${{ matrix.binaries }}; do + binary_path="${release_dir}/${binary}" + unsigned_name="${binary}-${target}-unsigned" + unsigned_path="${dest}/${unsigned_name}" + if [[ ! -f "${binary_path}" ]]; then + echo "Binary ${binary_path} not found" + exit 1 + fi + + cp "${binary_path}" "${unsigned_path}" + tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}" + zstd -T0 -19 --rm "${unsigned_path}" + done + + - if: ${{ runner.os == 'macOS' }} + name: Upload unsigned macOS artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-unsigned + path: codex-rs/unsigned-dist/${{ matrix.target }}/* + if-no-files-found: error + - if: ${{ contains(matrix.target, 'linux') }} name: Cosign Linux artifacts uses: ./.github/actions/linux-code-sign @@ -279,7 +351,7 @@ jobs: artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release binaries: ${{ matrix.binaries }} - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: @@ -293,7 +365,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }} + - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} name: Build macOS dmg shell: bash run: | @@ -333,7 +405,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }} + - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -347,6 +419,7 @@ jobs: apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - name: Stage artifacts + if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | dest="dist/${{ matrix.target }}" @@ -376,7 +449,7 @@ jobs: fi - name: Build Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} + if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} shell: bash run: | set -euo pipefail @@ -427,7 +500,7 @@ jobs: "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - name: Upload Python runtime wheel - if: ${{ matrix.bundle == 'primary' }} + if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: python-runtime-wheel-${{ matrix.target }} @@ -435,6 +508,7 @@ jobs: if-no-files-found: error - name: Compress artifacts + if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} shell: bash run: | # Path that contains the uncompressed binaries for the current @@ -471,6 +545,7 @@ jobs: done - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} with: name: ${{ matrix.artifact_name }} # Upload the per-binary .zst files, .tar.gz equivalents, and any @@ -478,55 +553,6 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* - build-macos: - needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Release builds can take a long time, so leave some headroom to avoid - # having to restart the full workflow due to a timeout. - timeout-minutes: 90 - environment: macos-signing - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - env: - # Preserve the LTO setting from the original combined release build matrix. - # Linux ARM is why this currently resolves to thin for every target. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: primary - artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: aarch64-apple-darwin - bundle: app-server - artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: primary - artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" - build_dmg: "true" - - runner: macos-15-xlarge - target: x86_64-apple-darwin - bundle: app-server - artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" - build_dmg: "false" - - steps: *rust_release_build_steps - build-windows: needs: tag-check uses: ./.github/workflows/rust-release-windows.yml @@ -549,7 +575,6 @@ jobs: release: needs: - build - - build-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets @@ -558,9 +583,12 @@ jobs: permissions: contents: write actions: read + env: + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} + sign_macos: ${{ steps.release_mode.outputs.sign_macos }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} @@ -571,6 +599,11 @@ jobs: with: persist-credentials: false + - name: Define release mode + id: release_mode + run: | + echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" + - name: Generate release notes from tag commit message id: release_notes shell: bash @@ -598,6 +631,24 @@ jobs: - name: List run: ls -R dist/ + - name: Prune artifacts excluded from unsigned macOS release + if: ${{ env.SIGN_MACOS == 'false' }} + run: | + find dist -mindepth 1 -maxdepth 1 -type d \ + ! -name '*-apple-darwin*-unsigned' \ + ! -name 'aarch64-unknown-linux-musl' \ + ! -name 'aarch64-unknown-linux-musl-app-server' \ + ! -name 'x86_64-unknown-linux-musl' \ + ! -name 'x86_64-unknown-linux-musl-app-server' \ + ! -name 'aarch64-pc-windows-msvc' \ + ! -name 'x86_64-pc-windows-msvc' \ + -exec rm -rf {} + + + if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then + echo "No unsigned macOS artifacts found in downloaded workflow artifacts." + exit 1 + fi + - name: Delete entries from dist/ that should not go in the release run: | rm -rf dist/windows-binaries* @@ -629,6 +680,12 @@ jobs: set -euo pipefail version="${VERSION}" + if [[ "${SIGN_MACOS}" != "true" ]]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "npm_tag=" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "npm_tag=" >> "$GITHUB_OUTPUT" @@ -648,6 +705,11 @@ jobs: set -euo pipefail version="${VERSION}" + if [[ "${SIGN_MACOS}" != "true" ]]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "should_publish=true" >> "$GITHUB_OUTPUT" elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then @@ -657,21 +719,25 @@ jobs: fi - name: Setup pnpm + if: ${{ env.SIGN_MACOS == 'true' }} uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js for npm packaging + if: ${{ env.SIGN_MACOS == 'true' }} uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 - name: Install dependencies + if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile # stage_npm_packages.py requires DotSlash when staging releases. - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm packages + if: ${{ env.SIGN_MACOS == 'true' }} env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} @@ -683,6 +749,7 @@ jobs: --package codex-sdk - name: Stage installer scripts + if: ${{ env.SIGN_MACOS == 'true' }} run: | cp scripts/install/install.sh dist/install.sh cp scripts/install/install.ps1 dist/install.ps1 @@ -694,25 +761,37 @@ jobs: tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** + make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: ${{ env.SIGN_MACOS == 'true' }} + uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: ${{ env.SIGN_MACOS == 'false' }} + uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag: ${{ github.ref_name }} + config: .github/dotslash-unsigned-config.json + + - if: ${{ env.SIGN_MACOS == 'true' }} + uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag: ${{ github.ref_name }} config: .github/dotslash-zsh-config.json - - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 + - if: ${{ env.SIGN_MACOS == 'true' }} + uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -722,7 +801,7 @@ jobs: - name: Trigger developers.openai.com deploy # Only trigger the deploy if the release is not a pre-release. # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ !contains(steps.release_name.outputs.name, '-') }} + if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} continue-on-error: true env: DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} @@ -936,7 +1015,7 @@ jobs: needs: release # Only publish stable/mainline releases to WinGet; pre-releases include a # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: ${{ !contains(needs.release.outputs.version, '-') }} + if: ${{ needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} # This job only invokes a GitHub Action to open/update the winget-pkgs PR; # it does not execute Windows-only tooling, so Linux is sufficient. runs-on: ubuntu-latest @@ -956,6 +1035,7 @@ jobs: update-branch: name: Update latest-alpha-cli branch + if: ${{ needs.release.outputs.sign_macos == 'true' }} permissions: contents: write needs: release diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ce362f0978..87434e1c68 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1482,6 +1482,7 @@ "serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}", "serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}", "serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}", + "serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}", "serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}", "serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}", "serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}", diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 67ab3e2d95..475239549a 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -2,7 +2,7 @@ // Unified entry point for the Codex CLI. import { spawn } from "node:child_process"; -import { existsSync } from "fs"; +import { existsSync, realpathSync } from "fs"; import { createRequire } from "node:module"; import path from "path"; import { fileURLToPath } from "url"; @@ -171,6 +171,7 @@ const packageManagerEnvVar = ? "CODEX_MANAGED_BY_BUN" : "CODEX_MANAGED_BY_NPM"; env[packageManagerEnvVar] = "1"; +env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, "..")); const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4a23797cc3..20197567ab 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1902,7 +1902,6 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-file-watcher", - "codex-git-attribution", "codex-git-utils", "codex-guardian", "codex-hooks", @@ -2221,6 +2220,7 @@ dependencies = [ "assert_matches", "clap", "clap_complete", + "codex-api", "codex-app-server", "codex-app-server-daemon", "codex-app-server-protocol", @@ -2235,11 +2235,14 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-install-context", "codex-login", "codex-mcp", "codex-mcp-server", "codex-memories-write", + "codex-model-provider", "codex-models-manager", + "codex-plugin", "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", @@ -2254,11 +2257,14 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-windows-sandbox", + "crossterm", + "http 1.4.0", "libc", "owo-colors", "predicates", "pretty_assertions", "regex-lite", + "serde", "serde_json", "sqlx", "supports-color 3.0.2", @@ -2432,6 +2438,7 @@ dependencies = [ "prost 0.14.3", "schemars 0.8.22", "serde", + "serde_ignored", "serde_json", "serde_path_to_error", "sha2", @@ -2911,16 +2918,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "codex-git-attribution" -version = "0.0.0" -dependencies = [ - "codex-core", - "codex-extension-api", - "codex-features", - "pretty_assertions", -] - [[package]] name = "codex-git-utils" version = "0.0.0" @@ -3141,6 +3138,7 @@ dependencies = [ name = "codex-memories-extension" version = "0.0.0" dependencies = [ + "async-trait", "codex-core", "codex-extension-api", "codex-features", @@ -3481,6 +3479,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "bytes", "codex-api", "codex-client", @@ -3732,6 +3731,7 @@ dependencies = [ name = "codex-tools" version = "0.0.0" dependencies = [ + "async-trait", "codex-app-server-protocol", "codex-code-mode", "codex-features", @@ -3797,7 +3797,6 @@ dependencies = [ "codex-utils-oss", "codex-utils-path", "codex-utils-plugins", - "codex-utils-pty", "codex-utils-sandbox-summary", "codex-utils-sleep-inhibitor", "codex-utils-string", @@ -5454,7 +5453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11641,6 +11640,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index bd0bcec66a..14b1264a81 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -46,7 +46,6 @@ members = [ "execpolicy-legacy", "ext/extension-api", "ext/guardian", - "ext/git-attribution", "ext/memories", "external-agent-migration", "external-agent-sessions", @@ -164,7 +163,6 @@ codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-guardian = { path = "ext/guardian" } -codex-git-attribution = { path = "ext/git-attribution" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -352,6 +350,7 @@ seccompiler = "0.5.0" semver = "1.0" sentry = "0.46.0" serde = "1" +serde_ignored = "0.1.14" serde_json = "1" serde_path_to_error = "0.1.20" serde_with = "3.17" diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 46b88351ec..46fc3d862e 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -337,6 +337,8 @@ pub struct InProcessClientStartArgs { pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. pub loader_overrides: LoaderOverrides, + /// Whether config API paths should reject unknown config fields. + pub strict_config: bool, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, /// Feedback sink used by app-server/core telemetry and logs. @@ -403,6 +405,7 @@ impl InProcessClientStartArgs { config: self.config, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, + strict_config: self.strict_config, cloud_requirements: self.cloud_requirements, thread_config_loader, feedback: self.feedback, @@ -1031,6 +1034,7 @@ mod tests { config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, @@ -2189,6 +2193,7 @@ mod tests { config: config.clone(), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, @@ -2229,6 +2234,7 @@ mod tests { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index dc279794c2..305c2a36a3 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -156,3 +156,17 @@ fn update_loop_uses_hidden_app_server_subcommand() { vec!["app-server", "daemon", "pid-update-loop"] ); } + +#[test] +fn app_server_remote_control_uses_runtime_flag() { + let backend = PidBackend::new( + "codex".into(), + "app-server.pid".into(), + /*remote_control_enabled*/ true, + ); + + assert_eq!( + backend.command_args(), + vec!["app-server", "--remote-control", "--listen", "unix://"] + ); +} diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 826acbae68..4ab223051c 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -567,9 +567,13 @@ impl Daemon { return Ok(()); } + let managed_codex_path = self.managed_codex_bin.display(); Err(anyhow!( - "managed standalone Codex install not found at {}; install Codex first", - self.managed_codex_bin.display() + "managed standalone Codex install not found at {managed_codex_path}\n\n\ + This command requires the standalone install managed by the Codex installer, because \ + the daemon starts and updates app-server from that fixed path.\n\n\ + Install it with:\n curl -fsSL https://chatgpt.com/codex/install.sh | sh\n\n\ + Then rerun the command you just tried." )) } 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 a698080c67..c0ed187c3d 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 @@ -7467,6 +7467,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" @@ -17981,12 +17988,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { 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 ae0a614814..9227f3a9e8 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 @@ -3836,6 +3836,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" @@ -15805,12 +15812,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 87a826e5af..cb9176e560 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -482,6 +482,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" @@ -748,12 +755,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index 631318a8bd..37816463b6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -84,6 +84,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts index 1fe19b6765..08cb8c6bfd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts @@ -13,4 +13,9 @@ file: AbsolutePathBuf, } | { "type": "user", * This is the path to the user's config.toml file, though it is not * guaranteed to exist. */ -file: AbsolutePathBuf, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" }; +file: AbsolutePathBuf, +/** + * Name of the selected profile-v2 config layered on top of the base + * user config, when this layer represents one. + */ +profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts index 784991f017..13dc06e915 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WebSearchToolConfig } from "../WebSearchToolConfig"; -export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, }; +export type ToolsV2 = { web_search: WebSearchToolConfig | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 761c5274d1..b330f877fb 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -805,6 +805,18 @@ client_request_definitions! { serialization: global("config"), response: v2::ExperimentalFeatureEnablementSetResponse, }, + #[experimental("remoteControl/enable")] + RemoteControlEnable => "remoteControl/enable" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("remote-control"), + response: v2::RemoteControlEnableResponse, + }, + #[experimental("remoteControl/disable")] + RemoteControlDisable => "remoteControl/disable" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("remote-control"), + response: v2::RemoteControlDisableResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 95ab710a6b..f8e9afdd12 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -228,7 +228,6 @@ pub struct Profile { #[serde(rename_all = "camelCase")] pub struct Tools { pub web_search: Option, - pub view_image: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 2c0e2df8e5..108ce9252f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -51,6 +51,10 @@ pub enum ConfigLayerSource { /// This is the path to the user's config.toml file, though it is not /// guaranteed to exist. file: AbsolutePathBuf, + + /// Name of the selected profile-v2 config layered on top of the base + /// user config, when this layer represents one. + profile: Option, }, /// Path to a .codex/ folder within a project. There could be multiple of @@ -84,7 +88,13 @@ impl ConfigLayerSource { match self { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, - ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::User { profile, .. } => { + if profile.is_some() { + 21 + } else { + 20 + } + } ConfigLayerSource::Project { .. } => 25, ConfigLayerSource::SessionFlags => 30, ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, @@ -120,7 +130,6 @@ pub struct SandboxWorkspaceWrite { #[ts(export_to = "v2/")] pub struct ToolsV2 { pub web_search: Option, - pub view_image: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index 7dab24c3d4..e89a9d19b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -13,6 +13,24 @@ pub struct RemoteControlStatusChangedNotification { pub environment_id: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlEnableResponse { + pub status: RemoteControlConnectionStatus, + pub installation_id: String, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlDisableResponse { + pub status: RemoteControlConnectionStatus, + pub installation_id: String, + pub environment_id: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase", export_to = "v2/")] @@ -22,3 +40,33 @@ pub enum RemoteControlConnectionStatus { Connected, Errored, } + +impl From for RemoteControlEnableResponse { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + installation_id, + environment_id, + } = notification; + Self { + status, + installation_id, + environment_id, + } + } +} + +impl From for RemoteControlDisableResponse { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + installation_id, + environment_id, + } = notification; + Self { + status, + installation_id, + environment_id, + } + } +} diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index e3f39f60ce..f4eb1fc6a5 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -12,6 +12,7 @@ pub use transport::CHANNEL_CAPACITY; pub use transport::ConnectionOrigin; pub use transport::RemoteControlHandle; pub use transport::RemoteControlStartConfig; +pub use transport::RemoteControlUnavailable; pub use transport::TransportEvent; pub use transport::app_server_control_socket_path; pub use transport::auth; diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index 3e76069f43..a9e806c128 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -32,6 +32,7 @@ mod websocket; pub use remote_control::RemoteControlHandle; pub use remote_control::RemoteControlStartConfig; +pub use remote_control::RemoteControlUnavailable; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; pub use unix_socket::start_control_socket_acceptor; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 9ffe8b60bb..8722f4e9ee 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -19,6 +19,8 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use std::error::Error; +use std::fmt; use std::io; use std::sync::Arc; use tokio::sync::mpsc; @@ -47,23 +49,87 @@ pub struct RemoteControlHandle { state_db_available: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteControlUnavailable; + +impl fmt::Display for RemoteControlUnavailable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "remote control cannot be enabled because sqlite state db is unavailable" + ) + } +} + +impl Error for RemoteControlUnavailable {} + impl RemoteControlHandle { - pub fn set_enabled(&self, enabled: bool) { - let requested_enabled = enabled; - let enabled = enabled && self.state_db_available; - if requested_enabled && !self.state_db_available { + pub fn enable( + &self, + ) -> Result { + if !self.state_db_available { warn!("remote control cannot be enabled because sqlite state db is unavailable"); + return Err(RemoteControlUnavailable); } + self.enabled_tx.send_if_modified(|state| { - let changed = *state != enabled; - *state = enabled; + let changed = !*state; + *state = true; changed }); + + let status = self.status(); + if matches!( + status.status, + RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting + ) { + return Ok(status); + } + + Ok(self.publish_status(RemoteControlConnectionStatus::Connecting)) + } + + pub fn disable(&self) -> RemoteControlStatusChangedNotification { + self.enabled_tx.send_if_modified(|state| { + let changed = *state; + *state = false; + changed + }); + + self.publish_status(RemoteControlConnectionStatus::Disabled) + } + + pub fn status(&self) -> RemoteControlStatusChangedNotification { + self.status_tx.borrow().clone() } pub fn status_receiver(&self) -> watch::Receiver { self.status_tx.subscribe() } + + fn publish_status( + &self, + connection_status: RemoteControlConnectionStatus, + ) -> RemoteControlStatusChangedNotification { + self.status_tx.send_if_modified(|status| { + let next_status = RemoteControlStatusChangedNotification { + status: connection_status, + installation_id: status.installation_id.clone(), + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + }; + if *status == next_status { + return false; + } + + *status = next_status; + true + }); + self.status() + } } pub async fn start_remote_control( diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 88b7798827..3dcfb81ce5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -639,7 +639,10 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( .await .expect_err("remote control should not connect without sqlite state db"); - remote_handle.set_enabled(/*enabled*/ true); + assert_eq!( + remote_handle.enable().expect_err("enable should fail"), + super::RemoteControlUnavailable + ); timeout(Duration::from_millis(100), listener.accept()) .await .expect_err("remote control should remain disabled without sqlite state db"); @@ -655,7 +658,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( } #[tokio::test] -async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { +async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -701,7 +704,14 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { ) .await; - remote_handle.set_enabled(/*enabled*/ false); + assert_eq!( + remote_handle.disable(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + } + ); expect_remote_control_status_snapshot( &mut status_rx, RemoteControlStatusChangedNotification { @@ -718,13 +728,20 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { .await .expect_err("disabled remote control should not reconnect"); - remote_handle.set_enabled(/*enabled*/ true); + assert_eq!( + remote_handle.enable().expect("enable should succeed"), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: None, + } + ); expect_remote_control_status_snapshot( &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, installation_id: TEST_INSTALLATION_ID.to_string(), - environment_id: Some("env_test".to_string()), + environment_id: None, }, ) .await; diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ecc8d1c25a..c75461ecf7 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -42,7 +42,6 @@ codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } codex-guardian = { workspace = true } -codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } codex-hooks = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index bc13a63597..ce7fdc48a1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -190,7 +190,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). @@ -203,6 +203,8 @@ Example with notification opt-out: - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. +- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. +- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 9de2c184b9..18c276f07d 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -507,7 +507,7 @@ impl ExternalAgentConfigService { Ok(config) => { let configured_plugin_ids = config .config_layer_stack - .get_user_layer() + .get_active_user_layer() .and_then(|user_layer| user_layer.config.get("plugins")) .and_then(|plugins| { match plugins.clone().try_into::>() { diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 030829fa4b..25fdc5c0cb 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -30,6 +30,7 @@ pub(crate) struct ConfigManager { cli_overrides: Arc>>, runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: Arc>, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc>>, @@ -40,6 +41,7 @@ impl ConfigManager { codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, arg0_paths: Arg0DispatchPaths, thread_config_loader: Arc, @@ -49,6 +51,7 @@ impl ConfigManager { cli_overrides: Arc::new(RwLock::new(cli_overrides)), runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, + strict_config, cloud_requirements: Arc::new(RwLock::new(cloud_requirements)), arg0_paths, thread_config_loader: Arc::new(RwLock::new(thread_config_loader)), @@ -59,6 +62,10 @@ impl ConfigManager { self.codex_home.as_path() } + pub(crate) fn user_config_path(&self) -> std::io::Result { + self.loader_overrides.user_config_path(self.codex_home()) + } + pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { self.cli_overrides .read() @@ -161,6 +168,16 @@ impl ConfigManager { self.current_cli_overrides(), ) .await?; + if self.loader_overrides.user_config_path.is_some() + || self.loader_overrides.user_config_profile.is_some() + { + let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?; + config.config_layer_stack = config.config_layer_stack.with_user_config_profile( + &user_config_path, + self.loader_overrides.user_config_profile.as_ref(), + TomlValue::Table(toml::map::Map::new()), + ); + } self.apply_runtime_feature_enablement(&mut config); self.apply_arg0_paths(&mut config); Ok(config) @@ -217,6 +234,7 @@ impl ConfigManager { .codex_home(self.codex_home.clone()) .cli_overrides(merged_cli_overrides) .loader_overrides(self.loader_overrides.clone()) + .strict_config(self.strict_config) .harness_overrides(typesafe_overrides) .fallback_cwd(fallback_cwd) .cloud_requirements(self.current_cloud_requirements()) @@ -245,7 +263,10 @@ impl ConfigManager { &self.codex_home, cwd, &self.current_cli_overrides(), - self.loader_overrides.clone(), + codex_config::ConfigLoadOptions { + loader_overrides: self.loader_overrides.clone(), + strict_config: self.strict_config, + }, self.current_cloud_requirements(), thread_config_loader.as_ref(), ) @@ -280,6 +301,7 @@ impl ConfigManager { codex_home, cli_overrides, loader_overrides, + /*strict_config*/ false, cloud_requirements, Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index aef4393a09..64c5bee684 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -196,8 +196,9 @@ impl ConfigManager { expected_version: Option, edits: Vec<(String, JsonValue, MergeStrategy)>, ) -> Result { - let allowed_path = - AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home()); + let allowed_path = self + .user_config_path() + .map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?; let provided_path = match file_path { Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path)) .map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?, @@ -215,7 +216,7 @@ impl ConfigManager { .load_thread_agnostic_config() .await .map_err(|err| ConfigManagerError::io("failed to load configuration", err))?; - let user_layer = match layers.get_user_layer() { + let user_layer = match layers.get_active_user_layer() { Some(layer) => Cow::Borrowed(layer), None => Cow::Owned(create_empty_user_layer(&allowed_path).await?), }; @@ -305,7 +306,7 @@ impl ConfigManager { })?; if !config_edits.is_empty() { - ConfigEditsBuilder::new(self.codex_home()) + ConfigEditsBuilder::for_config_path(provided_path.as_path()) .with_edits(config_edits) .apply() .await @@ -321,7 +322,7 @@ impl ConfigManager { Ok(ConfigWriteResponse { status, version: updated_layers - .get_user_layer() + .get_active_user_layer() .ok_or_else(|| { ConfigManagerError::write( ConfigWriteErrorCode::UserLayerNotFound, @@ -375,6 +376,7 @@ async fn create_empty_user_layer( Ok(ConfigLayerEntry::new( ConfigLayerSource::User { file: config_toml.clone(), + profile: None, }, toml_value, )) @@ -574,7 +576,7 @@ fn override_message(layer: &ConfigLayerSource) -> String { dot_codex_folder.display(), ), ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(), - ConfigLayerSource::User { file } => { + ConfigLayerSource::User { file, .. } => { format!("Overridden by user config: {}", file.display()) } ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { @@ -594,7 +596,7 @@ fn compute_override_metadata( effective: &TomlValue, segments: &[String], ) -> Option { - let user_value = match layers.get_user_layer() { + let user_value = match layers.get_active_user_layer() { Some(user_layer) => value_at_path(&user_layer.config, segments), None => return None, }; diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index e9b0b3c769..c1a081e023 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -293,7 +293,8 @@ async fn read_includes_origins_and_layers() { assert_eq!( layers.get(1).unwrap().name, ConfigLayerSource::User { - file: user_file.clone() + file: user_file.clone(), + profile: None, } ); assert!(matches!( @@ -454,6 +455,80 @@ async fn write_value_defaults_to_user_config_path() { ); } +#[tokio::test] +async fn write_value_defaults_to_selected_user_config_path() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap(); + let selected_path = tmp.path().join("work.config.toml"); + std::fs::write(&selected_path, "").unwrap(); + + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.user_config_path = + Some(AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path")); + loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + loader_overrides, + CloudRequirementsLoader::default(), + ); + service + .write_value(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + assert_eq!( + std::fs::read_to_string(&selected_path).expect("read selected config"), + "model = \"gpt-work\"\n" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read main config"), + "model = \"gpt-main\"" + ); +} + +#[tokio::test] +async fn load_default_config_preserves_selected_user_config_path_after_load_error() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap(); + let selected_path = tmp.path().join("work.config.toml"); + std::fs::write(&selected_path, "not valid toml").unwrap(); + let selected_file = + AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path"); + + let mut loader_overrides = + LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")); + loader_overrides.user_config_path = Some(selected_file.clone()); + loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + loader_overrides, + CloudRequirementsLoader::default(), + ); + + service + .load_latest_config(/*fallback_cwd*/ None) + .await + .expect_err("selected config should fail to load"); + let config = service + .load_default_config() + .await + .expect("default config loads after selected config error"); + + assert_eq!( + config.config_layer_stack.get_user_config_file(), + Some(&selected_file) + ); +} + #[tokio::test] async fn invalid_user_value_rejected_even_if_overridden_by_managed() { let tmp = tempdir().expect("tempdir"); @@ -665,7 +740,10 @@ async fn read_reports_managed_overrides_user_and_session_flags() { assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags); assert_eq!( layers.get(2).unwrap().name, - ConfigLayerSource::User { file: user_file } + ConfigLayerSource::User { + file: user_file, + profile: None + } ); } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 411685e7d8..a293daf4f2 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -17,7 +17,6 @@ where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::new(); - codex_git_attribution::install(&mut builder); codex_guardian::install(&mut builder, guardian_agent_spawner); Arc::new(builder.build()) } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 61d00f1d3e..e4996d7921 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -122,6 +122,8 @@ pub struct InProcessStartArgs { pub cli_overrides: Vec<(String, TomlValue)>, /// Loader override knobs used by config API paths. pub loader_overrides: LoaderOverrides, + /// Whether config API paths should reject unknown config fields. + pub strict_config: bool, /// Preloaded cloud requirements provider. pub cloud_requirements: CloudRequirementsLoader, /// Loader used to fetch typed thread config sources before a thread starts. @@ -412,6 +414,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult IoResult Arc IoResult<()> { - run_main_with_transport( + run_main_with_transport_options( arg0_paths, cli_config_overrides, loader_overrides, + strict_config, default_analytics_enabled, AppServerTransport::Stdio, SessionSource::VSCode, AppServerWebsocketAuthSettings::default(), + AppServerRuntimeOptions::default(), ) .await } @@ -409,33 +412,12 @@ impl Default for AppServerRuntimeOptions { } } -pub async fn run_main_with_transport( - arg0_paths: Arg0DispatchPaths, - cli_config_overrides: CliConfigOverrides, - loader_overrides: LoaderOverrides, - default_analytics_enabled: bool, - transport: AppServerTransport, - session_source: SessionSource, - auth: AppServerWebsocketAuthSettings, -) -> IoResult<()> { - run_main_with_transport_options( - arg0_paths, - cli_config_overrides, - loader_overrides, - default_analytics_enabled, - transport, - session_source, - auth, - AppServerRuntimeOptions::default(), - ) - .await -} - #[allow(clippy::too_many_arguments)] pub async fn run_main_with_transport_options( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, loader_overrides: LoaderOverrides, + strict_config: bool, default_analytics_enabled: bool, transport: AppServerTransport, session_source: SessionSource, @@ -472,6 +454,7 @@ pub async fn run_main_with_transport_options( codex_home.to_path_buf(), cli_kv_overrides.clone(), loader_overrides, + strict_config, Default::default(), arg0_paths.clone(), Arc::new(NoopThreadConfigLoader), @@ -500,6 +483,10 @@ pub async fn run_main_with_transport_options( { Ok(config) => (config, true), Err(err) => { + if strict_config { + return Err(err); + } + let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); ( @@ -528,9 +515,16 @@ pub async fn run_main_with_transport_options( })?; codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME); codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME); - let state_db_result = rollout_state_db::try_init(&config).await; - let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); - let state_db = state_db_result.ok(); + let state_db = match rollout_state_db::try_init(&config).await { + Ok(state_db) => Some(state_db), + Err(err) => { + let state_db_path = codex_state::state_db_path(config.sqlite_home.as_path()); + return Err(std::io::Error::other(format!( + "failed to initialize sqlite state db at {}: {err}", + state_db_path.display() + ))); + } + }; if should_run_personality_migration { let effective_toml = config.config_layer_stack.effective_config(); @@ -647,10 +641,6 @@ pub async fn run_main_with_transport_options( } } let installation_id = resolve_installation_id(&config.codex_home).await?; - if let Some(err) = &state_db_init_error { - error!("failed to initialize sqlite state db: {err}"); - } - let transport_shutdown_token = CancellationToken::new(); let mut transport_accept_handles = Vec::>::new(); @@ -807,6 +797,7 @@ pub async fn run_main_with_transport_options( auth_manager, installation_id, rpc_transport: analytics_rpc_transport(&transport), + remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, })); let mut thread_created_rx = processor.thread_created_receiver(); diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 31a017d17c..6f5ecabf90 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -39,6 +39,10 @@ struct AppServerArgs { #[command(flatten)] auth: AppServerWebsocketAuthArgs, + /// Fail if config.toml contains unknown configuration fields. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Hidden debug-only test hook used by integration tests that spawn the /// production app-server binary. #[cfg(debug_assertions)] @@ -52,7 +56,15 @@ struct AppServerArgs { fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move { - let args = AppServerArgs::parse(); + let AppServerArgs { + listen, + session_source, + auth, + strict_config, + #[cfg(debug_assertions)] + disable_plugin_startup_tasks_for_tests, + remote_control, + } = AppServerArgs::parse(); let loader_overrides = if disable_managed_config_from_debug_env() { LoaderOverrides::without_managed_config_for_tests() } else { @@ -60,20 +72,20 @@ fn main() -> anyhow::Result<()> { .map(LoaderOverrides::with_managed_config_path_for_tests) .unwrap_or_default() }; - let transport = args.listen; - let session_source = args.session_source; - let auth = args.auth.try_into_settings()?; + let transport = listen; + let auth = auth.try_into_settings()?; let mut runtime_options = AppServerRuntimeOptions::default(); #[cfg(debug_assertions)] - if args.disable_plugin_startup_tasks_for_tests { + if disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; } - runtime_options.remote_control_enabled = args.remote_control; + runtime_options.remote_control_enabled = remote_control; run_main_with_transport_options( arg0_paths, CliConfigOverrides::default(), loader_overrides, + strict_config, /*default_analytics_enabled*/ false, transport, session_source, diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 31a6f4afd8..f7d32b2ea8 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -207,6 +207,7 @@ mod tests { temp_dir.path().to_path_buf(), Vec::new(), LoaderOverrides::without_managed_config_for_tests(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), loader.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e2d2f9c213..050a07f99d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -30,6 +30,7 @@ use crate::request_processors::MarketplaceRequestProcessor; use crate::request_processors::McpRequestProcessor; use crate::request_processors::PluginRequestProcessor; use crate::request_processors::ProcessExecRequestProcessor; +use crate::request_processors::RemoteControlRequestProcessor; use crate::request_processors::SearchRequestProcessor; use crate::request_processors::ThreadGoalRequestProcessor; use crate::request_processors::ThreadRequestProcessor; @@ -42,6 +43,7 @@ use crate::skills_watcher::SkillsWatcher; use crate::thread_state::ConnectionCapabilities; use crate::thread_state::ThreadStateManager; use crate::transport::AppServerTransport; +use crate::transport::RemoteControlHandle; use async_trait::async_trait; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppServerRpcTransport; @@ -172,6 +174,7 @@ pub(crate) struct MessageProcessor { marketplace_processor: MarketplaceRequestProcessor, mcp_processor: McpRequestProcessor, plugin_processor: PluginRequestProcessor, + remote_control_processor: RemoteControlRequestProcessor, search_processor: SearchRequestProcessor, thread_goal_processor: ThreadGoalRequestProcessor, thread_processor: ThreadRequestProcessor, @@ -264,6 +267,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) auth_manager: Arc, pub(crate) installation_id: String, pub(crate) rpc_transport: AppServerRpcTransport, + pub(crate) remote_control_handle: Option, pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, } @@ -286,6 +290,7 @@ impl MessageProcessor { auth_manager, installation_id, rpc_transport, + remote_control_handle, plugin_startup_tasks, } = args; auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { @@ -386,6 +391,7 @@ impl MessageProcessor { config_manager.clone(), workspace_settings_cache, ); + let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle); let search_processor = SearchRequestProcessor::new(outgoing.clone()); let thread_goal_processor = ThreadGoalRequestProcessor::new( Arc::clone(&thread_manager), @@ -484,6 +490,7 @@ impl MessageProcessor { marketplace_processor, mcp_processor, plugin_processor, + remote_control_processor, search_processor, thread_goal_processor, thread_processor, @@ -882,6 +889,14 @@ impl MessageProcessor { .experimental_feature_enablement_set(request_id.clone(), params) .await } + ClientRequest::RemoteControlEnable { .. } => self + .remote_control_processor + .enable() + .map(|response| Some(response.into())), + ClientRequest::RemoteControlDisable { .. } => self + .remote_control_processor + .disable() + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 452991270a..c955d06ba2 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -239,6 +239,7 @@ async fn build_test_processor( config.codex_home.to_path_buf(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), Arc::new(codex_config::NoopThreadConfigLoader), @@ -264,6 +265,7 @@ async fn build_test_processor( auth_manager, installation_id: "11111111-1111-4111-8111-111111111111".to_string(), rpc_transport: AppServerRpcTransport::Stdio, + remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, })); (processor, outgoing_rx) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 1a966bd496..376dfd329b 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -443,6 +443,7 @@ mod command_exec_processor; mod config_processor; mod environment_processor; mod external_agent_config_processor; +mod feedback_doctor_report; mod feedback_processor; mod fs_processor; mod git_processor; @@ -451,6 +452,7 @@ mod marketplace_processor; mod mcp_processor; mod plugins; mod process_exec_processor; +mod remote_control_processor; mod search; mod thread_processor; mod token_usage_replay; @@ -472,6 +474,7 @@ pub(crate) use marketplace_processor::MarketplaceRequestProcessor; pub(crate) use mcp_processor::McpRequestProcessor; pub(crate) use plugins::PluginRequestProcessor; pub(crate) use process_exec_processor::ProcessExecRequestProcessor; +pub(crate) use remote_control_processor::RemoteControlRequestProcessor; pub(crate) use search::SearchRequestProcessor; pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor; pub(crate) use thread_processor::ThreadRequestProcessor; diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs new file mode 100644 index 0000000000..3bd9c9fd9b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -0,0 +1,212 @@ +//! Builds a redacted doctor report attachment for feedback uploads. +//! +//! Feedback upload should never depend on doctor succeeding. This module runs +//! the configured Codex executable as a subprocess, accepts only valid JSON from +//! `codex doctor --json`, derives a small set of Sentry tags, and otherwise +//! skips the attachment with a warning. Keeping the report generation out of the +//! app-server process avoids sharing doctor internals across crates while still +//! attaching exactly the same JSON a user could copy from the CLI. + +use std::collections::BTreeMap; +use std::time::Duration; + +use codex_core::config::Config; +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; +use codex_feedback::FeedbackAttachment; +use serde_json::Value; +use tokio::process::Command; +use tokio::time::timeout; +use tracing::warn; + +const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25); +const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256; + +/// Redacted doctor report data that can be merged into a feedback upload. +pub(crate) struct DoctorFeedbackReport { + /// JSON support report to upload as `codex-doctor-report.json`. + pub(crate) attachment: FeedbackAttachment, + /// Low-cardinality Sentry tags derived from the report status and check ids. + pub(crate) tags: BTreeMap, +} + +/// Runs `codex doctor --json` and returns a best-effort feedback attachment. +/// +/// Failure to spawn Codex, finish before the timeout, or parse JSON means the +/// feedback upload proceeds without the doctor report. Callers should merge the +/// returned tags without overriding explicit client-provided tags. +pub(crate) async fn doctor_feedback_report(config: &Config) -> Option { + let executable = config + .codex_self_exe + .clone() + .or_else(|| std::env::current_exe().ok())?; + + let mut command = Command::new(&executable); + command.arg("doctor").arg("--json"); + command.kill_on_drop(/*kill_on_drop*/ true); + let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(err)) => { + warn!( + executable = %executable.display(), + error = %err, + "failed to run doctor report for feedback; skipping attachment" + ); + return None; + } + Err(_) => { + warn!( + executable = %executable.display(), + "timed out running doctor report for feedback; skipping attachment" + ); + return None; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(json_start) = stdout.find('{') else { + warn!( + executable = %executable.display(), + status = %output.status, + stderr = %String::from_utf8_lossy(&output.stderr), + "doctor report for feedback did not produce JSON; skipping attachment" + ); + return None; + }; + let json = stdout[json_start..].trim(); + let report: Value = match serde_json::from_str(json) { + Ok(report) => report, + Err(err) => { + warn!( + executable = %executable.display(), + status = %output.status, + error = %err, + "doctor report for feedback was not valid JSON; skipping attachment" + ); + return None; + } + }; + + let pretty = serde_json::to_vec_pretty(&report).unwrap_or_else(|_| json.as_bytes().to_vec()); + Some(DoctorFeedbackReport { + tags: doctor_report_tags(&report), + attachment: FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: pretty, + }, + }) +} + +fn doctor_report_tags(report: &Value) -> BTreeMap { + let mut tags = BTreeMap::new(); + if let Some(overall_status) = report.get("overallStatus").and_then(Value::as_str) { + tags.insert( + "doctor_overall_status".to_string(), + truncate_tag_value(overall_status), + ); + } + + let mut ok_count = 0usize; + let mut warning_count = 0usize; + let mut fail_count = 0usize; + let mut failed_checks = Vec::new(); + let mut warning_checks = Vec::new(); + if let Some(checks) = report.get("checks") { + for check in check_values(checks) { + let status = check.get("status").and_then(Value::as_str); + let id = check.get("id").and_then(Value::as_str).unwrap_or("unknown"); + match status { + Some("ok") => ok_count += 1, + Some("warning") => { + warning_count += 1; + warning_checks.push(id.to_string()); + } + Some("fail") => { + fail_count += 1; + failed_checks.push(id.to_string()); + } + _ => {} + } + } + } + tags.insert("doctor_ok_count".to_string(), ok_count.to_string()); + tags.insert( + "doctor_warning_count".to_string(), + warning_count.to_string(), + ); + tags.insert("doctor_fail_count".to_string(), fail_count.to_string()); + if !failed_checks.is_empty() { + tags.insert( + "doctor_failed_checks".to_string(), + truncate_tag_value(&failed_checks.join(",")), + ); + } + if !warning_checks.is_empty() { + tags.insert( + "doctor_warning_checks".to_string(), + truncate_tag_value(&warning_checks.join(",")), + ); + } + + tags +} + +/// Iterates checks from both the current keyed JSON shape and older array reports. +fn check_values(checks: &Value) -> Box + '_> { + match checks { + Value::Array(values) => Box::new(values.iter()), + Value::Object(values) => Box::new(values.values()), + _ => Box::new(std::iter::empty()), + } +} + +fn truncate_tag_value(value: &str) -> String { + if value.chars().count() <= MAX_DOCTOR_TAG_VALUE_LEN { + return value.to_string(); + } + let prefix = value + .chars() + .take(MAX_DOCTOR_TAG_VALUE_LEN.saturating_sub(3)) + .collect::(); + format!("{prefix}...") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn doctor_report_tags_summarize_status_counts() { + let report = json!({ + "overallStatus": "fail", + "checks": { + "runtime.provenance": {"id": "runtime.provenance", "status": "ok"}, + "websocket.reachability": { + "id": "websocket.reachability", + "status": "warning" + }, + "auth.credentials": {"id": "auth.credentials", "status": "fail"} + } + }); + + let tags = doctor_report_tags(&report); + + let expected = BTreeMap::from([ + ("doctor_fail_count".to_string(), "1".to_string()), + ( + "doctor_failed_checks".to_string(), + "auth.credentials".to_string(), + ), + ("doctor_ok_count".to_string(), "1".to_string()), + ("doctor_overall_status".to_string(), "fail".to_string()), + ( + "doctor_warning_checks".to_string(), + "websocket.reachability".to_string(), + ), + ("doctor_warning_count".to_string(), "1".to_string()), + ]); + assert_eq!(tags, expected); + } +} diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 5b9039b57d..18f519d3c0 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -56,6 +56,7 @@ impl FeedbackRequestProcessor { extra_log_files, tags, } = params; + let mut upload_tags = tags.unwrap_or_default(); let conversation_id = match thread_id.as_deref() { Some(thread_id) => match ThreadId::from_string(thread_id) { @@ -197,14 +198,27 @@ impl FeedbackRequestProcessor { } } + let mut extra_attachments = Vec::new(); + if include_logs + && let Some(doctor_report) = + super::feedback_doctor_report::doctor_feedback_report(&self.config).await + { + extra_attachments.push(doctor_report.attachment); + for (key, value) in doctor_report.tags { + upload_tags.entry(key).or_insert(value); + } + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { + let tags = (!upload_tags.is_empty()).then_some(&upload_tags); snapshot.upload_feedback(FeedbackUploadOptions { classification: &classification, reason: reason.as_deref(), - tags: tags.as_ref(), + tags, include_logs, + extra_attachments: &extra_attachments, extra_attachment_paths: &attachment_paths, session_source: Some(session_source), logs_override: sqlite_feedback_logs, diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index c33af189cf..a40007db11 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -13,7 +13,7 @@ use super::*; use crate::message_processor::ConnectionSessionState; use crate::message_processor::InitializedConnectionSessionState; -const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon"; +const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "codex-backend"]; #[derive(Clone)] pub(crate) struct InitializeRequestProcessor { @@ -92,7 +92,7 @@ impl InitializeRequestProcessor { } let originator = name.clone(); let user_agent_suffix = format!("{name}; {version}"); - let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME; + let mutates_global_identity = !NON_ORIGINATING_CLIENT_NAMES.contains(&name.as_str()); let codex_home = self.config.codex_home.clone(); if session .initialize(InitializedConnectionSessionState { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 6980945ae2..ea1ff3ead4 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -450,14 +450,15 @@ impl PluginRequestProcessor { return Ok(empty_response()); } let plugins_input = config.plugins_config_input(); - let (mut data, marketplace_load_errors) = if include_local { + if include_local || marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) { plugins_manager.maybe_start_plugin_list_background_tasks_for_config( &plugins_input, auth.clone(), &roots, Some(self.effective_plugins_changed_callback()), ); - + } + let (mut data, marketplace_load_errors) = if include_local { let config_for_marketplace_listing = plugins_input.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?; @@ -916,6 +917,9 @@ impl PluginRequestProcessor { params: PluginShareUpdateTargetsParams, ) -> Result { let (config, auth) = self.load_plugin_share_config_and_auth().await?; + if !config.features.enabled(Feature::PluginSharing) { + return Err(invalid_request("plugin sharing is disabled")); + } let PluginShareUpdateTargetsParams { remote_plugin_id, discoverability, diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs new file mode 100644 index 0000000000..41a124e94b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -0,0 +1,43 @@ +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::transport::RemoteControlHandle; +use crate::transport::RemoteControlUnavailable; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RemoteControlDisableResponse; +use codex_app_server_protocol::RemoteControlEnableResponse; + +#[derive(Clone)] +pub(crate) struct RemoteControlRequestProcessor { + remote_control_handle: Option, +} + +impl RemoteControlRequestProcessor { + pub(crate) fn new(remote_control_handle: Option) -> Self { + Self { + remote_control_handle, + } + } + + pub(crate) fn enable(&self) -> Result { + let handle = self.handle()?; + handle + .enable() + .map(RemoteControlEnableResponse::from) + .map_err(map_unavailable) + } + + pub(crate) fn disable(&self) -> Result { + let handle = self.handle()?; + Ok(RemoteControlDisableResponse::from(handle.disable())) + } + + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { + self.remote_control_handle + .as_ref() + .ok_or_else(|| internal_error("remote control is unavailable for this app-server")) + } +} + +fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError { + invalid_request(err.to_string()) +} diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 46fb8ed80d..92da7cdd8f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1,5 +1,7 @@ use super::*; use crate::error_code::method_not_found; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; @@ -3903,7 +3905,9 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) if matches!( overrides.default_permissions.as_deref(), - Some(":workspace" | ":danger-no-sandbox") + Some( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE | BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS + ) ) { return true; } diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 0929686c1b..be5f707a11 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -65,6 +65,9 @@ mod thread_processor_behavior_tests { use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -470,14 +473,16 @@ mod thread_processor_behavior_tests { )); assert!(requested_permissions_trust_project( &ConfigOverrides { - default_permissions: Some(":workspace".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), ..Default::default() }, cwd.as_path() )); assert!(requested_permissions_trust_project( &ConfigOverrides { - default_permissions: Some(":danger-no-sandbox".to_string()), + default_permissions: Some( + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string() + ), ..Default::default() }, cwd.as_path() @@ -491,7 +496,7 @@ mod thread_processor_behavior_tests { )); assert!(!requested_permissions_trust_project( &ConfigOverrides { - default_permissions: Some(":read-only".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), ..Default::default() }, cwd.as_path() @@ -585,6 +590,7 @@ mod thread_processor_behavior_tests { temp_dir.path().to_path_buf(), Vec::new(), LoaderOverrides::default(), + /*strict_config*/ false, CloudRequirementsLoader::default(), Arg0DispatchPaths::default(), Arc::new(StaticThreadConfigLoader::new(vec![ diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 3d18cf321b..f7222bd28f 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -18,7 +18,9 @@ pub(crate) use codex_app_server_transport::ConnectionId; pub(crate) use codex_app_server_transport::ConnectionOrigin; pub(crate) use codex_app_server_transport::OutgoingMessage; pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; +pub(crate) use codex_app_server_transport::RemoteControlHandle; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; +pub(crate) use codex_app_server_transport::RemoteControlUnavailable; pub(crate) use codex_app_server_transport::TransportEvent; pub use codex_app_server_transport::app_server_control_socket_path; pub use codex_app_server_transport::auth; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3319b07727..1aac0d63e7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -580,6 +580,18 @@ impl McpProcess { .await } + /// Send a `remoteControl/enable` JSON-RPC request. + pub async fn send_remote_control_enable_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/enable", /*params*/ None) + .await + } + + /// Send a `remoteControl/disable` JSON-RPC request. + pub async fn send_remote_control_disable_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/disable", /*params*/ None) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 754d1f9467..5253850107 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -149,6 +149,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index c60f4a32a1..bac309a930 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -1,4 +1,5 @@ mod auth; mod conversation_summary; mod fuzzy_file_search; +mod strict_config; mod v2; diff --git a/codex-rs/app-server/tests/suite/strict_config.rs b/codex-rs/app-server/tests/suite/strict_config.rs new file mode 100644 index 0000000000..93784c9752 --- /dev/null +++ b/codex-rs/app-server/tests/suite/strict_config.rs @@ -0,0 +1,33 @@ +use std::process::Command; + +use anyhow::Result; +use tempfile::TempDir; + +#[test] +fn strict_config_rejects_unknown_config_fields_for_standalone_app_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?) + .env("CODEX_HOME", codex_home.path()) + .env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.path().join("managed_config.toml"), + ) + .args(["--strict-config", "--listen", "off"]) + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("unknown configuration field `foo`"), + "expected strict config error in stderr, got: {stderr}" + ); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 211cec9355..2a4b8435ef 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -306,7 +306,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu ); assert!( !codex_home.path().join("parent.txt").exists(), - "permissionProfile :project_roots write should not grant the server cwd when command cwd differs" + "permissionProfile :workspace_roots write should not grant the server cwd when command cwd differs" ); Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index b5f795740c..2cbf476f1a 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -82,6 +82,7 @@ sandbox_mode = "workspace-write" origins.get("model").expect("origin").name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); let layers = layers.expect("layers present"); @@ -101,9 +102,6 @@ model = "gpt-user" [tools.web_search] context_size = "low" allowed_domains = ["example.com"] - -[tools] -view_image = false "#, )?; let codex_home_path = codex_home.path().canonicalize()?; @@ -138,7 +136,6 @@ view_image = false allowed_domains: Some(vec!["example.com".to_string()]), location: None, }), - view_image: Some(false), } ); assert_eq!( @@ -148,6 +145,7 @@ view_image = false .name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); assert_eq!( @@ -157,15 +155,9 @@ view_image = false .name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); - assert_eq!( - origins.get("tools.view_image").expect("origin").name, - ConfigLayerSource::User { - file: user_file.clone(), - } - ); - let layers = layers.expect("layers present"); assert_layers_user_then_optional_system(&layers, user_file)?; @@ -308,6 +300,7 @@ default_tools_approval_mode = "prompt" origins.get("apps.app1.enabled").expect("origin").name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); assert_eq!( @@ -317,6 +310,7 @@ default_tools_approval_mode = "prompt" .name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); assert_eq!( @@ -326,6 +320,7 @@ default_tools_approval_mode = "prompt" .name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); @@ -470,6 +465,7 @@ writable_roots = [{}] origins.get("sandbox_mode").expect("origin").name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); @@ -496,6 +492,7 @@ writable_roots = [{}] .name, ConfigLayerSource::User { file: user_file.clone(), + profile: None, } ); @@ -739,7 +736,10 @@ fn assert_layers_user_then_optional_system( assert_eq!(layers.len(), first_index + 2); assert_eq!( layers[first_index].name, - ConfigLayerSource::User { file: user_file } + ConfigLayerSource::User { + file: user_file, + profile: None + } ); assert!(matches!( layers[first_index + 1].name, @@ -767,7 +767,10 @@ fn assert_layers_managed_user_then_optional_system( ); assert_eq!( layers[first_index + 1].name, - ConfigLayerSource::User { file: user_file } + ConfigLayerSource::User { + file: user_file, + profile: None + } ); assert!(matches!( layers[first_index + 2].name, diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 3d3a473fab..47cacdb20e 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -89,6 +89,33 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> { Ok(()) } +#[tokio::test] +async fn initialize_codex_backend_does_not_override_originator() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex-backend".to_string(), + title: Some("Codex Backend".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { user_agent, .. } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_cli_rs/")); + Ok(()) +} + #[tokio::test] async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index a51f4bbd4e..bddf57f666 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -200,6 +200,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index cc0a1ed496..c5d0320c6f 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -38,6 +38,7 @@ mod plugin_uninstall; mod process_exec; mod rate_limits; mod realtime_conversation; +mod remote_control; #[cfg(debug_assertions)] mod remote_thread_store; mod request_permissions; 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 0b2200a9d5..4ffa3893d0 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1360,7 +1360,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; - write_remote_plugin_catalog_config( + write_plugins_enabled_config_with_base_url( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; @@ -1823,6 +1823,18 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { /*enabled*/ None, ))?; shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null; + let shared_unlisted_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_44444444444444444444444444444444", + "shared-unlisted-linear", + "Shared Unlisted Linear", + "UNLISTED", + /*enabled*/ None, + ))?; + shared_plugin_body["plugins"] + .as_array_mut() + .expect("shared plugins should be an array") + .push(shared_unlisted_body["plugins"][0].clone()); let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?; let mut workspace_installed_body: serde_json::Value = serde_json::from_str(&workspace_remote_plugin_page_body( @@ -1846,6 +1858,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { .push(unlisted_installed_body["plugins"][0].clone()); let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; mount_shared_workspace_plugins(&server, &shared_plugin_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -1878,10 +1891,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { .and_then(|interface| interface.display_name.as_deref()), Some("Shared with me") ); - assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins.len(), 2); assert_eq!( marketplace.plugins[0].id, - "shared-linear@workspace-shared-with-me-private" + "shared-linear@workspace-shared-with-me" ); assert_eq!( marketplace.plugins[0].remote_plugin_id.as_deref(), @@ -1913,6 +1926,29 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("https://chatgpt.example/plugins/share/share-key-1") ); assert_eq!(share_context.share_principals, None); + assert_eq!( + marketplace.plugins[1].id, + "shared-unlisted-linear@workspace-shared-with-me" + ); + assert_eq!( + marketplace.plugins[1].remote_plugin_id.as_deref(), + Some("plugins~Plugin_44444444444444444444444444444444") + ); + assert_eq!(marketplace.plugins[1].name, "shared-unlisted-linear"); + assert_eq!(marketplace.plugins[1].installed, false); + assert_eq!(marketplace.plugins[1].enabled, false); + let share_context = marketplace.plugins[1] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_44444444444444444444444444444444" + ); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Unlisted) + ); let marketplace = response .marketplaces @@ -1929,7 +1965,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { assert_eq!(marketplace.plugins.len(), 1); assert_eq!( marketplace.plugins[0].id, - "unlisted-linear@workspace-shared-with-me-unlisted" + "unlisted-linear@workspace-shared-with-me" ); assert_eq!( marketplace.plugins[0].remote_plugin_id.as_deref(), @@ -1951,6 +1987,12 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.discoverability, Some(PluginShareDiscoverability::Unlisted) ); + wait_for_remote_plugin_request_count( + &server, + "/ps/plugins/installed", + /*expected_count*/ 5, + ) + .await?; wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 525a4234aa..812f884a55 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -300,74 +300,76 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_plugin_read_request(PluginReadParams { - marketplace_path: None, - remote_marketplace_name: Some("workspace-shared-with-me-private".to_string()), - plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(), - }) - .await?; + for remote_marketplace_name in [ + "workspace-shared-with-me-private", + "workspace-shared-with-me", + ] { + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some(remote_marketplace_name.to_string()), + plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(), + }) + .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginReadResponse = to_response(response)?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; - assert_eq!( - response.plugin.marketplace_name, - "workspace-shared-with-me-private" - ); - assert_eq!( - response.plugin.summary.id, - "shared-linear@workspace-shared-with-me-private" - ); - assert_eq!( - response.plugin.summary.remote_plugin_id.as_deref(), - Some("plugins~Plugin_11111111111111111111111111111111") - ); - let share_context = response - .plugin - .summary - .share_context - .as_ref() - .expect("expected share context"); - assert_eq!( - share_context.remote_plugin_id, - "plugins~Plugin_11111111111111111111111111111111" - ); - assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4")); - assert_eq!( - share_context.discoverability, - Some(PluginShareDiscoverability::Private) - ); - assert_eq!( - share_context.creator_account_user_id.as_deref(), - Some("user-gavin__account-123") - ); - assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); - assert_eq!( - share_context.share_url.as_deref(), - Some("https://chatgpt.example/plugins/share/share-key-1") - ); - assert_eq!( - share_context.share_principals, - Some(vec![ - PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-gavin__account-123".to_string(), - role: PluginSharePrincipalRole::Owner, - name: "Gavin".to_string(), - }, - PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-ada__account-123".to_string(), - role: PluginSharePrincipalRole::Reader, - name: "Ada".to_string(), - }, - ]) - ); + assert_eq!(response.plugin.marketplace_name, "workspace-shared-with-me"); + assert_eq!( + response.plugin.summary.id, + "shared-linear@workspace-shared-with-me" + ); + assert_eq!( + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_11111111111111111111111111111111") + ); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_11111111111111111111111111111111" + ); + assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!( + share_context.share_principals, + Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-gavin__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Gavin".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-ada__account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Ada".to_string(), + }, + ]) + ); + } Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index efe3d291a4..69f93f0b7e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -169,7 +169,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@workspace-shared-with-me-private".to_string(), + id: "demo-plugin@workspace-shared-with-me".to_string(), remote_plugin_id: Some("plugins_123".to_string()), local_version: None, name: "demo-plugin".to_string(), @@ -573,7 +573,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@workspace-shared-with-me-private".to_string(), + id: "demo-plugin@workspace-shared-with-me".to_string(), remote_plugin_id: Some("plugins_123".to_string()), local_version: None, name: "demo-plugin".to_string(), @@ -1038,6 +1038,57 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_update_targets_rejects_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "discoverability": "UNLISTED", + "shareTargets": [], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "plugin sharing is disabled"); + Ok(()) +} + #[tokio::test] async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { let codex_home = TempDir::new()?; @@ -1123,7 +1174,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@workspace-shared-with-me-private".to_string(), + id: "demo-plugin@workspace-shared-with-me".to_string(), remote_plugin_id: Some("plugins_123".to_string()), local_version: None, name: "demo-plugin".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs new file mode 100644 index 0000000000..ae6bc9013f --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -0,0 +1,54 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlDisableResponse; +use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RequestId; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn remote_control_disable_returns_disabled_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_disable_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlDisableResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn remote_control_enable_returns_connecting_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlEnableResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index e5c0b2c53f..9787868f5c 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -75,6 +75,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index d6267b8fc0..fa143254a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -375,6 +375,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -440,6 +441,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), @@ -525,6 +527,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index dfc79e9d48..d2f8f268a2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -245,6 +245,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, + strict_config: false, cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 524b795b81..62192ebd08 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -62,6 +62,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; @@ -780,7 +781,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text_elements: Vec::new(), }], permissions: Some(PermissionProfileSelectionParams::Profile { - id: ":danger-no-sandbox".to_string(), + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), modifications: None, }), ..Default::default() @@ -1895,8 +1896,8 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![first_cwd.try_into()?], network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }), permissions: None, model: Some("mock-model".to_string()), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 31247418e5..6fe93e36af 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -537,8 +537,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![workspace.clone().try_into()?], network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 05d8186686..372f62e696 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -6,6 +6,9 @@ use anyhow::Context; use serde::de::DeserializeOwned; use std::time::Duration; +const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; +const CODEX_PRODUCT_SKU: &str = "codex"; + /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, @@ -46,6 +49,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout( let mut request = client .get(&url) .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU) .header("Content-Type", "application/json"); if let Some(timeout) = timeout { diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 120d2e497a..627d5d16d1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } @@ -36,11 +37,14 @@ codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-install-context = { workspace = true } codex-login = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } +codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } +codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } @@ -52,18 +56,23 @@ codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +crossterm = { workspace = true } +http = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", + "net", "process", "rt-multi-thread", "signal", + "time", ] } toml = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 2722f69849..30c60d7b95 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -190,6 +190,7 @@ async fn run_command_under_sandbox( .map_err(anyhow::Error::msg)?, codex_linux_sandbox_exe, config_options, + /*strict_config*/ false, ) .await?; @@ -636,12 +637,14 @@ async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, + strict_config: bool, ) -> anyhow::Result { load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, options, /*codex_home*/ None, + strict_config, ) .await } @@ -651,6 +654,7 @@ async fn load_debug_sandbox_config_with_codex_home( codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, + strict_config: bool, ) -> anyhow::Result { let DebugSandboxConfigOptions { permissions_profile, @@ -680,6 +684,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + strict_config, ) .await?; @@ -697,6 +702,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + strict_config, ) .await .map_err(Into::into) @@ -707,14 +713,16 @@ async fn build_debug_sandbox_config( harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() .cli_overrides(cli_overrides) - .harness_overrides(harness_overrides); - if let ManagedRequirementsMode::Ignore = managed_requirements_mode { + .harness_overrides(harness_overrides) + .strict_config(strict_config); + if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { builder = builder.loader_overrides(LoaderOverrides { ignore_managed_requirements: true, - ..Default::default() + ..LoaderOverrides::default() }); } if let Some(codex_home) = codex_home { @@ -783,6 +791,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; let legacy_config = build_debug_sandbox_config( @@ -793,6 +802,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -805,6 +815,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -840,6 +851,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; let read_only_config = build_debug_sandbox_config( @@ -850,6 +862,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -862,6 +875,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -905,6 +919,7 @@ mod tests { }, Some(codex_home_path.clone()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -917,6 +932,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Include, }, Some(codex_home_path), + /*strict_config*/ false, ) .await?; @@ -942,6 +958,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -975,6 +992,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -1004,6 +1022,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; @@ -1015,6 +1034,7 @@ mod tests { ConfigOverrides::default(), Some(codex_home.path().to_path_buf()), ManagedRequirementsMode::Include, + /*strict_config*/ false, ) .await?; @@ -1040,6 +1060,7 @@ mod tests { managed_requirements_mode: ManagedRequirementsMode::Ignore, }, Some(codex_home.path().to_path_buf()), + /*strict_config*/ false, ) .await?; diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs new file mode 100644 index 0000000000..a3742baa72 --- /dev/null +++ b/codex-rs/cli/src/doctor.rs @@ -0,0 +1,3844 @@ +//! Implements the `codex doctor` diagnostic report. +//! +//! Doctor is intentionally read-mostly: checks inspect the current installation, +//! configuration, authentication, terminal, state paths, and bounded reachability +//! probes without attempting repair or starting long-lived services. Each check +//! returns a redacted, serializable row so the same data can back the human +//! summary and `--json` support report. +//! +//! A failing check should describe the problem and remediation, but it should not +//! mutate user state. That keeps the command safe to run before filing a support +//! issue or while diagnosing a broken local installation. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::env; +use std::ffi::OsStr; +use std::future::Future; +use std::io::IsTerminal; +use std::io::Read; +use std::net::IpAddr; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use clap::Parser; +use codex_api::ApiError; +use codex_api::ResponsesWebsocketClient; +use codex_api::is_azure_responses_provider; +use codex_arg0::Arg0DispatchPaths; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; +use codex_core::config::find_codex_home; +use codex_features::FEATURES; +use codex_install_context::InstallContext; +use codex_install_context::StandalonePlatform; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; +use codex_login::CODEX_API_KEY_ENV_VAR; +use codex_login::CodexAuth; +use codex_login::OPENAI_API_KEY_ENV_VAR; +use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::default_headers; +use codex_login::load_auth_dot_json; +use codex_model_provider::create_model_provider; +use codex_protocol::protocol::AskForApproval; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; +use codex_tui::Cli as TuiCli; +use codex_utils_cli::CliConfigOverrides; +use http::HeaderMap; +use http::HeaderValue; +use serde::Serialize; +use supports_color::Stream; + +mod background; +mod output; +mod progress; +mod runtime; +mod updates; + +use background::background_server_check; +use output::HumanOutputOptions; +use output::redact_detail; +use output::render_human_report; +use progress::DoctorProgress; +use progress::doctor_progress; +use runtime::runtime_check; +use runtime::search_check; +use updates::updates_check; + +const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const WEBSOCKET_IMMEDIATE_CLOSE_GRACE: Duration = Duration::from_millis(250); +const SLOW_CHECK_PROGRESS_THRESHOLD: Duration = Duration::from_secs(2); +const SLOW_CHECK_PROGRESS_INTERVAL: Duration = Duration::from_secs(1); +const PROXY_ENV_VARS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +]; +const COLOR_ENV_VARS: &[&str] = &[ + "COLORTERM", + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "FORCE_COLOR", + "COLORFGBG", +]; +const TERMINAL_DIMENSION_ENV_VARS: &[&str] = &["COLUMNS", "LINES"]; +const TERMINFO_ENV_VARS: &[&str] = &["TERMINFO", "TERMINFO_DIRS"]; +const LOCALE_ENV_VARS: &[&str] = &["LC_ALL", "LC_CTYPE", "LANG"]; +const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[ + "SSH_TTY", + "SSH_CONNECTION", + "SSH_CLIENT", + "MOSH_IP", + "WSL_DISTRO_NAME", + "WSL_INTEROP", + "VSCODE_INJECTION", + "VSCODE_IPC_HOOK_CLI", + "WAYLAND_DISPLAY", + "DISPLAY", + "WT_SESSION", +]; +const TMUX_OPTION_NAMES: &[&str] = &[ + "extended-keys", + "xterm-keys", + "allow-passthrough", + "set-clipboard", + "focus-events", +]; +const NARROW_TERMINAL_COLUMNS: u16 = 80; +const NARROW_TERMINAL_ROWS: u16 = 24; + +/// Options for building a local Codex diagnostic report. +/// +/// The command always runs the full bounded diagnostic set. Human output includes +/// detailed diagnostics by default; --summary keeps the terminal output compact. +#[derive(Debug, Parser)] +pub struct DoctorCommand { + /// Emit a redacted machine-readable report. + #[arg(long, default_value_t = false)] + json: bool, + + /// Only show grouped check rows and the final count summary. + #[arg(long, default_value_t = false)] + summary: bool, + + /// Expand long lists in detailed human output. + #[arg(long, default_value_t = false)] + all: bool, + + /// Disable ANSI color in human output. + #[arg(long, default_value_t = false)] + no_color: bool, + + /// Use ASCII status labels and separators in human output. + #[arg(long, default_value_t = false)] + ascii: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +enum CheckStatus { + Ok, + Warning, + Fail, +} + +/// Machine-readable doctor output shared by human and JSON renderers. +/// +/// The schema is intentionally flat: each check carries its own category, +/// status, details, remediation, and duration so support tooling can filter or +/// redact individual rows without understanding the renderer's section layout. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorReport { + schema_version: u32, + generated_at: String, + overall_status: CheckStatus, + codex_version: String, + checks: Vec, +} + +/// One diagnostic result in the doctor report. +/// +/// Summaries are safe for compact human output. Details may include local paths +/// or command output and are redacted before rendering or JSON serialization. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorCheck { + id: String, + category: String, + status: CheckStatus, + summary: String, + details: Vec, + issues: Vec, + remediation: Option, + duration_ms: u64, +} + +/// Structured cause/remedy metadata for a non-ok doctor check. +/// +/// Human output uses issues to make warnings and failures self-explanatory: +/// the row headline says what is wrong, matching detail rows show measured vs. +/// expected values, and remedies are printed as explicit next actions. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorIssue { + severity: CheckStatus, + cause: String, + measured: Option, + expected: Option, + remedy: Option, + fields: Vec, +} + +impl DoctorIssue { + fn new(severity: CheckStatus, cause: impl Into) -> Self { + Self { + severity, + cause: cause.into(), + measured: None, + expected: None, + remedy: None, + fields: Vec::new(), + } + } + + fn measured(mut self, measured: impl Into) -> Self { + self.measured = Some(measured.into()); + self + } + + fn expected(mut self, expected: impl Into) -> Self { + self.expected = Some(expected.into()); + self + } + + fn remedy(mut self, remedy: impl Into) -> Self { + self.remedy = Some(remedy.into()); + self + } + + fn field(mut self, field: impl Into) -> Self { + self.fields.push(field.into()); + self + } +} + +impl DoctorCheck { + fn new( + id: impl Into, + category: impl Into, + status: CheckStatus, + summary: impl Into, + ) -> Self { + Self { + id: id.into(), + category: category.into(), + status, + summary: summary.into(), + details: Vec::new(), + issues: Vec::new(), + remediation: None, + duration_ms: 0, + } + } + + fn detail(mut self, detail: impl Into) -> Self { + self.details.push(detail.into()); + self + } + + fn details(mut self, details: Vec) -> Self { + self.details.extend(details); + self + } + + fn remediation(mut self, remediation: impl Into) -> Self { + self.remediation = Some(remediation.into()); + self + } + + fn issue(mut self, issue: DoctorIssue) -> Self { + self.issues.push(issue); + self + } +} + +/// Builds, renders, and exits according to the current doctor report. +/// +/// This is the CLI entry point for codex doctor. It does not repair issues; +/// failures are represented in the report and cause a non-zero process exit so +/// scripts can distinguish a clean environment from one that needs attention. +pub async fn run_doctor( + command: DoctorCommand, + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result<()> { + let report = build_report(&command, root_config_overrides, interactive, arg0_paths).await; + + if command.json { + println!( + "{}", + serde_json::to_string_pretty(&redacted_json_report(&report))? + ); + } else { + print!( + "{}", + render_human_report(&report, human_output_options(&command)) + ); + } + + if report.overall_status == CheckStatus::Fail { + std::process::exit(1); + } + + Ok(()) +} + +async fn build_report( + command: &DoctorCommand, + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> DoctorReport { + let progress = doctor_progress(command.json); + let mut checks = Vec::new(); + checks.push(run_sync_check("installation", progress.clone(), || { + installation_check(!command.summary) + })); + checks.push(run_sync_check("runtime", progress.clone(), runtime_check)); + checks.push(run_sync_check("search", progress.clone(), search_check)); + + progress.begin("config"); + let config_result = load_config(root_config_overrides, interactive, arg0_paths).await; + match &config_result { + Ok(config) => { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + let reachability_plan = provider_reachability_plan(config); + let ( + config_check, + auth_check, + updates_check, + network_check, + websocket_check, + mcp_check, + sandbox_check, + terminal_check, + state_check, + background_server_check, + reachability_check, + ) = tokio::join!( + async { run_sync_check("config", progress.clone(), || config_check(config)) }, + async { run_sync_check("auth", progress.clone(), || auth_check(config)) }, + async { run_sync_check("updates", progress.clone(), || updates_check(config)) }, + async { run_sync_check("network", progress.clone(), network_check) }, + run_async_check( + "websocket", + progress.clone(), + websocket_reachability_check(config, Some(auth_manager)), + ), + run_async_check("MCP", progress.clone(), mcp_check(config)), + async { + run_sync_check("sandbox", progress.clone(), || { + sandbox_check(config, arg0_paths) + }) + }, + async { + run_sync_check("terminal", progress.clone(), || { + terminal_check(command.no_color) + }) + }, + run_async_check("state", progress.clone(), state_check(config)), + async { + run_sync_check("app-server", progress.clone(), || { + background_server_check(config) + }) + }, + run_async_check( + "provider reachability", + progress.clone(), + provider_reachability_check(reachability_plan), + ), + ); + checks.extend([ + config_check, + auth_check, + updates_check, + network_check, + websocket_check, + mcp_check, + sandbox_check, + terminal_check, + state_check, + background_server_check, + reachability_check, + ]); + } + Err(err) => { + let reachability_plan = default_reachability_plan(); + let (config_check, network_check, terminal_check, state_check, reachability_check) = tokio::join!( + async { + run_sync_check("config", progress.clone(), || { + DoctorCheck::new( + "config.load", + "config", + CheckStatus::Fail, + "config could not be loaded", + ) + .detail(err.to_string()) + .remediation("Fix the reported config error, then rerun codex doctor.") + }) + }, + async { run_sync_check("network", progress.clone(), network_check) }, + async { + run_sync_check("terminal", progress.clone(), || { + terminal_check(command.no_color) + }) + }, + async { run_sync_check("state", progress.clone(), fallback_state_check) }, + run_async_check( + "provider reachability", + progress.clone(), + provider_reachability_check(reachability_plan), + ), + ); + checks.extend([ + config_check, + network_check, + terminal_check, + state_check, + reachability_check, + ]); + } + } + + progress.settle(); + + let overall_status = overall_status(&checks); + DoctorReport { + schema_version: 1, + generated_at: generated_at(), + overall_status, + codex_version: env!("CARGO_PKG_VERSION").to_string(), + checks, + } +} + +async fn load_config( + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result { + let mut cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + if interactive.web_search { + cli_kv_overrides.push(( + "web_search".to_string(), + toml::Value::String("live".to_string()), + )); + } + + let overrides = ConfigOverrides { + ephemeral: Some(true), + ..config_overrides_from_interactive(interactive, arg0_paths) + }; + + ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await + .context("failed to load Codex config") +} + +fn config_overrides_from_interactive( + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> ConfigOverrides { + let approval_policy = if interactive.dangerously_bypass_approvals_and_sandbox { + Some(AskForApproval::Never) + } else { + interactive.approval_policy.map(Into::into) + }; + let sandbox_mode = if interactive.dangerously_bypass_approvals_and_sandbox { + Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) + } else { + interactive.sandbox_mode.map(Into::into) + }; + ConfigOverrides { + model: interactive.model.clone(), + config_profile: interactive.config_profile.clone(), + approval_policy, + sandbox_mode, + cwd: interactive.cwd.clone(), + model_provider: interactive + .oss + .then(|| interactive.oss_provider.clone()) + .flatten(), + codex_self_exe: arg0_paths.codex_self_exe.clone(), + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), + show_raw_agent_reasoning: interactive.oss.then_some(true), + additional_writable_roots: interactive.add_dir.clone(), + ..Default::default() + } +} + +/// JSON support report emitted by `codex doctor --json`. +/// +/// The report is keyed by check id so support tooling can fetch paths like +/// `checks["terminal.metadata"]` without scanning arrays. Human rendering can +/// reorder or group rows independently, but this JSON shape should stay stable +/// across cosmetic output changes. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorReport { + schema_version: u32, + generated_at: String, + overall_status: CheckStatus, + codex_version: String, + checks: BTreeMap, +} + +/// One redacted check in the JSON support report. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorCheck { + id: String, + category: String, + status: CheckStatus, + summary: String, + details: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + issues: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + notes: Vec, + remediation: Option, + duration_ms: u64, +} + +/// One redacted issue in the JSON support report. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorIssue { + severity: CheckStatus, + cause: String, + measured: Option, + expected: Option, + remedy: Option, + fields: Vec, +} + +/// JSON detail value that preserves repeated detail keys without inventing names. +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +enum JsonDetailValue { + One(String), + Many(Vec), +} + +impl JsonDetailValue { + fn push(&mut self, value: String) { + match self { + JsonDetailValue::One(previous) => { + *self = JsonDetailValue::Many(vec![std::mem::take(previous), value]); + } + JsonDetailValue::Many(values) => values.push(value), + } + } +} + +fn redacted_json_report(report: &DoctorReport) -> JsonDoctorReport { + let checks = report + .checks + .iter() + .map(|check| { + let json_check = redacted_json_check(check); + (check.id.clone(), json_check) + }) + .collect(); + JsonDoctorReport { + schema_version: report.schema_version, + generated_at: report.generated_at.clone(), + overall_status: report.overall_status, + codex_version: report.codex_version.clone(), + checks, + } +} + +fn redacted_json_check(check: &DoctorCheck) -> JsonDoctorCheck { + let (details, notes) = structured_json_details(&check.details); + JsonDoctorCheck { + id: check.id.clone(), + category: check.category.clone(), + status: check.status, + summary: check.summary.clone(), + details, + issues: check.issues.iter().map(redacted_json_issue).collect(), + notes, + remediation: check.remediation.as_deref().map(redact_detail), + duration_ms: check.duration_ms, + } +} + +fn redacted_json_issue(issue: &DoctorIssue) -> JsonDoctorIssue { + JsonDoctorIssue { + severity: issue.severity, + cause: redact_detail(&issue.cause), + measured: issue.measured.as_deref().map(redact_detail), + expected: issue.expected.as_deref().map(redact_detail), + remedy: issue.remedy.as_deref().map(redact_detail), + fields: issue + .fields + .iter() + .map(|field| redact_detail(field)) + .collect(), + } +} + +/// Converts redacted `label: value` detail strings into JSON key/value fields. +/// +/// Detail strings that do not follow the doctor detail convention are preserved +/// as notes instead of being dropped. Repeated labels become arrays so callers +/// can still retrieve the common scalar case directly while keeping all values. +fn structured_json_details(details: &[String]) -> (BTreeMap, Vec) { + let mut structured: BTreeMap = BTreeMap::new(); + let mut notes = Vec::new(); + for detail in details { + let redacted = redact_detail(detail); + let Some((key, value)) = redacted.split_once(": ") else { + notes.push(redacted); + continue; + }; + let key = key.trim(); + if key.is_empty() { + notes.push(redacted); + continue; + } + let value = value.to_string(); + match structured.get_mut(key) { + Some(existing) => existing.push(value), + None => { + structured.insert(key.to_string(), JsonDetailValue::One(value)); + } + } + } + (structured, notes) +} + +fn run_sync_check( + label: &'static str, + progress: Arc, + f: impl FnOnce() -> DoctorCheck, +) -> DoctorCheck { + progress.begin(label); + let start = Instant::now(); + let mut check = f(); + check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + progress.finish(label, check.status); + check +} + +async fn run_async_check( + label: &'static str, + progress: Arc, + future: Fut, +) -> DoctorCheck +where + Fut: Future, +{ + progress.begin(label); + let start = Instant::now(); + tokio::pin!(future); + let mut progress_interval = tokio::time::interval(SLOW_CHECK_PROGRESS_INTERVAL); + loop { + tokio::select! { + mut check = &mut future => { + check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + progress.finish(label, check.status); + return check; + } + _ = progress_interval.tick() => { + let elapsed = start.elapsed(); + if elapsed >= SLOW_CHECK_PROGRESS_THRESHOLD { + progress.heartbeat(label, elapsed); + } + } + } + } +} + +fn overall_status(checks: &[DoctorCheck]) -> CheckStatus { + if checks.iter().any(|check| check.status == CheckStatus::Fail) { + CheckStatus::Fail + } else if checks + .iter() + .any(|check| check.status == CheckStatus::Warning) + { + CheckStatus::Warning + } else { + CheckStatus::Ok + } +} + +fn generated_at() -> String { + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => { + let seconds = duration.as_secs(); + format!("{seconds}s since unix epoch") + } + Err(_) => "unknown".to_string(), + } +} + +fn installation_check(show_details: bool) -> DoctorCheck { + let mut details = Vec::new(); + let current_exe = env::current_exe().ok(); + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + let inherited_managed_env = inherited_managed_env_for_cargo_binary(current_exe.as_deref()); + let install_context = doctor_install_context(current_exe.as_deref()); + details.push(format!( + "install context: {}", + describe_install_context(&install_context) + )); + if inherited_managed_env { + details.push( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + ); + } + details.push(format!( + "managed by npm: {}", + doctor_managed_by_npm(current_exe.as_deref()) + )); + details.push(format!( + "managed by bun: {}", + env::var_os("CODEX_MANAGED_BY_BUN").is_some() + )); + push_env_path_detail( + &mut details, + "managed package root", + "CODEX_MANAGED_PACKAGE_ROOT", + ); + + let path_entries = codex_path_entries(); + let mut status = CheckStatus::Ok; + let mut summary = "installation looks consistent".to_string(); + let mut remediation = None; + + if path_entries.len() > 1 { + details.push(format!("PATH codex entries: {}", path_entries.len())); + } + if show_details || path_entries.len() > 1 { + details.extend( + path_entries + .iter() + .enumerate() + .map(|(index, path)| format!("PATH codex #{}: {path}", index + 1)), + ); + } + + if doctor_managed_by_npm(current_exe.as_deref()) { + match npm_global_root_check() { + NpmRootCheck::Match { package_root } => { + details.push(format!("npm update target: {}", package_root.display())); + } + NpmRootCheck::Mismatch { + running_package_root, + npm_package_root, + } => { + status = CheckStatus::Fail; + summary = + "npm install -g @openai/codex would update a different install".to_string(); + remediation = Some(format!( + "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).", + running_package_root.display(), + npm_package_root.display() + )); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm-managed launch is missing package-root provenance".to_string(); + remediation = Some( + "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT." + .to_string(), + ); + } + NpmRootCheck::NpmUnavailable(error) => { + status = status.max(CheckStatus::Warning); + summary = "npm-managed launch could not inspect npm global root".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + let mut check = DoctorCheck::new("installation", "install", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn doctor_install_context(current_exe: Option<&Path>) -> InstallContext { + if inherited_managed_env_for_cargo_binary(current_exe) { + InstallContext::Other + } else { + InstallContext::current().clone() + } +} + +fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool { + env::var_os("CODEX_MANAGED_BY_NPM").is_some() + && !inherited_managed_env_for_cargo_binary(current_exe) +} + +fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { + if env::var_os("CODEX_MANAGED_BY_NPM").is_none() + && env::var_os("CODEX_MANAGED_BY_BUN").is_none() + { + return false; + } + + let Some(current_exe) = current_exe else { + return false; + }; + let components = current_exe + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>(); + components + .windows(2) + .any(|window| window[0] == "target" && matches!(window[1].as_ref(), "debug" | "release")) +} + +fn describe_install_context(context: &InstallContext) -> String { + match context { + InstallContext::Standalone { + release_dir, + resources_dir, + platform, + } => { + let platform = match platform { + StandalonePlatform::Unix => "unix", + StandalonePlatform::Windows => "windows", + }; + let resources = resources_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()); + format!( + "standalone ({platform}, release {}, resources {resources})", + release_dir.display() + ) + } + InstallContext::Npm => "npm".to_string(), + InstallContext::Bun => "bun".to_string(), + InstallContext::Brew => "brew".to_string(), + InstallContext::Other => "other".to_string(), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum NpmRootCheck { + Match { + package_root: PathBuf, + }, + Mismatch { + running_package_root: PathBuf, + npm_package_root: PathBuf, + }, + MissingPackageRoot, + NpmUnavailable(String), +} + +fn npm_global_root_check() -> NpmRootCheck { + let Some(running_package_root) = env::var_os("CODEX_MANAGED_PACKAGE_ROOT").map(PathBuf::from) + else { + return NpmRootCheck::MissingPackageRoot; + }; + + let output = match run_command("npm", ["root", "-g"]) { + Ok(output) => output, + Err(err) => return NpmRootCheck::NpmUnavailable(err), + }; + let Some(npm_root) = output.lines().map(str::trim).find(|line| !line.is_empty()) else { + return NpmRootCheck::NpmUnavailable("empty output from npm root -g".to_string()); + }; + + compare_npm_package_roots(&running_package_root, &PathBuf::from(npm_root)) +} + +fn compare_npm_package_roots(running_package_root: &Path, npm_root: &Path) -> NpmRootCheck { + let npm_package_root = npm_root.join("@openai").join("codex"); + let running = normalize_path_for_compare(running_package_root); + let target = normalize_path_for_compare(&npm_package_root); + if running == target { + NpmRootCheck::Match { + package_root: npm_package_root, + } + } else { + NpmRootCheck::Mismatch { + running_package_root: running_package_root.to_path_buf(), + npm_package_root, + } + } +} + +fn normalize_path_for_compare(path: &Path) -> String { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let raw = canonical.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + raw.to_ascii_lowercase() + } else { + raw + } +} + +fn display_list>(items: &[T]) -> String { + if items.is_empty() { + "none".to_string() + } else { + items + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + } +} + +fn codex_path_entries() -> Vec { + #[cfg(windows)] + let result = run_command("where", ["codex"]); + #[cfg(not(windows))] + let result = run_command("which", ["-a", "codex"]); + + result + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + +fn run_command(program: &str, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(program) + .args(args) + .output() + .map_err(|err| err.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + return Err(format!("exited with status {}", output.status)); + } + return Err(stderr); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn config_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + details.push(format!("CODEX_HOME: {}", config.codex_home.display())); + details.push(format!("cwd: {}", config.cwd.display())); + details.push(format!( + "model: {}", + config.model.as_deref().unwrap_or("") + )); + details.push(format!("model provider: {}", config.model_provider_id)); + details.push(format!("log dir: {}", config.log_dir.display())); + details.push(format!("sqlite home: {}", config.sqlite_home.display())); + details.push(format!("mcp servers: {}", config.mcp_servers.get().len())); + feature_flag_details(config, &mut details); + config_toml_details(config, &mut details); + + let status = if config.startup_warnings.is_empty() { + CheckStatus::Ok + } else { + details.extend( + config + .startup_warnings + .iter() + .map(|warning| format!("startup warning: {warning}")), + ); + CheckStatus::Warning + }; + + DoctorCheck::new("config.load", "config", status, "config loaded").details(details) +} + +fn feature_flag_details(config: &Config, details: &mut Vec) { + let features = config.features.get(); + let enabled_features = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id)) + .map(|spec| spec.key) + .collect::>(); + let overrides = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id) != spec.default_enabled) + .map(|spec| format!("{}={}", spec.key, features.enabled(spec.id))) + .collect::>(); + details.push(format!("feature flags enabled: {}", enabled_features.len())); + details.push(format!( + "enabled feature flags: {}", + display_list(&enabled_features) + )); + details.push(format!( + "feature flag overrides: {}", + display_list(&overrides) + )); + for usage in features.legacy_feature_usages() { + details.push(format!( + "legacy feature flag: {} -> {}", + usage.alias, + usage.feature.key() + )); + } +} + +fn config_toml_details(config: &Config, details: &mut Vec) { + let config_path = config.codex_home.join(codex_config::CONFIG_TOML_FILE); + details.push(format!("config.toml: {}", config_path.display())); + match std::fs::read_to_string(&config_path) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(_) => details.push("config.toml parse: ok".to_string()), + Err(err) => details.push(format!("config.toml parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("config.toml: missing".to_string()); + } + Err(err) => details.push(format!("config.toml read: {err}")), + } +} + +fn auth_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let auth_path = config.codex_home.join("auth.json"); + details.push(format!( + "auth storage mode: {:?}", + config.cli_auth_credentials_store_mode + )); + details.push(format!("auth file: {}", auth_path.display())); + + let env_auth_vars = [ + OPENAI_API_KEY_ENV_VAR, + CODEX_API_KEY_ENV_VAR, + CODEX_ACCESS_TOKEN_ENV_VAR, + ] + .into_iter() + .filter(|name| env_var_present(name)) + .collect::>(); + if !env_auth_vars.is_empty() { + details.push(format!( + "auth env vars present: {}", + env_auth_vars.join(", ") + )); + } + if let Some(check) = provider_specific_auth_check( + config.model_provider.requires_openai_auth, + config.model_provider.env_key.as_deref(), + config.model_provider.env_key_instructions.as_deref(), + details.clone(), + env_var_present, + ) { + return check; + } + + match load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) { + Ok(Some(auth)) => { + details.push(format!("stored auth mode: {}", stored_auth_mode(&auth))); + details.push(format!("stored API key: {}", auth.openai_api_key.is_some())); + details.push(format!("stored ChatGPT tokens: {}", auth.tokens.is_some())); + details.push(format!( + "stored agent identity: {}", + auth.agent_identity.is_some() + )); + let auth_issues = stored_auth_issues(&auth, env_var_present); + details.extend( + auth_issues + .iter() + .map(|issue| format!("stored auth issue: {issue}")), + ); + let status = if !auth_issues.is_empty() && env_auth_vars.is_empty() { + CheckStatus::Fail + } else if !auth_issues.is_empty() || env_auth_vars.len() > 1 { + CheckStatus::Warning + } else { + CheckStatus::Ok + }; + let summary = match status { + CheckStatus::Ok => "auth is configured", + CheckStatus::Warning if !auth_issues.is_empty() => { + "auth is provided by environment, but stored credentials are incomplete" + } + CheckStatus::Warning => { + "auth is configured, but multiple auth env vars are present" + } + CheckStatus::Fail => "stored credentials are incomplete", + }; + let mut check = + DoctorCheck::new("auth.credentials", "auth", status, summary).details(details); + if status == CheckStatus::Fail { + check = + check.remediation("Run codex login again or provide a supported auth env var."); + } + check + } + Ok(None) if !env_auth_vars.is_empty() => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "auth is provided by environment", + ) + .details(details), + Ok(None) => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "no Codex credentials were found", + ) + .details(details) + .remediation("Run codex login or provide an API key through a supported auth env var."), + Err(err) => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "stored credentials could not be read", + ) + .detail(err.to_string()) + .remediation("Fix auth storage access or run codex login again."), + } +} + +fn provider_specific_auth_check( + requires_openai_auth: bool, + provider_env_key: Option<&str>, + provider_env_key_instructions: Option<&str>, + mut details: Vec, + env_var_present: impl Fn(&str) -> bool, +) -> Option { + details.push(format!( + "model provider requires OpenAI auth: {requires_openai_auth}" + )); + if requires_openai_auth { + return None; + } + + match provider_env_key { + Some(env_key) if env_var_present(env_key) => { + details.push(format!("provider auth env var: {env_key} (present)")); + Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "auth is provided by the active model provider", + ) + .details(details), + ) + } + Some(env_key) => { + details.push(format!("provider auth env var: {env_key} (missing)")); + let remediation = provider_env_key_instructions + .map(str::to_string) + .unwrap_or_else(|| format!("Set {env_key} for the active model provider.")); + Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "active model provider auth env var is missing", + ) + .details(details) + .remediation(remediation), + ) + } + None => Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "OpenAI auth is not required for the active model provider", + ) + .details(details), + ), + } +} + +fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { + match stored_auth_mode_value(auth) { + codex_app_server_protocol::AuthMode::ApiKey => "api_key", + codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + } +} + +fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::AuthMode { + if let Some(mode) = auth.auth_mode { + return mode; + } + if auth.openai_api_key.is_some() { + codex_app_server_protocol::AuthMode::ApiKey + } else { + codex_app_server_protocol::AuthMode::Chatgpt + } +} + +fn stored_auth_issues( + auth: &AuthDotJson, + env_var_present: impl Fn(&str) -> bool, +) -> Vec<&'static str> { + let mut issues = Vec::new(); + match stored_auth_mode_value(auth) { + codex_app_server_protocol::AuthMode::ApiKey => { + let stored_key_present = auth + .openai_api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()); + let env_key_present = + env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR); + if !stored_key_present && !env_key_present { + issues.push("API key auth is missing an API key"); + } + } + codex_app_server_protocol::AuthMode::Chatgpt => { + match auth.tokens.as_ref() { + Some(tokens) => { + if tokens.access_token.trim().is_empty() { + issues.push("ChatGPT auth is missing an access token"); + } + if tokens.refresh_token.trim().is_empty() { + issues.push("ChatGPT auth is missing a refresh token"); + } + } + None => issues.push("ChatGPT auth is missing token data"), + } + if auth.last_refresh.is_none() { + issues.push("ChatGPT auth is missing refresh metadata"); + } + } + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => { + match auth.tokens.as_ref() { + Some(tokens) => { + if tokens.access_token.trim().is_empty() { + issues.push("external ChatGPT auth is missing an access token"); + } + if tokens.account_id.is_none() && tokens.id_token.chatgpt_account_id.is_none() { + issues.push("external ChatGPT auth is missing a ChatGPT account id"); + } + } + None => issues.push("external ChatGPT auth is missing token data"), + } + if auth.last_refresh.is_none() { + issues.push("external ChatGPT auth is missing refresh metadata"); + } + } + codex_app_server_protocol::AuthMode::AgentIdentity => { + if auth + .agent_identity + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("agent identity auth is missing an agent identity token"); + } + } + } + issues +} + +fn network_check() -> DoctorCheck { + let mut details = Vec::new(); + push_proxy_env_details(&mut details); + + let mut status = CheckStatus::Ok; + let mut summary = "network-related environment looks readable".to_string(); + for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] { + if let Some(raw) = env::var_os(name) { + let path = PathBuf::from(raw); + match std::fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => { + if let Err(err) = read_probe_file(&path) { + status = CheckStatus::Warning; + summary = "custom CA env var points at an unreadable file".to_string(); + details.push(format!("{name}: {} ({err})", path.display())); + } else { + details.push(format!("{name}: readable file {}", path.display())); + } + } + Ok(_) => { + status = CheckStatus::Warning; + summary = "custom CA env var does not point at a file".to_string(); + details.push(format!("{name}: not a file {}", path.display())); + } + Err(err) => { + status = CheckStatus::Warning; + summary = "custom CA env var points at an unreadable path".to_string(); + details.push(format!("{name}: {} ({err})", path.display())); + } + } + } + } + + DoctorCheck::new("network.env", "network", status, summary).details(details) +} + +fn push_proxy_env_details(details: &mut Vec) { + let present_proxy_vars = PROXY_ENV_VARS + .iter() + .copied() + .filter(|name| env_var_present(name)) + .collect::>(); + if present_proxy_vars.is_empty() { + details.push("proxy env vars: none".to_string()); + } else { + details.push(format!( + "proxy env vars present: {}", + present_proxy_vars.join(", ") + )); + } +} + +fn read_probe_file(path: &Path) -> std::io::Result<()> { + let mut file = std::fs::File::open(path)?; + let mut buffer = [0_u8; 1]; + let _ = file.read(&mut buffer)?; + Ok(()) +} + +async fn mcp_check(config: &Config) -> DoctorCheck { + mcp_check_from_servers(config.mcp_servers.get()).await +} + +async fn mcp_check_from_servers(servers: &HashMap) -> DoctorCheck { + if servers.is_empty() { + return DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Ok, + "no MCP servers configured", + ); + } + + let mut details = Vec::new(); + let mut transport_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); + let mut disabled = 0usize; + let mut missing_env = Vec::new(); + let mut unreachable_required_http = Vec::new(); + let mut unreachable_optional_http = Vec::new(); + + for (name, server) in servers { + let disabled_server = !server.enabled || server.disabled_reason.is_some(); + if disabled_server { + disabled += 1; + } + match &server.transport { + McpServerTransportConfig::Stdio { + command, + env, + env_vars, + cwd, + .. + } => { + *transport_counts.entry("stdio").or_default() += 1; + if disabled_server { + continue; + } + if let Some(cwd) = cwd + && !cwd.exists() + { + missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); + } + if command.trim().is_empty() { + missing_env.push(format!("{name}: stdio command is empty")); + } else if let Err(err) = + stdio_command_resolves(command, cwd.as_deref(), env.as_ref()) + { + missing_env.push(format!( + "{name}: stdio command {command:?} is not resolvable ({err})" + )); + } + if let Some(env) = env { + for key in env.keys().filter(|key| key.trim().is_empty()) { + missing_env.push(format!("{name}: empty env key {key}")); + } + } + for env_var in env_vars { + if env_var.is_remote_source() { + missing_env.push(format!( + "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", + env_var.name() + )); + } else if !env_var_present(env_var.name()) { + missing_env.push(format!("{name}: env var {} is not set", env_var.name())); + } + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + env_http_headers, + .. + } => { + *transport_counts.entry("streamable_http").or_default() += 1; + if disabled_server { + continue; + } + if let Some(env_var) = bearer_token_env_var + && !env_var_present(env_var) + { + missing_env.push(format!("{name}: bearer token env var {env_var} is not set")); + } + if let Some(headers) = env_http_headers { + for env_var in headers.values() { + if !env_var_present(env_var) { + missing_env + .push(format!("{name}: header env var {env_var} is not set")); + } + } + } + if let Err(err) = mcp_http_probe_url(url).await { + let detail = format!("{name}: {url} ({err})"); + if server.required { + unreachable_required_http.push(detail); + } else { + unreachable_optional_http.push(detail); + } + } + } + } + } + + details.push(format!("configured servers: {}", servers.len())); + details.push(format!("disabled servers: {disabled}")); + for (transport, count) in transport_counts { + details.push(format!("{transport} servers: {count}")); + } + details.extend(missing_env.iter().cloned()); + details.extend( + unreachable_required_http + .iter() + .map(|detail| format!("required reachability failed: {detail}")), + ); + details.extend( + unreachable_optional_http + .iter() + .map(|detail| format!("optional reachability failed: {detail}")), + ); + + let required_missing = servers.iter().any(|(name, server)| { + server.required + && missing_env + .iter() + .any(|missing| missing.starts_with(&format!("{name}:"))) + }); + let status = if required_missing || !unreachable_required_http.is_empty() { + CheckStatus::Fail + } else if !missing_env.is_empty() || !unreachable_optional_http.is_empty() { + CheckStatus::Warning + } else { + CheckStatus::Ok + }; + let summary = match status { + CheckStatus::Ok => "MCP configuration is locally consistent", + CheckStatus::Warning => "MCP configuration has optional issues", + CheckStatus::Fail => "MCP configuration has failing required inputs or reachability", + }; + + let mut check = DoctorCheck::new("mcp.config", "mcp", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Set the missing MCP env vars or disable the affected server."); + } + check +} + +fn sandbox_check(config: &Config, arg0_paths: &Arg0DispatchPaths) -> DoctorCheck { + let mut details = Vec::new(); + details.push(format!( + "approval policy: {:?}", + config.permissions.approval_policy.value() + )); + let file_system_sandbox = config.permissions.file_system_sandbox_policy(); + details.push(format!("filesystem sandbox: {}", file_system_sandbox.kind)); + details.push(format!( + "network sandbox: {}", + config.permissions.network_sandbox_policy() + )); + push_path_detail( + &mut details, + "codex-linux-sandbox helper", + arg0_paths.codex_linux_sandbox_exe.as_deref(), + ); + push_path_detail( + &mut details, + "execve wrapper helper", + arg0_paths.main_execve_wrapper_exe.as_deref(), + ); + + let mut status = CheckStatus::Ok; + let mut summary = "sandbox configuration is readable".to_string(); + if let Some(helper) = arg0_paths.codex_linux_sandbox_exe.as_deref() + && !helper.exists() + { + status = CheckStatus::Warning; + summary = "Linux sandbox helper path does not exist".to_string(); + } + + DoctorCheck::new("sandbox.helpers", "sandbox", status, summary).details(details) +} + +#[derive(Clone, Debug)] +struct TerminalCheckInputs { + info: TerminalInfo, + env: BTreeMap, + present_env: BTreeSet, + no_color_flag: bool, + stdin_is_terminal: bool, + stdout_is_terminal: bool, + stderr_is_terminal: bool, + stream_supports_color: bool, + terminal_size: Result<(u16, u16), String>, + tmux_details: Vec, +} + +impl TerminalCheckInputs { + fn detect(no_color_flag: bool) -> Self { + let names = terminal_env_names(); + let (env, present_env) = collect_env_snapshot(&names); + let terminal_size = crossterm::terminal::size().map_err(|err| err.to_string()); + let info = terminal_info(); + let tmux_details = if matches!(info.multiplexer, Some(Multiplexer::Tmux { .. })) { + tmux_diagnostic_details() + } else { + Vec::new() + }; + Self { + info, + env, + present_env, + no_color_flag, + stdin_is_terminal: std::io::stdin().is_terminal(), + stdout_is_terminal: std::io::stdout().is_terminal(), + stderr_is_terminal: std::io::stderr().is_terminal(), + stream_supports_color: supports_color::on(Stream::Stdout).is_some(), + terminal_size, + tmux_details, + } + } + + fn env_value(&self, name: &str) -> Option<&str> { + self.env.get(name).map(String::as_str) + } + + fn env_present(&self, name: &str) -> bool { + self.present_env.contains(name) + } +} + +fn terminal_check(no_color_flag: bool) -> DoctorCheck { + terminal_check_from_inputs(TerminalCheckInputs::detect(no_color_flag)) +} + +fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck { + let info = &inputs.info; + let name = info.name; + let mut details = vec![format!("terminal: {}", terminal_name(info))]; + if let Some(term_program) = info.term_program.as_deref() { + details.push(format!("TERM_PROGRAM: {term_program}")); + } + if let Some(version) = info.version.as_deref() { + details.push(format!("terminal version: {version}")); + } + if let Some(term) = info.term.as_deref() { + details.push(format!("TERM: {term}")); + } + if let Some(multiplexer) = info.multiplexer.as_ref() { + details.push(format!("multiplexer: {}", multiplexer_name(multiplexer))); + } + details.push(format!("stdin is terminal: {}", inputs.stdin_is_terminal)); + details.push(format!("stdout is terminal: {}", inputs.stdout_is_terminal)); + details.push(format!("stderr is terminal: {}", inputs.stderr_is_terminal)); + match &inputs.terminal_size { + Ok((columns, rows)) => details.push(format!("terminal size: {columns}x{rows}")), + Err(err) => details.push(format!("terminal size: unavailable ({err})")), + } + push_terminal_env_values(&mut details, &inputs, TERMINAL_DIMENSION_ENV_VARS); + details.push(format!("color output: {}", color_output_summary(&inputs))); + push_terminal_env_values(&mut details, &inputs, COLOR_ENV_VARS); + let terminfo_warning = push_terminfo_details(&mut details, &inputs); + let locale = effective_locale(&inputs); + if let Some(locale) = locale.as_ref() { + details.push(format!("effective locale: {locale}")); + } + push_presence_env_values(&mut details, &inputs, REMOTE_TERMINAL_ENV_VARS); + details.extend(inputs.tmux_details.iter().cloned()); + + let locale_warning = locale.as_deref().is_some_and(is_non_utf8_locale); + let mut issues = Vec::new(); + if matches!(name, TerminalName::Dumb) { + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "TERM=dumb - colors and cursor control are disabled", + ) + .measured("TERM=dumb") + .expected("TERM=xterm-256color or another real terminal type") + .remedy("set TERM to a real value, for example xterm-256color") + .field("TERM"), + ); + } + if locale_warning { + let measured = locale.unwrap_or_else(|| "unknown".to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + "locale is not UTF-8 - unicode glyphs may render incorrectly", + ) + .measured(measured) + .expected("UTF-8 locale, for example en_US.UTF-8") + .remedy("export LANG=en_US.UTF-8 or another UTF-8 locale") + .field("effective locale"), + ); + } + if terminfo_warning { + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "TERMINFO unreadable - terminal capabilities are unknown", + ) + .expected("readable terminfo file or directory") + .remedy("check that $TERMINFO points to a readable directory") + .field("TERMINFO") + .field("TERMINFO_DIRS entry"), + ); + } + issues.extend(terminal_size_issues(&inputs)); + + let status = issues + .iter() + .map(|issue| issue.severity) + .max() + .unwrap_or(CheckStatus::Ok); + let summary = issues + .first() + .map(|issue| issue.cause.as_str()) + .unwrap_or("terminal metadata was detected"); + let mut check = DoctorCheck::new("terminal.env", "terminal", status, summary).details(details); + for issue in issues { + check = check.issue(issue); + } + check +} + +fn terminal_name(info: &TerminalInfo) -> &'static str { + match info.name { + TerminalName::AppleTerminal => "Apple Terminal", + TerminalName::Ghostty => "Ghostty", + TerminalName::Iterm2 => "iTerm2", + TerminalName::WarpTerminal => "Warp", + TerminalName::VsCode => "VS Code", + TerminalName::WezTerm => "WezTerm", + TerminalName::Kitty => "kitty", + TerminalName::Alacritty => "Alacritty", + TerminalName::Konsole => "Konsole", + TerminalName::GnomeTerminal => "GNOME Terminal", + TerminalName::Vte => "VTE", + TerminalName::WindowsTerminal => "Windows Terminal", + TerminalName::Dumb => "dumb", + TerminalName::Unknown => "unknown", + } +} + +fn multiplexer_name(multiplexer: &Multiplexer) -> String { + match multiplexer { + Multiplexer::Tmux { version } => match version { + Some(version) => format!("tmux {version}"), + None => "tmux".to_string(), + }, + Multiplexer::Zellij { version } => match version { + Some(version) => format!("zellij {version}"), + None => "zellij".to_string(), + }, + } +} + +fn terminal_env_names() -> BTreeSet<&'static str> { + let mut names = BTreeSet::from(["TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION"]); + names.extend(COLOR_ENV_VARS.iter().copied()); + names.extend(TERMINAL_DIMENSION_ENV_VARS.iter().copied()); + names.extend(TERMINFO_ENV_VARS.iter().copied()); + names.extend(LOCALE_ENV_VARS.iter().copied()); + names.extend(REMOTE_TERMINAL_ENV_VARS.iter().copied()); + names +} + +fn collect_env_snapshot( + names: &BTreeSet<&'static str>, +) -> (BTreeMap, BTreeSet) { + let mut values = BTreeMap::new(); + let mut present = BTreeSet::new(); + for name in names { + if let Some(raw) = env::var_os(name) { + present.insert((*name).to_string()); + let value = raw.to_string_lossy().trim().to_string(); + if !value.is_empty() { + values.insert((*name).to_string(), value); + } + } + } + (values, present) +} + +fn push_terminal_env_values( + details: &mut Vec, + inputs: &TerminalCheckInputs, + names: &[&str], +) { + for name in names { + if let Some(value) = inputs.env_value(name) { + details.push(format!("{name}: {value}")); + } else if inputs.env_present(name) { + details.push(format!("{name}: present")); + } + } +} + +fn push_presence_env_values( + details: &mut Vec, + inputs: &TerminalCheckInputs, + names: &[&str], +) { + for name in names { + if inputs.env_present(name) { + details.push(format!("{name}: present")); + } + } +} + +fn color_output_summary(inputs: &TerminalCheckInputs) -> String { + if should_enable_color( + inputs.no_color_flag, + inputs.env_present("NO_COLOR"), + inputs.env_value("TERM"), + inputs.stdout_is_terminal, + inputs.stream_supports_color, + ) { + return "enabled".to_string(); + } + + let reason = if inputs.no_color_flag { + "--no-color" + } else if inputs.env_present("NO_COLOR") { + "NO_COLOR" + } else if inputs.env_value("TERM") == Some("dumb") { + "TERM=dumb" + } else if !inputs.stdout_is_terminal { + "stdout is not a terminal" + } else if !inputs.stream_supports_color { + "terminal color support not detected" + } else { + "disabled" + }; + format!("disabled ({reason})") +} + +fn push_terminfo_details(details: &mut Vec, inputs: &TerminalCheckInputs) -> bool { + let mut has_warning = false; + if let Some(raw) = inputs.env_value("TERMINFO") { + let path = PathBuf::from(raw); + let (status, warning) = terminal_path_readiness(&path); + details.push(format!("TERMINFO: {} ({status})", path.display())); + has_warning |= warning; + } + if let Some(raw) = inputs.env_value("TERMINFO_DIRS") { + for path in env::split_paths(raw).filter(|path| !path.as_os_str().is_empty()) { + let (status, warning) = terminal_path_readiness(&path); + details.push(format!( + "TERMINFO_DIRS entry: {} ({status})", + path.display() + )); + has_warning |= warning; + } + } else if inputs.env_present("TERMINFO_DIRS") { + details.push("TERMINFO_DIRS: present".to_string()); + } + has_warning +} + +fn terminal_path_readiness(path: &Path) -> (String, bool) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) { + Ok(_) => ("dir".to_string(), false), + Err(err) => (format!("dir unreadable: {err}"), true), + }, + Ok(metadata) if metadata.is_file() => match read_probe_file(path) { + Ok(_) => ("file".to_string(), false), + Err(err) => (format!("file unreadable: {err}"), true), + }, + Ok(_) => ("not a file or directory".to_string(), true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => ("missing".to_string(), true), + Err(err) => (err.to_string(), true), + } +} + +fn effective_locale(inputs: &TerminalCheckInputs) -> Option { + LOCALE_ENV_VARS + .iter() + .find_map(|name| inputs.env_value(name).map(ToString::to_string)) +} + +fn is_non_utf8_locale(locale: &str) -> bool { + let locale = locale.to_ascii_lowercase(); + !(locale.contains("utf-8") || locale.contains("utf8")) +} + +fn terminal_size_issues(inputs: &TerminalCheckInputs) -> Vec { + let mut issues = Vec::new(); + if let Ok((columns, rows)) = inputs.terminal_size { + if columns > 0 && columns < NARROW_TERMINAL_COLUMNS { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("width {columns} cols - output may wrap (recommended >=80)"), + ) + .measured(format!("{columns} x {rows}")) + .expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns")) + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ); + } + if rows > 0 && rows < NARROW_TERMINAL_ROWS { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("height {rows} rows - content may scroll off (recommended >=24)"), + ) + .measured(format!("{columns} x {rows}")) + .expected(format!(">= {NARROW_TERMINAL_ROWS} rows")) + .remedy("resize the window to at least 24 rows") + .field("terminal size"), + ); + } + } + + if let Some(columns) = inputs + .env_value("COLUMNS") + .and_then(|columns| columns.parse::().ok()) + && columns > 0 + && columns < NARROW_TERMINAL_COLUMNS + { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("COLUMNS={columns} - output may wrap (recommended >=80)"), + ) + .measured(format!("{columns} columns")) + .expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns")) + .remedy("resize the window to at least 80 columns") + .field("COLUMNS"), + ); + } + if let Some(rows) = inputs + .env_value("LINES") + .and_then(|rows| rows.parse::().ok()) + && rows > 0 + && rows < NARROW_TERMINAL_ROWS + { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("LINES={rows} - content may scroll off (recommended >=24)"), + ) + .measured(format!("{rows} rows")) + .expected(format!(">= {NARROW_TERMINAL_ROWS} rows")) + .remedy("resize the window to at least 24 rows") + .field("LINES"), + ); + } + + issues +} + +fn tmux_diagnostic_details() -> Vec { + let mut details = Vec::new(); + push_tmux_display_detail(&mut details, "tmux client termtype", "#{client_termtype}"); + push_tmux_display_detail(&mut details, "tmux client termname", "#{client_termname}"); + for option in TMUX_OPTION_NAMES { + let value = tmux_option_value(option).unwrap_or_else(|| "unavailable".to_string()); + details.push(format!("tmux {option}: {value}")); + } + details +} + +fn push_tmux_display_detail(details: &mut Vec, label: &str, format: &str) { + if let Some(value) = tmux_display_message(format) { + details.push(format!("{label}: {value}")); + } +} + +fn tmux_option_value(option: &str) -> Option { + let output = Command::new("tmux") + .args(["show-options", "-gqv", option]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + non_empty_trimmed(String::from_utf8(output.stdout).ok()?) +} + +fn tmux_display_message(format: &str) -> Option { + let output = Command::new("tmux") + .args(["display-message", "-p", format]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + non_empty_trimmed(String::from_utf8(output.stdout).ok()?) +} + +fn non_empty_trimmed(value: String) -> Option { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } +} + +async fn state_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + path_readiness(&mut details, "CODEX_HOME", &config.codex_home); + path_readiness(&mut details, "log dir", &config.log_dir); + path_readiness(&mut details, "sqlite home", &config.sqlite_home); + let state_db = codex_state::state_db_path(&config.sqlite_home); + let log_db = codex_state::logs_db_path(&config.sqlite_home); + path_readiness(&mut details, "state DB", &state_db); + path_readiness(&mut details, "log DB", &log_db); + let mut integrity_failures = Vec::new(); + sqlite_integrity_detail(&mut details, &mut integrity_failures, "state DB", &state_db).await; + sqlite_integrity_detail(&mut details, &mut integrity_failures, "log DB", &log_db).await; + rollout_stats_details(&mut details, &config.codex_home); + standalone_release_cache_details(&mut details); + + let status = if integrity_failures.is_empty() { + CheckStatus::Ok + } else { + CheckStatus::Fail + }; + let summary = if status == CheckStatus::Ok { + "state paths and databases are inspectable" + } else { + "state database integrity check failed" + }; + let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details); + if status == CheckStatus::Fail { + check = check + .remediation("Back up CODEX_HOME, then remove or repair the affected SQLite database."); + } + check +} + +async fn sqlite_integrity_detail( + details: &mut Vec, + integrity_failures: &mut Vec, + label: &str, + path: &Path, +) { + if !path.is_file() { + details.push(format!("{label} integrity: skipped (missing)")); + return; + } + + match codex_state::sqlite_integrity_check(path).await { + Ok(rows) if rows.iter().all(|row| row == "ok") => { + details.push(format!("{label} integrity: ok")); + } + Ok(rows) => { + let message = format!("{label} integrity: {}", rows.join("; ")); + integrity_failures.push(message.clone()); + details.push(message); + } + Err(err) => { + let message = format!("{label} integrity: {err}"); + integrity_failures.push(message.clone()); + details.push(message); + } + } +} + +fn rollout_stats_details(details: &mut Vec, codex_home: &Path) { + let active = collect_rollout_stats(&codex_home.join("sessions")); + let archived = collect_rollout_stats(&codex_home.join("archived_sessions")); + push_rollout_stats_detail(details, "active rollout files", active); + push_rollout_stats_detail(details, "archived rollout files", archived); +} + +fn push_rollout_stats_detail(details: &mut Vec, label: &str, stats: RolloutStats) { + match stats.error { + Some(error) => details.push(format!("{label}: scan failed ({error})")), + None => details.push(format!( + "{label}: {} files, {} total bytes, {} average bytes", + stats.files, + stats.total_bytes, + stats.average_bytes() + )), + } +} + +#[derive(Default)] +struct RolloutStats { + files: u64, + total_bytes: u64, + error: Option, +} + +impl RolloutStats { + fn average_bytes(&self) -> u64 { + if self.files == 0 { + 0 + } else { + self.total_bytes / self.files + } + } +} + +fn collect_rollout_stats(root: &Path) -> RolloutStats { + let mut stats = RolloutStats::default(); + collect_rollout_stats_inner(root, &mut stats); + stats +} + +fn collect_rollout_stats_inner(path: &Path, stats: &mut RolloutStats) { + if stats.error.is_some() { + return; + } + let entries = match std::fs::read_dir(path) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + let path = entry.path(); + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + if metadata.is_dir() { + collect_rollout_stats_inner(&path, stats); + } else if metadata.is_file() && is_rollout_file(&path) { + stats.files += 1; + stats.total_bytes = stats.total_bytes.saturating_add(metadata.len()); + } + } +} + +fn is_rollout_file(path: &Path) -> bool { + path.extension() == Some(OsStr::new("jsonl")) + && path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with("rollout-")) +} + +async fn websocket_reachability_check( + config: &Config, + auth_manager: Option>, +) -> DoctorCheck { + let provider = &config.model_provider; + let mut details = vec![ + format!("model provider: {}", config.model_provider_id), + format!("provider name: {}", provider.name), + format!("wire API: {}", provider.wire_api), + format!("supports websockets: {}", provider.supports_websockets), + ]; + push_proxy_env_details(&mut details); + + if !provider.supports_websockets { + return DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket is not enabled for the active provider", + ) + .details(details); + } + + details.push(format!( + "connect timeout: {} ms", + provider.websocket_connect_timeout().as_millis() + )); + + let runtime_provider = create_model_provider(provider.clone(), auth_manager); + let auth = runtime_provider.auth().await; + details.push(format!( + "auth mode: {}", + auth.as_ref().map(auth_mode_name).unwrap_or("none") + )); + + let api_provider = match runtime_provider.api_provider().await { + Ok(api_provider) => api_provider, + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket provider setup failed", + details, + format!("provider setup failed: {err}"), + ); + } + }; + match api_provider.websocket_url_for_path("responses") { + Ok(url) => { + details.push(format!("endpoint: {url}")); + if let Some(host) = url.host_str() + && let Some(port) = url.port_or_known_default() + { + details.extend(dns_address_family_details(host, port).await); + } + } + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket endpoint could not be built", + details, + format!("endpoint build failed: {err}"), + ); + } + } + + let api_auth = match runtime_provider.api_auth().await { + Ok(api_auth) => api_auth, + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket auth could not be resolved", + details, + format!("auth resolution failed: {err}"), + ); + } + }; + + let mut extra_headers = HeaderMap::new(); + extra_headers.insert( + OPENAI_BETA_HEADER, + HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), + ); + let client = ResponsesWebsocketClient::new(api_provider, api_auth); + match tokio::time::timeout( + provider.websocket_connect_timeout(), + client.probe_handshake( + extra_headers, + default_headers(), + WEBSOCKET_IMMEDIATE_CLOSE_GRACE, + ), + ) + .await + { + Ok(Ok(probe)) => { + details.push(format!("handshake result: HTTP {}", probe.status)); + details.push(format!("reasoning header: {}", probe.reasoning_included)); + details.push(format!( + "models etag present: {}", + probe.models_etag_present + )); + details.push(format!( + "server model present: {}", + probe.server_model_present + )); + if let Some(close) = probe.immediate_close { + details.push(format!("immediate close code: {}", close.code)); + details.push(format!("immediate close reason: {}", close.reason)); + return DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Warning, + "Responses WebSocket closed immediately after handshake", + ) + .details(details) + .remediation( + "Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.", + ); + } + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .details(details) + } + Ok(Err(err)) => websocket_probe_warning( + "Responses WebSocket failed; HTTPS fallback may still work", + details, + websocket_error_detail(&err), + ), + Err(_) => websocket_probe_warning( + "Responses WebSocket timed out; HTTPS fallback may still work", + details, + "handshake timed out".to_string(), + ), + } +} + +fn websocket_probe_warning( + summary: &'static str, + mut details: Vec, + error_detail: String, +) -> DoctorCheck { + details.push(error_detail); + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Warning, + summary, + ) + .details(details) + .remediation("Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.") +} + +fn websocket_error_detail(err: &ApiError) -> String { + match err { + ApiError::Transport(transport) => format!("handshake transport error: {transport}"), + ApiError::Api { status, message } => { + format!("handshake API error: {status} {message}") + } + ApiError::Stream(message) => format!("handshake stream error: {message}"), + ApiError::ContextWindowExceeded + | ApiError::QuotaExceeded + | ApiError::UsageNotIncluded + | ApiError::Retryable { .. } + | ApiError::RateLimit(_) + | ApiError::InvalidRequest { .. } + | ApiError::CyberPolicy { .. } + | ApiError::ServerOverloaded => format!("handshake error: {err}"), + } +} + +fn auth_mode_name(auth: &CodexAuth) -> &'static str { + match auth.auth_mode() { + codex_app_server_protocol::AuthMode::ApiKey => "api_key", + codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + } +} + +async fn dns_address_family_details(host: &str, port: u16) -> Vec { + match tokio::net::lookup_host((host, port)).await { + Ok(addresses) => { + let addresses = addresses.collect::>(); + let ipv4_count = addresses + .iter() + .filter(|address| matches!(address.ip(), IpAddr::V4(_))) + .count(); + let ipv6_count = addresses + .iter() + .filter(|address| matches!(address.ip(), IpAddr::V6(_))) + .count(); + let first_family = addresses + .first() + .map(|address| match address.ip() { + IpAddr::V4(_) => "IPv4", + IpAddr::V6(_) => "IPv6", + }) + .unwrap_or("none"); + vec![format!( + "DNS: {ipv4_count} IPv4, {ipv6_count} IPv6, first {first_family}" + )] + } + Err(err) => vec![format!("DNS: lookup failed ({err})")], + } +} + +fn fallback_state_check() -> DoctorCheck { + let codex_home = find_codex_home(); + match codex_home { + Ok(path) => DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "CODEX_HOME was resolved without config", + ) + .detail(format!("CODEX_HOME: {}", path.display())), + Err(err) => DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Warning, + "CODEX_HOME could not be resolved", + ) + .detail(err.to_string()), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityPlan { + description: String, + endpoints: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityEndpoint { + label: String, + url: String, + required: bool, + route_probe_url: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProviderAuthReachabilityMode { + NotRequired, + ApiKey, + Chatgpt, +} + +impl ProviderAuthReachabilityMode { + fn description(self) -> &'static str { + match self { + Self::NotRequired => "provider auth", + Self::ApiKey => "API key auth", + Self::Chatgpt => "ChatGPT auth", + } + } +} + +fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { + let stored_auth = + load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) + .ok() + .flatten(); + let mode = provider_auth_reachability_mode_from_auth( + config.model_provider.requires_openai_auth, + env_var_present, + stored_auth.as_ref(), + ); + provider_reachability_plan_from_parts( + mode, + &config.model_provider_id, + &config.model_provider.name, + config.model_provider.base_url.as_deref(), + config.model_provider.query_params.as_ref(), + config.model_provider.is_amazon_bedrock(), + &config.chatgpt_base_url, + ) +} + +fn default_reachability_plan() -> ReachabilityPlan { + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::Chatgpt, + "openai", + "OpenAI", + /*provider_base_url*/ None, + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ) +} + +fn provider_auth_reachability_mode_from_auth( + requires_openai_auth: bool, + env_var_present: impl Fn(&str) -> bool, + stored_auth: Option<&AuthDotJson>, +) -> ProviderAuthReachabilityMode { + if !requires_openai_auth { + return ProviderAuthReachabilityMode::NotRequired; + } + if env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR) { + return ProviderAuthReachabilityMode::ApiKey; + } + if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) { + return ProviderAuthReachabilityMode::Chatgpt; + } + match stored_auth.map(stored_auth_mode_value) { + Some(codex_app_server_protocol::AuthMode::ApiKey) => ProviderAuthReachabilityMode::ApiKey, + Some( + codex_app_server_protocol::AuthMode::Chatgpt + | codex_app_server_protocol::AuthMode::ChatgptAuthTokens + | codex_app_server_protocol::AuthMode::AgentIdentity, + ) + | None => ProviderAuthReachabilityMode::Chatgpt, + } +} + +fn provider_reachability_plan_from_parts( + mode: ProviderAuthReachabilityMode, + provider_id: &str, + provider_name: &str, + provider_base_url: Option<&str>, + provider_query_params: Option<&HashMap>, + is_amazon_bedrock: bool, + chatgpt_base_url: &str, +) -> ReachabilityPlan { + let provider_route_probe_url = provider_base_url + .or_else(|| { + (mode == ProviderAuthReachabilityMode::ApiKey).then_some("https://api.openai.com/v1") + }) + .and_then(|url| { + should_probe_models_route(provider_name, url, is_amazon_bedrock) + .then(|| provider_url_for_path(url, "models", provider_query_params)) + }); + let endpoints = match mode { + ProviderAuthReachabilityMode::ApiKey => vec![ReachabilityEndpoint { + label: format!("{provider_id} API"), + url: provider_base_url + .unwrap_or("https://api.openai.com/v1") + .to_string(), + required: true, + route_probe_url: provider_route_probe_url, + }], + ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint { + label: "ChatGPT".to_string(), + url: chatgpt_base_url.to_string(), + required: true, + route_probe_url: None, + }], + ProviderAuthReachabilityMode::NotRequired => provider_base_url + .map(|url| { + vec![ReachabilityEndpoint { + label: format!("{provider_id} API"), + url: url.to_string(), + required: true, + route_probe_url: provider_route_probe_url, + }] + }) + .unwrap_or_default(), + }; + ReachabilityPlan { + description: mode.description().to_string(), + endpoints, + } +} + +fn should_probe_models_route(provider_name: &str, base_url: &str, is_amazon_bedrock: bool) -> bool { + !is_amazon_bedrock && !is_azure_responses_provider(provider_name, Some(base_url)) +} + +fn provider_url_for_path( + base_url: &str, + path: &str, + query_params: Option<&HashMap>, +) -> String { + let base = base_url.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let mut url = if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + }; + + if let Some(params) = query_params + && !params.is_empty() + { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str( + ¶ms + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&"), + ); + } + + url +} + +async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck { + let mut details = vec![format!("reachability mode: {}", plan.description)]; + if plan.endpoints.is_empty() { + details.push("active provider endpoint: none configured".to_string()); + return DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider has no HTTP endpoint to probe", + ) + .details(details); + } + + let mut failures = Vec::new(); + let mut optional_failures = Vec::new(); + let mut route_failures = Vec::new(); + let mut route_warnings = Vec::new(); + let mut issues = Vec::new(); + for endpoint in plan.endpoints { + match http_probe_url(&endpoint.url).await { + Ok(status) => details.push(format!( + "{} base URL: {} reachable ({status})", + endpoint.label, endpoint.url + )), + Err(err) => { + let requirement = if endpoint.required { + "required" + } else { + "optional" + }; + details.push(format!( + "{} base URL: {} {err} ({requirement})", + endpoint.label, endpoint.url + )); + if endpoint.required { + failures.push(endpoint.url); + } else { + optional_failures.push(endpoint.url); + } + continue; + } + } + + let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else { + continue; + }; + match provider_route_probe_url(route_probe_url).await { + RouteProbeOutcome::Ok(status) => { + details.push(format!( + "{} route probe: {route_probe_url} route exists ({status})", + endpoint.label, + )); + } + RouteProbeOutcome::Warning(status) => { + details.push(format!( + "{} route probe: {route_probe_url} returned {status} (warning)", + endpoint.label, + )); + route_warnings.push(route_probe_url.to_string()); + } + RouteProbeOutcome::Fail(status) => { + details.push(format!( + "{} route probe: {route_probe_url} returned {status} (required)", + endpoint.label, + )); + route_failures.push(route_probe_url.to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "provider base URL route returned 404 - verify the configured API prefix", + ) + .measured(format!("{route_probe_url} returned {status}")) + .expected("GET /models returns 2xx, 401, or 403") + .remedy("Set base_url to the provider API root, for example https://api.openai.com/v1") + .field("route probe"), + ); + } + RouteProbeOutcome::TransportError(err) => { + details.push(format!( + "{} route probe: {route_probe_url} {err} (required)", + endpoint.label, + )); + route_failures.push(route_probe_url.to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "provider route probe could not connect - verify network access to the provider API", + ) + .measured(format!("{route_probe_url} {err}")) + .expected("GET /models completes") + .remedy("Check proxy, VPN, firewall, DNS, and custom CA configuration.") + .field("route probe"), + ); + } + } + } + + let (status, summary) = provider_reachability_outcome( + failures.len() + route_failures.len(), + optional_failures.len() + route_warnings.len(), + ); + let mut check = DoctorCheck::new( + "network.provider_reachability", + "reachability", + status, + summary, + ) + .details(details); + for issue in issues { + check = check.issue(issue); + } + if status != CheckStatus::Ok { + check = check.remediation("Check proxy, VPN, firewall, DNS, and custom CA configuration."); + } + check +} + +enum RouteProbeOutcome { + Ok(String), + Warning(String), + Fail(String), + TransportError(String), +} + +async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome { + match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await { + Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => { + RouteProbeOutcome::Ok(format!("HTTP {status}")) + } + Ok(404) => RouteProbeOutcome::Fail("HTTP 404".to_string()), + Ok(status) => RouteProbeOutcome::Warning(format!("HTTP {status}")), + Err(err) => RouteProbeOutcome::TransportError(err), + } +} + +fn provider_reachability_outcome( + required_failures: usize, + warnings: usize, +) -> (CheckStatus, &'static str) { + match (required_failures, warnings) { + (0, 0) => ( + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + (0, _) => ( + CheckStatus::Warning, + "provider endpoint checks returned warnings", + ), + (_, _) => ( + CheckStatus::Fail, + "one or more required provider endpoints are unreachable over HTTP", + ), + } +} + +async fn http_probe_url(url: &str) -> Result { + http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url(url: &str) -> Result { + mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + match http_probe_url_with_timeout(url, timeout).await { + Ok(status) => Ok(status), + Err(head_err) => match http_get_probe_url_with_timeout(url, timeout).await { + Ok(status) => Ok(status), + Err(get_err) => Err(format!("HEAD {head_err}; GET {get_err}")), + }, + } +} + +async fn http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + let response = build_reqwest_client() + .head(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + "request timed out".to_string() + } else if err.is_connect() { + "connect failed".to_string() + } else if err.is_builder() { + "request could not be built".to_string() + } else { + err.to_string() + } + })?; + Ok(format!("HTTP {}", response.status().as_u16())) +} + +async fn http_get_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + http_get_probe_status_with_timeout(url, timeout) + .await + .map(|status| format!("HTTP {status}")) +} + +async fn http_get_probe_status_with_timeout(url: &str, timeout: Duration) -> Result { + let response = build_reqwest_client() + .get(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + "request timed out".to_string() + } else if err.is_connect() { + "connect failed".to_string() + } else if err.is_builder() { + "request could not be built".to_string() + } else { + err.to_string() + } + })?; + Ok(response.status().as_u16()) +} + +fn stdio_command_resolves( + command: &str, + cwd: Option<&Path>, + server_env: Option<&HashMap>, +) -> Result<(), String> { + let command_path = Path::new(command); + if command_path.is_absolute() { + return executable_path_exists(command_path); + } + + if command_path.components().count() > 1 { + let base = cwd + .map(Path::to_path_buf) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + return executable_path_exists(&base.join(command_path)); + } + + let Some(path_env) = server_env + .and_then(|env| env.get("PATH").map(String::as_str)) + .map(std::ffi::OsString::from) + .or_else(|| env::var_os("PATH")) + else { + return Err("PATH is not set".to_string()); + }; + + for dir in env::split_paths(&path_env) { + let candidate = dir.join(command); + if executable_path_exists(&candidate).is_ok() { + return Ok(()); + } + #[cfg(windows)] + { + let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|extension| !extension.is_empty()) { + let candidate = dir.join(format!("{command}{extension}")); + if executable_path_exists(&candidate).is_ok() { + return Ok(()); + } + } + } + } + Err("not found on PATH".to_string()) +} + +fn executable_path_exists(path: &Path) -> Result<(), String> { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => executable_file_permission(path, &metadata), + Ok(_) => Err("path is not a file".to_string()), + Err(err) => Err(err.to_string()), + } +} + +#[cfg(unix)] +fn executable_file_permission(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o111 == 0 { + Err(format!("{} is not executable", path.display())) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn executable_file_permission(_path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> { + Ok(()) +} + +fn path_readiness(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) => { + let kind = if metadata.is_dir() { + "dir" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + details.push(format!("{label}: {} ({kind})", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push(format!("{label}: {} (missing)", path.display())); + } + Err(err) => details.push(format!("{label}: {} ({err})", path.display())), + } +} + +fn standalone_release_cache_details(details: &mut Vec) { + let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + return; + }; + let Some(releases_dir) = release_dir.parent() else { + return; + }; + let Ok(entries) = std::fs::read_dir(releases_dir) else { + return; + }; + let release_count = entries.filter_map(Result::ok).count(); + details.push(format!( + "standalone release cache: {release_count} entries in {}", + releases_dir.display() + )); +} + +fn push_path_detail(details: &mut Vec, label: &str, path: Option<&Path>) { + match path { + Some(path) => details.push(format!("{label}: {}", path.display())), + None => details.push(format!("{label}: none")), + } +} + +fn push_env_path_detail(details: &mut Vec, label: &str, name: &str) { + match env::var_os(name) { + Some(path) => details.push(format!("{label}: {}", PathBuf::from(path).display())), + None => details.push(format!("{label}: not set")), + } +} + +fn env_var_present(name: &str) -> bool { + env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn human_output_options(command: &DoctorCommand) -> HumanOutputOptions { + let term = env::var("TERM").ok(); + let color_enabled = should_enable_color( + command.no_color, + env::var_os("NO_COLOR").is_some(), + term.as_deref(), + std::io::stdout().is_terminal(), + supports_color::on(Stream::Stdout).is_some(), + ); + HumanOutputOptions { + show_details: !command.summary, + show_all: command.all, + ascii: command.ascii, + color_enabled, + } +} + +fn should_enable_color( + no_color_flag: bool, + no_color_env: bool, + term: Option<&str>, + stdout_is_tty: bool, + stream_supports_color: bool, +) -> bool { + !no_color_flag + && !no_color_env + && term != Some("dumb") + && stdout_is_tty + && stream_supports_color +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::io::Write; + use std::net::TcpListener; + use std::sync::Mutex; + + use clap::Parser; + use codex_protocol::config_types::SandboxMode; + use pretty_assertions::assert_eq; + + use super::*; + + #[derive(Default)] + struct RecordingProgress { + events: Mutex>, + } + + impl RecordingProgress { + fn events(&self) -> Vec { + self.events.lock().expect("events lock").clone() + } + } + + impl DoctorProgress for RecordingProgress { + fn begin(&self, label: &'static str) { + self.events + .lock() + .expect("events lock") + .push(format!("begin {label}")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.events + .lock() + .expect("events lock") + .push(format!("heartbeat {label} {}", elapsed.as_secs())); + } + + fn finish(&self, label: &'static str, status: CheckStatus) { + self.events + .lock() + .expect("events lock") + .push(format!("finish {label} {status:?}")); + } + + fn settle(&self) { + self.events + .lock() + .expect("events lock") + .push("settle".to_string()); + } + } + + fn respond_once(listener: &TcpListener, response: &[u8]) { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + stream.write_all(response).expect("write response"); + } + + #[test] + fn overall_status_prefers_fail() { + let checks = vec![ + DoctorCheck::new("a", "config", CheckStatus::Warning, "warning"), + DoctorCheck::new("b", "auth", CheckStatus::Fail, "fail"), + ]; + assert_eq!(overall_status(&checks), CheckStatus::Fail); + } + + #[test] + fn run_sync_check_notifies_progress() { + let progress_impl = Arc::new(RecordingProgress::default()); + let progress: Arc = progress_impl.clone(); + + let check = run_sync_check("test", progress, || { + DoctorCheck::new("test", "test", CheckStatus::Ok, "ok") + }); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + progress_impl.events(), + vec!["begin test".to_string(), "finish test Ok".to_string()] + ); + } + + #[tokio::test] + async fn run_async_check_notifies_progress() { + let progress_impl = Arc::new(RecordingProgress::default()); + let progress: Arc = progress_impl.clone(); + + let check = run_async_check("test", progress, async { + DoctorCheck::new("test", "test", CheckStatus::Warning, "warning") + }) + .await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + progress_impl.events(), + vec!["begin test".to_string(), "finish test Warning".to_string()] + ); + } + + #[test] + fn compare_npm_package_roots_detects_match() { + let running = PathBuf::from("/prefix/lib/node_modules/@openai/codex"); + let npm_root = PathBuf::from("/prefix/lib/node_modules"); + assert_eq!( + compare_npm_package_roots(&running, &npm_root), + NpmRootCheck::Match { + package_root: npm_root.join("@openai").join("codex") + } + ); + } + + #[test] + fn compare_npm_package_roots_detects_mismatch() { + let running = PathBuf::from("/old/lib/node_modules/@openai/codex"); + let npm_root = PathBuf::from("/new/lib/node_modules"); + assert_eq!( + compare_npm_package_roots(&running, &npm_root), + NpmRootCheck::Mismatch { + running_package_root: running, + npm_package_root: npm_root.join("@openai").join("codex"), + } + ); + } + + #[test] + fn config_overrides_from_interactive_preserves_global_options() { + let interactive = TuiCli::parse_from([ + "codex", + "--oss", + "--local-provider", + "ollama", + "--model", + "llama3.2", + "--cd", + "/tmp", + "--sandbox", + "danger-full-access", + "--ask-for-approval", + "never", + "--add-dir", + "/var/tmp", + ]); + let arg0_paths = Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/bin/codex")), + codex_linux_sandbox_exe: Some(PathBuf::from("/bin/codex-linux-sandbox")), + main_execve_wrapper_exe: Some(PathBuf::from("/bin/codex-execve-wrapper")), + }; + + let overrides = config_overrides_from_interactive(&interactive, &arg0_paths); + + assert_eq!(overrides.model.as_deref(), Some("llama3.2")); + assert_eq!(overrides.model_provider.as_deref(), Some("ollama")); + assert_eq!(overrides.cwd.as_deref(), Some(Path::new("/tmp"))); + assert_eq!(overrides.approval_policy, Some(AskForApproval::Never)); + assert_eq!(overrides.sandbox_mode, Some(SandboxMode::DangerFullAccess)); + assert_eq!(overrides.show_raw_agent_reasoning, Some(true)); + assert_eq!( + overrides.additional_writable_roots, + vec![PathBuf::from("/var/tmp")] + ); + assert_eq!(overrides.codex_self_exe, arg0_paths.codex_self_exe); + assert_eq!( + overrides.codex_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe + ); + assert_eq!( + overrides.main_execve_wrapper_exe, + arg0_paths.main_execve_wrapper_exe + ); + } + + #[test] + fn redacted_json_report_structures_and_sanitizes_details() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ) + .detail( + "optional reachability failed: remote: https://user:pass@example.com/mcp?x=abc (connect failed)", + ) + .detail("OPENAI_API_KEY: sk-live-secret") + .detail("duplicate: one") + .detail("duplicate: two") + .detail("freeform note") + .issue( + DoctorIssue::new( + CheckStatus::Warning, + "remote https://user:pass@example.com/mcp?x=abc is unreachable", + ) + .measured("https://user:pass@example.com/mcp?x=abc") + .expected("reachable MCP endpoint") + .remedy("Check https://user:pass@example.com/help?x=abc.") + .field("optional reachability failed"), + ) + .remediation("Open https://user:pass@example.com/help?x=abc."), + ], + }; + + let redacted_report = redacted_json_report(&report); + let redacted = serde_json::to_string(&redacted_report).expect("serialize report"); + let json = serde_json::to_value(redacted_report).expect("report should serialize"); + + assert!(!redacted.contains("user:pass")); + assert!(!redacted.contains("x=abc")); + assert!(!redacted.contains("sk-live-secret")); + assert!(redacted.contains("https://example.com/mcp")); + assert_eq!(json["checks"].is_object(), true); + assert_eq!(json["checks"]["mcp.config"]["id"], "mcp.config"); + assert_eq!( + json["checks"]["mcp.config"]["details"]["OPENAI_API_KEY"], + "" + ); + assert_eq!( + json["checks"]["mcp.config"]["details"]["duplicate"], + serde_json::json!(["one", "two"]) + ); + assert_eq!( + json["checks"]["mcp.config"]["notes"], + serde_json::json!(["freeform note"]) + ); + assert_eq!( + json["checks"]["mcp.config"]["issues"][0]["measured"], + "https://example.com/mcp" + ); + assert_eq!( + json["checks"]["mcp.config"]["issues"][0]["remedy"], + "Check https://example.com/help." + ); + } + + #[tokio::test] + async fn mcp_check_ignores_disabled_servers() { + let disabled_server: McpServerConfig = toml::from_str( + r#" + url = "http://127.0.0.1:9/mcp" + enabled = false + required = true + bearer_token_env_var = "CODEX_DOCTOR_DISABLED_MCP_TOKEN" + "#, + ) + .expect("should deserialize disabled MCP config"); + let servers = HashMap::from([("disabled".to_string(), disabled_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "MCP configuration is locally consistent"); + assert!(check.details.contains(&"disabled servers: 1".to_string())); + assert!( + check + .details + .iter() + .all(|detail| !detail.contains("CODEX_DOCTOR_DISABLED_MCP_TOKEN")) + ); + assert!( + check + .details + .iter() + .all(|detail| !detail.contains("reachability failed")) + ); + } + + #[tokio::test] + async fn mcp_check_warns_for_optional_http_reachability() { + let optional_server: McpServerConfig = toml::from_str( + r#" + url = "http://127.0.0.1:9/mcp" + "#, + ) + .expect("should deserialize optional MCP config"); + let servers = HashMap::from([("optional".to_string(), optional_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!(check.summary, "MCP configuration has optional issues"); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("optional reachability failed: optional:")) + ); + } + + #[tokio::test] + async fn mcp_check_fails_required_remote_stdio_env_var() { + let command = toml::Value::String( + std::env::current_exe() + .expect("current exe") + .to_string_lossy() + .into_owned(), + ); + let required_server: McpServerConfig = toml::from_str(&format!( + r#" + command = {command} + required = true + env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}] + "#, + )) + .expect("should deserialize required MCP config"); + let servers = HashMap::from([("required".to_string(), required_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert!(check.details.iter().any(|detail| { + detail.contains( + "required: env_vars entry `REMOTE_ONLY_TOKEN` uses source `remote`, which requires remote MCP stdio", + ) + })); + } + + #[test] + fn provider_specific_auth_allows_non_openai_provider_without_env_key() { + let check = provider_specific_auth_check( + /*requires_openai_auth*/ false, + /*provider_env_key*/ None, + /*provider_env_key_instructions*/ None, + Vec::new(), + |_| false, + ) + .expect("non-OpenAI provider should produce a provider-specific check"); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + check.summary, + "OpenAI auth is not required for the active model provider" + ); + } + + #[test] + fn provider_specific_auth_fails_when_provider_env_key_is_missing() { + let check = provider_specific_auth_check( + /*requires_openai_auth*/ false, + Some("PROVIDER_API_KEY"), + Some("Set PROVIDER_API_KEY before running Codex."), + Vec::new(), + |_| false, + ) + .expect("non-OpenAI provider should produce a provider-specific check"); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "active model provider auth env var is missing" + ); + assert_eq!( + check.remediation, + Some("Set PROVIDER_API_KEY before running Codex.".to_string()) + ); + } + + #[test] + fn stored_auth_validation_rejects_missing_api_key() { + let auth = AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["API key auth is missing an API key"] + ); + assert!(stored_auth_issues(&auth, |name| name == OPENAI_API_KEY_ENV_VAR).is_empty()); + } + + #[test] + fn stored_auth_validation_rejects_missing_chatgpt_tokens() { + let auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec![ + "ChatGPT auth is missing token data", + "ChatGPT auth is missing refresh metadata", + ] + ); + } + + #[test] + fn provider_reachability_mode_uses_api_key_auth() { + let api_key_auth = AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey), + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + provider_auth_reachability_mode_from_auth( + /*requires_openai_auth*/ true, + |_| false, + Some(&api_key_auth), + ), + ProviderAuthReachabilityMode::ApiKey + ); + assert_eq!( + provider_auth_reachability_mode_from_auth( + /*requires_openai_auth*/ true, + |name| name == OPENAI_API_KEY_ENV_VAR, + /*stored_auth*/ None, + ), + ProviderAuthReachabilityMode::ApiKey + ); + } + + #[test] + fn provider_reachability_uses_active_provider_endpoint() { + assert_eq!( + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "azure", + "azure", + Some("https://example.openai.azure.com/openai/v1"), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ), + ReachabilityPlan { + description: "provider auth".to_string(), + endpoints: vec![ReachabilityEndpoint { + label: "azure API".to_string(), + url: "https://example.openai.azure.com/openai/v1".to_string(), + required: true, + route_probe_url: None, + }], + } + ); + } + + #[test] + fn provider_reachability_adds_models_route_probe_for_openai_compatible_base_urls() { + let query_params = HashMap::from([("api-version".to_string(), "2026-01-01".to_string())]); + + assert_eq!( + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "custom", + "Custom", + Some("https://example.com/openai/v1/"), + Some(&query_params), + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ), + ReachabilityPlan { + description: "provider auth".to_string(), + endpoints: vec![ReachabilityEndpoint { + label: "custom API".to_string(), + url: "https://example.com/openai/v1/".to_string(), + required: true, + route_probe_url: Some( + "https://example.com/openai/v1/models?api-version=2026-01-01".to_string() + ), + }], + } + ); + } + + #[test] + fn provider_reachability_skips_route_probe_for_bedrock() { + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "amazon-bedrock", + "Amazon Bedrock", + Some("https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1"), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ true, + "https://chatgpt.com/backend-api/", + ); + + assert_eq!(plan.endpoints[0].route_probe_url, None); + } + + #[test] + fn provider_reachability_api_key_does_not_require_chatgpt() { + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + /*provider_base_url*/ None, + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + assert_eq!( + plan.endpoints, + vec![ReachabilityEndpoint { + label: "openai API".to_string(), + url: "https://api.openai.com/v1".to_string(), + required: true, + route_probe_url: Some("https://api.openai.com/v1/models".to_string()), + }] + ); + } + + #[test] + fn provider_reachability_outcome_reports_required_failures() { + assert_eq!( + provider_reachability_outcome(/*required_failures*/ 0, /*warnings*/ 1,), + ( + CheckStatus::Warning, + "provider endpoint checks returned warnings", + ) + ); + assert_eq!( + provider_reachability_outcome(/*required_failures*/ 1, /*warnings*/ 0,), + ( + CheckStatus::Fail, + "one or more required provider endpoints are unreachable over HTTP", + ) + ); + } + + #[tokio::test] + async fn provider_reachability_route_404_fails_bad_base_url_path() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + Some(&format!("http://{addr}/xxxx")), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + let check = provider_reachability_check(plan).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(check.status, CheckStatus::Fail); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("route probe:") && detail.contains("HTTP 404")) + ); + assert_eq!(check.issues.len(), 1); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("Set base_url to the provider API root, for example https://api.openai.com/v1") + ); + } + + #[tokio::test] + async fn provider_reachability_route_401_keeps_reachability_ok() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + respond_once( + &listener, + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + Some(&format!("http://{addr}/v1")), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + let check = provider_reachability_check(plan).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(check.status, CheckStatus::Ok); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("route exists (HTTP 401)")) + ); + } + + #[test] + fn collect_rollout_stats_counts_nested_rollout_files() { + let temp = tempfile::tempdir().expect("create temp dir"); + let nested = temp + .path() + .join("sessions") + .join("2026") + .join("05") + .join("13"); + std::fs::create_dir_all(&nested).expect("create nested rollout dir"); + std::fs::write( + nested.join("rollout-2026-05-13T00-00-00-test.jsonl"), + "12345", + ) + .expect("write rollout file"); + std::fs::write(nested.join("not-a-rollout.jsonl"), "ignored").expect("write ignored jsonl"); + + let stats = collect_rollout_stats(&temp.path().join("sessions")); + + assert_eq!(stats.files, 1); + assert_eq!(stats.total_bytes, 5); + assert_eq!(stats.average_bytes(), 5); + assert_eq!(stats.error, None); + } + + #[tokio::test] + async fn http_probe_treats_http_status_as_reachable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write response"); + }); + + let status = http_probe_url(&format!("http://{addr}/mcp")).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(status, Ok("HTTP 405".to_string())); + } + + #[tokio::test] + async fn mcp_http_probe_falls_back_to_get_when_head_times_out() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (mut head_stream, _) = listener.accept().expect("accept HEAD probe request"); + let head = std::thread::spawn(move || { + let mut request = [0; 1024]; + let _ = head_stream.read(&mut request); + std::thread::sleep(Duration::from_millis(50)); + }); + + let (mut get_stream, _) = listener.accept().expect("accept GET probe request"); + let mut request = [0; 1024]; + let _ = get_stream.read(&mut request); + get_stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write response"); + head.join().expect("HEAD holder should finish"); + }); + + let status = mcp_http_probe_url_with_timeout( + &format!("http://{addr}/mcp"), + Duration::from_millis(10), + ) + .await; + server.join().expect("probe server thread should finish"); + + assert_eq!(status, Ok("HTTP 405".to_string())); + } + + #[tokio::test] + async fn mcp_check_fails_required_missing_stdio_command() { + let required_server: McpServerConfig = toml::from_str( + r#" + command = "definitely-missing-codex-doctor-mcp" + required = true + "#, + ) + .expect("should deserialize required MCP config"); + let servers = HashMap::from([("required".to_string(), required_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "MCP configuration has failing required inputs or reachability" + ); + assert!(check.details.iter().any(|detail| { + detail.contains( + "required: stdio command \"definitely-missing-codex-doctor-mcp\" is not resolvable", + ) + })); + } + + #[cfg(unix)] + #[test] + fn read_probe_file_rejects_unreadable_file() { + use std::os::unix::fs::PermissionsExt; + + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "cert").expect("write temp file"); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o000); + std::fs::set_permissions(file.path(), permissions).expect("remove read permissions"); + + let result = read_probe_file(file.path()); + + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(file.path(), permissions).expect("restore read permissions"); + assert!(result.is_err()); + } + + #[cfg(unix)] + #[test] + fn executable_path_exists_rejects_non_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "#!/bin/sh\n").expect("write temp file"); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(file.path(), permissions).expect("set non-executable mode"); + + let result = executable_path_exists(file.path()); + + assert!(result.is_err()); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(file.path(), permissions).expect("set executable mode"); + assert_eq!(executable_path_exists(file.path()), Ok(())); + } + + #[test] + fn should_enable_color_respects_terminal_inputs() { + assert!(should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ true, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ true, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("dumb"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ false, + /*stream_supports_color*/ true, + )); + } + + fn terminal_inputs() -> TerminalCheckInputs { + TerminalCheckInputs { + info: TerminalInfo { + name: TerminalName::Unknown, + term_program: None, + version: None, + term: Some("xterm-256color".to_string()), + multiplexer: None, + }, + env: BTreeMap::from([("TERM".to_string(), "xterm-256color".to_string())]), + present_env: BTreeSet::from(["TERM".to_string()]), + no_color_flag: false, + stdin_is_terminal: true, + stdout_is_terminal: true, + stderr_is_terminal: true, + stream_supports_color: true, + terminal_size: Ok((120, 40)), + tmux_details: Vec::new(), + } + } + + fn set_terminal_env(inputs: &mut TerminalCheckInputs, name: &str, value: &str) { + inputs.present_env.insert(name.to_string()); + if value.is_empty() { + inputs.env.remove(name); + } else { + inputs.env.insert(name.to_string(), value.to_string()); + } + } + + #[test] + fn terminal_check_warns_for_dumb_terminal() { + let mut inputs = terminal_inputs(); + inputs.info.name = TerminalName::Dumb; + inputs.info.term = Some("dumb".to_string()); + set_terminal_env(&mut inputs, "TERM", "dumb"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "TERM=dumb - colors and cursor control are disabled" + ); + assert_eq!(check.issues.len(), 1); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("set TERM to a real value, for example xterm-256color") + ); + } + + #[test] + fn terminal_check_warns_for_narrow_terminal() { + let mut inputs = terminal_inputs(); + inputs.terminal_size = Ok((79, 24)); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "width 79 cols - output may wrap (recommended >=80)" + ); + assert_eq!(check.issues[0].expected.as_deref(), Some(">= 80 columns")); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("resize the window to at least 80 columns") + ); + } + + #[test] + fn terminal_check_warns_for_declared_narrow_terminal() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "COLUMNS", "60"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "COLUMNS=60 - output may wrap (recommended >=80)" + ); + assert!(check.details.contains(&"COLUMNS: 60".to_string())); + assert_eq!(check.issues[0].fields, vec!["COLUMNS".to_string()]); + } + + #[test] + fn terminal_check_warns_for_non_utf8_locale() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "LANG", "C"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "locale is not UTF-8 - unicode glyphs may render incorrectly" + ); + assert!(check.details.contains(&"effective locale: C".to_string())); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("export LANG=en_US.UTF-8 or another UTF-8 locale") + ); + } + + #[test] + fn terminal_check_warns_for_unreadable_terminfo_path() { + let tempdir = tempfile::tempdir().expect("create tempdir"); + let missing = tempdir.path().join("missing-terminfo"); + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "TERMINFO", &missing.to_string_lossy()); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "TERMINFO unreadable - terminal capabilities are unknown" + ); + assert!( + check + .details + .iter() + .any(|detail| detail.starts_with("TERMINFO: ") && detail.ends_with(" (missing)")) + ); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("check that $TERMINFO points to a readable directory") + ); + } + + #[test] + fn terminal_check_reports_remote_indicators_as_present_only() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "SSH_CONNECTION", "10.0.0.1 1 10.0.0.2 22"); + + let check = terminal_check_from_inputs(inputs); + + assert!( + check + .details + .contains(&"SSH_CONNECTION: present".to_string()) + ); + assert!( + !check + .details + .iter() + .any(|detail| detail.contains("10.0.0.1")) + ); + } + + #[test] + fn terminal_check_keeps_tmux_probe_failures_non_fatal() { + let mut inputs = terminal_inputs(); + inputs.info.multiplexer = Some(Multiplexer::Tmux { version: None }); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "terminal metadata was detected"); + } + + #[test] + fn color_output_summary_reports_disabled_reasons() { + let mut inputs = terminal_inputs(); + inputs.no_color_flag = true; + assert_eq!(color_output_summary(&inputs), "disabled (--no-color)"); + + inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "NO_COLOR", ""); + assert_eq!(color_output_summary(&inputs), "disabled (NO_COLOR)"); + + inputs = terminal_inputs(); + inputs.info.term = Some("dumb".to_string()); + set_terminal_env(&mut inputs, "TERM", "dumb"); + assert_eq!(color_output_summary(&inputs), "disabled (TERM=dumb)"); + + inputs = terminal_inputs(); + inputs.stdout_is_terminal = false; + assert_eq!( + color_output_summary(&inputs), + "disabled (stdout is not a terminal)" + ); + } +} diff --git a/codex-rs/cli/src/doctor/background.rs b/codex-rs/cli/src/doctor/background.rs new file mode 100644 index 0000000000..0ec5132cd3 --- /dev/null +++ b/codex-rs/cli/src/doctor/background.rs @@ -0,0 +1,150 @@ +//! Reports app-server daemon state without starting or stopping the daemon. +//! +//! The background-server check is deliberately passive. It reads the daemon +//! state directory, PID files, settings file, and control socket path, then +//! attempts only a local socket connection when a socket already exists. That +//! keeps doctor safe to run while the user is debugging startup or update-loop +//! issues. + +use std::path::Path; + +use codex_core::config::Config; + +use super::CheckStatus; +use super::DoctorCheck; + +const STATE_DIR_NAME: &str = "app-server-daemon"; +const SETTINGS_FILE_NAME: &str = "settings.json"; +const PID_FILE_NAME: &str = "app-server.pid"; +const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid"; + +/// Builds the app-server status row from existing daemon state. +/// +/// Missing files are expected for the ephemeral/not-running case and should not +/// be treated as failures. A stale socket is a warning because it can explain +/// client connection problems without proving the daemon itself is broken. +pub(super) fn background_server_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let state_dir = config.codex_home.join(STATE_DIR_NAME); + details.push(format!("daemon state dir: {}", state_dir.display())); + push_file_detail( + &mut details, + "settings", + &state_dir.join(SETTINGS_FILE_NAME), + ); + push_file_detail(&mut details, "pid file", &state_dir.join(PID_FILE_NAME)); + push_file_detail( + &mut details, + "update-loop pid file", + &state_dir.join(UPDATE_PID_FILE_NAME), + ); + + let socket_path = match codex_app_server::app_server_control_socket_path(&config.codex_home) { + Ok(socket_path) => socket_path, + Err(err) => { + return DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Warning, + "background server socket path could not be resolved", + ) + .details(details) + .detail(err.to_string()); + } + }; + + details.push(format!("control socket: {}", socket_path.display())); + let status = socket_status(socket_path.as_path()); + details.push(format!("status: {}", status.detail_label())); + details.push(format!("mode: {}", server_mode(&state_dir))); + + let mut check = DoctorCheck::new( + "app_server.status", + "app-server", + status.check_status(), + status.summary(), + ) + .details(details); + if status.check_status() == CheckStatus::Warning { + check = check.remediation("Run codex app-server daemon version for more details."); + } + check +} + +fn push_file_detail(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + details.push(format!("{label}: {} (file)", path.display())); + } + Ok(_) => { + details.push(format!("{label}: {} (not a file)", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push(format!("{label}: {} (missing)", path.display())); + } + Err(err) => details.push(format!("{label}: {} ({err})", path.display())), + } +} + +fn server_mode(state_dir: &Path) -> &'static str { + if state_dir.join(SETTINGS_FILE_NAME).is_file() { + "persistent" + } else { + "ephemeral" + } +} + +#[derive(Clone, Copy)] +enum SocketStatus { + NotRunning, + Running, + #[cfg(unix)] + StaleOrUnreachable, +} + +impl SocketStatus { + fn check_status(self) -> CheckStatus { + match self { + Self::NotRunning | Self::Running => CheckStatus::Ok, + #[cfg(unix)] + Self::StaleOrUnreachable => CheckStatus::Warning, + } + } + + fn summary(self) -> &'static str { + match self { + Self::NotRunning => "background server is not running", + Self::Running => "background server is running", + #[cfg(unix)] + Self::StaleOrUnreachable => "background server socket is stale or unreachable", + } + } + + fn detail_label(self) -> &'static str { + match self { + Self::NotRunning => "not running", + Self::Running => "running", + #[cfg(unix)] + Self::StaleOrUnreachable => "stale or unreachable", + } + } +} + +fn socket_status(socket_path: &Path) -> SocketStatus { + if !socket_path.exists() { + return SocketStatus::NotRunning; + } + + #[cfg(unix)] + { + match std::os::unix::net::UnixStream::connect(socket_path) { + Ok(_) => SocketStatus::Running, + Err(_) => SocketStatus::StaleOrUnreachable, + } + } + + #[cfg(not(unix))] + { + SocketStatus::Running + } +} diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs new file mode 100644 index 0000000000..da5fd20ce8 --- /dev/null +++ b/codex-rs/cli/src/doctor/output.rs @@ -0,0 +1,1555 @@ +//! Renders doctor reports for terminal users. +//! +//! The renderer is intentionally separate from check construction so the JSON +//! report can stay stable while the human view optimizes for scanability. It +//! groups checks by concern, colors only status/actionable tokens, and redacts +//! sensitive detail lines before showing them in detailed output. + +mod detail; + +use std::fmt::Write as _; + +use detail::HumanDetail; +use detail::detail_lines; +use owo_colors::OwoColorize; +use owo_colors::XtermColors; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorReport; + +const NAME_WIDTH: usize = 12; +const DETAIL_LABEL_WIDTH: usize = 24; +const SEPARATOR_WIDTH: usize = 61; + +const GROUPS: &[OutputGroup] = &[ + OutputGroup { + title: "Environment", + keys: &["runtime", "install", "search", "terminal", "state"], + }, + OutputGroup { + title: "Configuration", + keys: &["config", "auth", "mcp", "sandbox"], + }, + OutputGroup { + title: "Updates", + keys: &["updates"], + }, + OutputGroup { + title: "Connectivity", + keys: &["network", "websocket", "reachability"], + }, + OutputGroup { + title: "Background Server", + keys: &["app-server"], + }, +]; + +struct OutputGroup { + title: &'static str, + keys: &'static [&'static str], +} + +/// Rendering controls for human doctor output. +/// +/// These options affect presentation only. They must not change which checks +/// run or which fields are present in the underlying JSON report. +#[derive(Clone, Copy, Debug)] +pub(super) struct HumanOutputOptions { + pub(super) show_details: bool, + pub(super) show_all: bool, + pub(super) ascii: bool, + pub(super) color_enabled: bool, +} + +/// Formats a doctor report into the grouped terminal layout. +/// +/// The renderer expects checks to carry stable categories, but it owns their +/// display order. Adding a new category without adding it to GROUPS keeps JSON +/// output intact but hides that row from the human view. +pub(super) fn render_human_report(report: &DoctorReport, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "{} {}", + bold("Codex Doctor", options), + dim(&header_suffix(report), options) + ); + out.push('\n'); + + let notes = notes_for_report(report); + if !notes.is_empty() { + let _ = writeln!(out, "{}", bold("Notes", options)); + for note in ¬es { + write_note_row(&mut out, note, options); + } + let _ = writeln!(out, "{}", dim(&separator(options), options)); + out.push('\n'); + } + + let mut wrote_group = false; + for group in GROUPS { + let group_checks = checks_for_group(report, group); + if group_checks.is_empty() { + continue; + } + + if wrote_group { + out.push('\n'); + } + wrote_group = true; + + let _ = writeln!(out, "{}", bold(group.title, options)); + for check in group_checks { + write_check_row(&mut out, check, options); + } + } + + out.push('\n'); + let _ = writeln!(out, "{}", dim(&separator(options), options)); + let _ = writeln!(out, "{}", summary_line(report, options)); + out.push('\n'); + write_footer(&mut out, options); + out +} + +fn checks_for_group<'a>(report: &'a DoctorReport, group: &OutputGroup) -> Vec<&'a DoctorCheck> { + group + .keys + .iter() + .flat_map(|key| { + report + .checks + .iter() + .filter(move |check| check.category == *key) + }) + .collect() +} + +fn write_check_row(out: &mut String, check: &DoctorCheck, options: HumanOutputOptions) { + let description = row_description(check, options); + let status = display_status(check); + let _ = writeln!( + out, + " {}{} {}", + status_marker_slot(status, options), + format_args!("{: { + let is_issue = expected.is_some(); + let label = format!("{label: { + let spacer = " ".repeat(DETAIL_LABEL_WIDTH); + let _ = writeln!( + out, + " {} {}", + detail_label(&spacer, options), + detail_value(&value, options) + ); + } + HumanDetail::Bullet(value) => { + let _ = writeln!( + out, + " {} {}", + very_dim(if options.ascii { "-" } else { "·" }, options), + dim(&highlight_actions(&value, options), options) + ); + } + HumanDetail::Remedy(value) => { + let marker = if options.ascii { "->" } else { "→" }; + let _ = writeln!( + out, + " {} {}", + orange(marker, options), + highlight_actions(&value, options) + ); + } + } +} + +fn row_description(check: &DoctorCheck, options: HumanOutputOptions) -> String { + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) && !check.issues.is_empty() + { + return issue_summary(check); + } + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) + && let Some(remediation) = &check.remediation + { + let dash = if options.ascii { " - " } else { " — " }; + let summary = &check.summary; + return format!("{summary}{dash}{remediation}"); + } + + display_summary(check, options) +} + +fn issue_summary(check: &DoctorCheck) -> String { + match check.issues.as_slice() { + [] => check.summary.clone(), + [issue] => issue.cause.clone(), + issues => format!( + "{} issues - {}", + issues.len(), + issues + .iter() + .take(2) + .map(|issue| issue.cause.as_str()) + .collect::>() + .join("; ") + ), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayStatus { + Ok, + Update, + Note, + Warning, + Fail, + Idle, +} + +struct DoctorNote { + status: DisplayStatus, + name: String, + summary: String, +} + +fn display_status(check: &DoctorCheck) -> DisplayStatus { + if check.category == "app-server" + && check.status == CheckStatus::Ok + && check + .details + .iter() + .any(|detail| detail == "status: not running") + { + return DisplayStatus::Idle; + } + + match check.status { + CheckStatus::Ok => DisplayStatus::Ok, + CheckStatus::Warning => DisplayStatus::Warning, + CheckStatus::Fail => DisplayStatus::Fail, + } +} + +fn status_marker(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = if options.ascii { + match status { + DisplayStatus::Ok => "[ok]", + DisplayStatus::Update => "[up]", + DisplayStatus::Note | DisplayStatus::Warning => "[!!]", + DisplayStatus::Fail => "[XX]", + DisplayStatus::Idle => "[--]", + } + } else { + match status { + DisplayStatus::Ok => "✓", + DisplayStatus::Update => "↑", + DisplayStatus::Note | DisplayStatus::Warning => "⚠", + DisplayStatus::Fail => "✗", + DisplayStatus::Idle => "○", + } + }; + + match status { + DisplayStatus::Ok => green(marker, options), + DisplayStatus::Update => amber(marker, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(marker, options), + DisplayStatus::Fail => red(marker, options), + DisplayStatus::Idle => dim(marker, options), + } +} + +fn status_marker_slot(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = status_marker(status, options); + format!("{marker} ") +} + +fn style_description( + description: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let highlighted = highlight_actions(description, options); + match status { + DisplayStatus::Ok | DisplayStatus::Idle => dim(&highlighted, options), + DisplayStatus::Update => amber(&highlighted, options), + DisplayStatus::Note | DisplayStatus::Warning | DisplayStatus::Fail => highlighted, + } +} + +fn detail_marker(is_issue: bool, options: HumanOutputOptions) -> String { + if !is_issue { + return " ".to_string(); + } + orange(if options.ascii { ">" } else { "▸" }, options) +} + +fn style_note_summary(note: &DoctorNote, options: HumanOutputOptions) -> String { + if note.status == DisplayStatus::Update { + return style_update_note_summary(¬e.summary, options); + } + style_description(¬e.summary, note.status, options) +} + +fn style_update_note_summary(summary: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return summary.to_string(); + } + + let Some((version, rest)) = summary.split_once(" available") else { + return amber(summary, options); + }; + let Some((action, parenthetical)) = rest.split_once(" (") else { + return format!( + "{}{}", + amber(&format!("{version} available"), options), + amber(rest, options) + ); + }; + format!( + "{}{} {}", + amber(&format!("{version} available"), options), + amber(action, options), + dim(&format!("({parenthetical}"), options) + ) +} + +fn summary_line(report: &DoctorReport, options: HumanOutputOptions) -> String { + let notes = notes_for_report(report); + let counts = StatusCounts::from_report(report, notes.len()); + let separator = dim(if options.ascii { " | " } else { " · " }, options); + let status = overall_status_label(report.overall_status); + let mut parts = vec![count_label(counts.ok, "ok", DisplayStatus::Ok, options)]; + if counts.idle > 0 { + parts.push(count_label( + counts.idle, + "idle", + DisplayStatus::Idle, + options, + )); + } + if counts.notes > 0 { + parts.push(count_label( + counts.notes, + "notes", + DisplayStatus::Note, + options, + )); + } + parts.push(count_label( + counts.warning, + "warn", + DisplayStatus::Warning, + options, + )); + parts.push(count_label( + counts.fail, + "fail", + DisplayStatus::Fail, + options, + )); + format!( + "{} {}", + parts.join(&separator), + styled_overall_status(status, report.overall_status, options) + ) +} + +fn count_label( + count: usize, + label: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let count = dim(&count.to_string(), options); + let label = match status { + DisplayStatus::Ok => green(label, options), + DisplayStatus::Update => amber(label, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(label, options), + DisplayStatus::Fail => red(label, options), + DisplayStatus::Idle => dim(label, options), + }; + format!("{count} {label}") +} + +fn overall_status_label(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Ok => "ok", + CheckStatus::Warning => "degraded", + CheckStatus::Fail => "failed", + } +} + +fn styled_overall_status(label: &str, status: CheckStatus, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return label.to_string(); + } + + match status { + CheckStatus::Ok => label.green().bold().to_string(), + CheckStatus::Warning => label.yellow().bold().to_string(), + CheckStatus::Fail => label.red().bold().to_string(), + } +} + +fn write_footer(out: &mut String, options: HumanOutputOptions) { + if options.show_details { + let _ = writeln!( + out, + "{} {:<24} {} {}", + cyan("--summary", options), + dim("compact output", options), + cyan("--all", options), + dim("expand truncated lists", options) + ); + } else { + let _ = writeln!( + out, + "{}", + dim( + "Run codex doctor without --summary for detailed diagnostics.", + options + ) + ); + let _ = writeln!( + out, + "{} {:<28} {} {}", + cyan("--all", options), + dim("expand truncated lists", options), + cyan("--json", options), + dim("redacted report", options) + ); + return; + } + let _ = writeln!( + out, + "{} {}", + cyan("--json", options), + dim("redacted report", options) + ); +} + +fn header_suffix(report: &DoctorReport) -> String { + let version = format!("v{}", report.codex_version); + report + .checks + .iter() + .find(|check| check.category == "runtime") + .and_then(|check| detail::detail_value(check, "platform")) + .map_or(version.clone(), |platform| { + format!("{version} · {platform}") + }) +} + +fn notes_for_report(report: &DoctorReport) -> Vec { + let mut notes = Vec::new(); + if let Some(check) = find_check(report, "updates") { + update_note(check, report) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "state") { + rollout_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "sandbox") { + sandbox_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + non_ok_notes(report) + .into_iter() + .for_each(|note| notes.push(note)); + auth_reachability_note(report) + .into_iter() + .for_each(|note| notes.push(note)); + notes +} + +fn find_check<'a>(report: &'a DoctorReport, category: &str) -> Option<&'a DoctorCheck> { + report + .checks + .iter() + .find(|check| check.category == category) +} + +fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option { + let status = detail::detail_value(check, "latest version status")?; + if !status.contains("newer version is available") { + return None; + } + let latest = detail::detail_value(check, "latest version") + .or_else(|| detail::detail_value(check, "cached latest version")) + .unwrap_or_else(|| "newer version".to_string()); + let dismissed = detail::detail_value(check, "dismissed version"); + let mut parenthetical = format!("current {}", report.codex_version); + if let Some(dismissed) = dismissed + && !detail::is_falsy(&dismissed) + { + parenthetical.push_str(&format!(", dismissed {dismissed}")); + } + Some(DoctorNote { + status: DisplayStatus::Update, + name: "updates".to_string(), + summary: format!("{latest} available ({parenthetical})"), + }) +} + +fn rollout_note(check: &DoctorCheck) -> Option { + let active = detail::detail_value(check, "active rollout files")?; + let (files, bytes) = detail::rollout_files_and_bytes(&active)?; + if files < 1000 && bytes < 1024 * 1024 * 1024 { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "rollouts".to_string(), + summary: format!( + "{} active files · {} on disk", + detail::format_count(files), + detail::format_bytes(bytes) + ), + }) +} + +fn sandbox_note(check: &DoctorCheck) -> Option { + let filesystem = detail::detail_value(check, "filesystem sandbox")?; + let network = detail::detail_value(check, "network sandbox")?; + if filesystem == "restricted" && network == "restricted" { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "sandbox".to_string(), + summary: format!("filesystem {filesystem} · network {network}"), + }) +} + +fn non_ok_notes(report: &DoctorReport) -> Vec { + report + .checks + .iter() + .filter(|check| matches!(check.status, CheckStatus::Warning | CheckStatus::Fail)) + .map(|check| DoctorNote { + status: display_status(check), + name: check.category.clone(), + summary: actionable_note_summary(check), + }) + .collect() +} + +fn actionable_note_summary(check: &DoctorCheck) -> String { + if !check.issues.is_empty() { + return issue_summary(check); + } + if let Some(remediation) = &check.remediation { + return format!("{} - {remediation}", check.summary); + } + check.summary.clone() +} + +fn auth_reachability_note(report: &DoctorReport) -> Option { + let websocket = find_check(report, "websocket")?; + let reachability = find_check(report, "reachability")?; + let auth_mode = detail::detail_value(websocket, "auth mode")?; + let reachability_mode = detail::detail_value(reachability, "reachability mode")?; + let auth_mode_lower = auth_mode.to_ascii_lowercase(); + let reachability_mode_lower = reachability_mode.to_ascii_lowercase(); + if auth_mode_lower.contains("chatgpt") && reachability_mode_lower.contains("api key") { + return Some(DoctorNote { + status: DisplayStatus::Warning, + name: "auth".to_string(), + summary: "mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode".to_string(), + }); + } + None +} + +fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String { + match check.category.as_str() { + "runtime" => runtime_summary(check), + "install" if check.status == CheckStatus::Ok => "consistent".to_string(), + "search" => search_summary(check), + "terminal" => terminal_summary(check), + "state" => state_summary(check), + "config" if check.status == CheckStatus::Ok => "loaded".to_string(), + "mcp" => mcp_summary(check), + "sandbox" => sandbox_summary(check), + "network" => network_summary(check), + "websocket" => websocket_summary(check), + "app-server" => app_server_summary(check), + _ => check.summary.clone(), + } +} + +fn runtime_summary(check: &DoctorCheck) -> String { + if detail::detail_value(check, "current executable") + .is_some_and(|path| path.contains("/target/debug/")) + { + return "local debug build".to_string(); + } + detail::detail_value(check, "install method").unwrap_or_else(|| check.summary.clone()) +} + +fn search_summary(check: &DoctorCheck) -> String { + let provider = detail::detail_value(check, "search provider"); + let command = detail::detail_value(check, "search command"); + let readiness = detail::detail_value(check, "search command readiness"); + match (readiness, provider, command) { + (Some(readiness), Some(provider), Some(command)) if check.status == CheckStatus::Ok => { + format!("{readiness} ({provider}, `{command}`)") + } + _ => check.summary.clone(), + } +} + +fn terminal_summary(check: &DoctorCheck) -> String { + let mut parts = Vec::new(); + if let Some(terminal) = detail::detail_value(check, "terminal") { + let version = detail::detail_value(check, "terminal version"); + parts.push(version.map_or(terminal.clone(), |version| format!("{terminal} {version}"))); + } + if let Some(multiplexer) = detail::detail_value(check, "multiplexer") { + parts.push(multiplexer); + } + if let Some(term) = detail::detail_value(check, "TERM") { + parts.push(format!("TERM={term}")); + } + if parts.is_empty() { + check.summary.clone() + } else { + parts.join(" · ") + } +} + +fn state_summary(check: &DoctorCheck) -> String { + let state_ok = + detail::detail_value(check, "state DB integrity").is_some_and(|value| value == "ok"); + let log_ok = detail::detail_value(check, "log DB integrity").is_some_and(|value| value == "ok"); + if state_ok && log_ok { + "databases healthy".to_string() + } else { + check.summary.clone() + } +} + +fn mcp_summary(check: &DoctorCheck) -> String { + let Some(count) = detail::detail_value(check, "configured servers") else { + return check.summary.clone(); + }; + let disabled = + detail::detail_value(check, "disabled servers").unwrap_or_else(|| "0".to_string()); + let transports = check + .details + .iter() + .filter_map(|detail| detail.split_once(" servers: ")) + .filter(|(transport, _)| *transport != "configured" && *transport != "disabled") + .map(|(transport, count)| format!("{count} {transport}")) + .collect::>(); + if transports.is_empty() { + format!("{count} servers · {disabled} disabled") + } else { + format!( + "{} server ({}) · {} disabled", + count, + transports.join(", "), + disabled + ) + } +} + +fn sandbox_summary(check: &DoctorCheck) -> String { + let approval = detail::detail_value(check, "approval policy"); + let filesystem = detail::detail_value(check, "filesystem sandbox"); + let network = detail::detail_value(check, "network sandbox"); + match (approval, filesystem, network) { + (Some(approval), Some(filesystem), Some(network)) => { + format!("{filesystem} fs + {network} network · approval {approval}") + } + _ => check.summary.clone(), + } +} + +fn network_summary(check: &DoctorCheck) -> String { + detail::detail_value(check, "proxy env vars") + .map(|value| { + if value == "none" { + "no proxy env vars".to_string() + } else { + "proxy env vars present".to_string() + } + }) + .unwrap_or_else(|| check.summary.clone()) +} + +fn websocket_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "handshake result") + .or_else(|| detail::detail_value(check, "handshake status")); + let timeout = detail::detail_value(check, "connect timeout") + .map(|value| value.replace("000 ms", "s").replace(" ms", "ms")); + match (status, timeout) { + (Some(status), Some(timeout)) => format!("connected ({status}) · {timeout} timeout"), + _ => check.summary.clone(), + } +} + +fn app_server_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "status"); + let mode = detail::detail_value(check, "mode"); + match (status, mode) { + (Some(status), Some(mode)) => format!("{status} ({mode} mode)"), + _ => check.summary.clone(), + } +} + +fn separator(options: HumanOutputOptions) -> String { + if options.ascii { + "-".repeat(SEPARATOR_WIDTH) + } else { + "─".repeat(SEPARATOR_WIDTH) + } +} + +fn highlight_actions(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&highlight_flags(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&highlight_flags(part, options)); + } + in_code = !in_code; + } + out +} + +fn highlight_flags(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + if bare.starts_with("--") { + let highlighted = cyan(bare, options); + format!("{highlighted}{punctuation}{suffix}") + } else { + token.to_string() + } + }) + .collect() +} + +pub(super) fn redact_detail(detail: &str) -> String { + let lower = detail.to_ascii_lowercase(); + let label = lower.split(':').next().unwrap_or_default(); + if label.contains("env var") { + return redact_urls(detail); + } + if detail + .split_once(": ") + .is_some_and(|(_, value)| is_safe_presence_value(value)) + { + return redact_urls(detail); + } + + let secret_keys = [ + "openai_api_key", + "codex_api_key", + "codex_access_token", + "authorization", + "bearer_token", + "token", + "secret", + ]; + if secret_keys.iter().any(|key| lower.contains(key)) { + let name = detail.split(':').next().unwrap_or(detail); + format!("{name}: ") + } else { + redact_urls(detail) + } +} + +fn is_safe_presence_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "false" | "yes" | "no" | "present" | "absent" | "missing" | "not set" + ) +} + +fn redact_urls(detail: &str) -> String { + detail + .split_inclusive(char::is_whitespace) + .map(redact_url_token) + .collect() +} + +fn redact_url_token(token: &str) -> String { + let Some(scheme_end) = token.find("://") else { + return token.to_string(); + }; + let mut suffix_start = token.len(); + while suffix_start > scheme_end + 3 + && matches!( + token.as_bytes()[suffix_start - 1], + b' ' | b'\t' | b'\n' | b'\r' | b'.' | b',' | b';' | b':' | b')' | b']' + ) + { + suffix_start -= 1; + } + + let (body, suffix) = token.split_at(suffix_start); + let scheme_prefix_end = scheme_end + 3; + let rest = &body[scheme_prefix_end..]; + let authority_end = rest + .find(['/', '?', '#']) + .map(|index| scheme_prefix_end + index) + .unwrap_or(body.len()); + let authority = &body[scheme_prefix_end..authority_end]; + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let path = &body[authority_end..]; + let path = path + .find(['?', '#']) + .map(|index| &path[..index]) + .unwrap_or(path); + let path = redact_url_path(path); + format!( + "{}{}{}{}", + &body[..scheme_prefix_end], + authority, + path, + suffix + ) +} + +fn redact_url_path(path: &str) -> String { + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + let Some(first_segment) = segments.next() else { + return path.to_string(); + }; + if segments.next().is_some() { + format!("/{first_segment}/") + } else { + path.to_string() + } +} + +#[derive(Default)] +struct StatusCounts { + ok: usize, + idle: usize, + notes: usize, + warning: usize, + fail: usize, +} + +impl StatusCounts { + fn from_report(report: &DoctorReport, notes: usize) -> Self { + let mut counts = Self { + notes, + ..Self::default() + }; + for check in &report.checks { + match display_status(check) { + DisplayStatus::Ok => counts.ok += 1, + DisplayStatus::Idle => counts.idle += 1, + DisplayStatus::Warning => counts.warning += 1, + DisplayStatus::Fail => counts.fail += 1, + DisplayStatus::Update | DisplayStatus::Note => {} + } + } + counts + } +} + +fn bold(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.bold().to_string() + } else { + text.to_string() + } +} + +fn dim(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.dimmed().to_string() + } else { + text.to_string() + } +} + +fn very_dim(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 238, options) +} + +fn detail_label(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 240, options) +} + +fn detail_value(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + style_detail_text(text, options) +} + +fn style_detail_text(text: &str, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&style_detail_plain_text(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&style_detail_plain_text(part, options)); + } + in_code = !in_code; + } + out +} + +fn style_detail_plain_text(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| style_detail_token(token, options)) + .collect() +} + +fn style_detail_token(token: &str, options: HumanOutputOptions) -> String { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + let styled = style_detail_bare_token(bare, options); + format!("{styled}{punctuation}{suffix}") +} + +fn style_detail_bare_token(bare: &str, options: HumanOutputOptions) -> String { + if bare.is_empty() { + return String::new(); + } + if bare == "" { + return color256(&bare.italic().to_string(), /*code*/ 244, options); + } + if bare.contains("(missing)") || detail::is_falsy(bare) { + return color256(bare, /*code*/ 240, options); + } + if let Some((label, value)) = bare.split_once(':') + && detail::is_falsy(value) + { + return format!("{label}:{}", color256(value, /*code*/ 240, options)); + } + if bare == "ok" { + return green(bare, options); + } + if bare.starts_with("--") || looks_copyable(bare) { + return cyan(bare, options); + } + if matches!(bare, "B" | "KB" | "MB" | "GB" | "TB" | "files" | "file") { + return dim(bare, options); + } + bare.to_string() +} + +fn green(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 10, options) +} + +fn amber(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 220, options) +} + +fn orange(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 214, options) +} + +fn red(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 196, options) +} + +fn cyan(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 117, options) +} + +fn color256(text: &str, code: u8, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.color(XtermColors::from(code)).to_string() + } else { + text.to_string() + } +} + +fn looks_copyable(text: &str) -> bool { + text.starts_with("http://") + || text.starts_with("https://") + || text.starts_with("wss://") + || text.starts_with("~/") + || text.starts_with('/') + || text.starts_with("./") + || text.starts_with("../") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn detailed_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn summary_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn detailed_all_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: true, + ascii: false, + color_enabled: false, + } + } + + fn detailed_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: true, + } + } + + fn sample_report() -> DoctorReport { + let checks = vec![ + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + "running local build on darwin-arm64", + ), + DoctorCheck::new( + "installation", + "install", + CheckStatus::Ok, + "installation looks consistent", + ), + DoctorCheck::new( + "runtime.search", + "search", + CheckStatus::Ok, + "search is OK (bundled)", + ), + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "narrow terminal", + ), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ), + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "token expired", + ) + .detail("OPENAI_API_KEY: present") + .remediation("Run `codex login`."), + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ), + DoctorCheck::new( + "network.env", + "network", + CheckStatus::Ok, + "network environment readable", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + ]; + DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Fail, + codex_version: "0.0.0".to_string(), + checks, + } + } + + #[test] + fn render_human_report_includes_details_by_default_without_color() { + let rendered = render_human_report(&sample_report(), detailed_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + managed by npm: no · bun: no · package root — + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + OPENAI_API_KEY present + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +--summary compact output --all expand truncated lists +--json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_summary_output_without_color() { + let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_ascii_output() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: true, + color_enabled: false, + }, + ); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + [!!] terminal narrow terminal + [XX] auth token expired - Run `codex login`. +------------------------------------------------------------- + +Environment + [ok] runtime running local build on darwin-arm64 + [ok] install consistent + [ok] search search is OK (bundled) + [!!] terminal narrow terminal + [ok] state state paths inspectable + +Configuration + [XX] auth token expired - Run `codex login`. + +Updates + [ok] updates update configuration is locally consistent + +Connectivity + [ok] network network environment readable + [ok] websocket Responses WebSocket handshake succeeded + [ok] reachability active provider endpoints are reachable over HTTP + +Background Server + [ok] app-server background server is not running + +{} +9 ok | 2 notes | 1 warn | 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "-".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_includes_redacted_details() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + }, + ); + assert!(rendered.contains(" OPENAI_API_KEY present")); + } + + #[test] + fn render_human_report_explains_terminal_warning_issue() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .detail("terminal: Ghostty") + .detail("terminal version: 1.3.1") + .detail("terminal size: 79x26") + .issue( + super::super::DoctorIssue::new( + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .expected(">= 80 columns") + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ), + ], + }; + + let rendered = render_human_report(&report, detailed_no_color_unicode_options()); + + assert!( + rendered.contains("⚠ terminal width 79 cols - output may wrap (recommended >=80)") + ); + assert!(rendered.contains("▸ terminal size 79x26 (expected >= 80 columns)")); + assert!(rendered.contains("→ resize the window to at least 80 columns")); + assert!(!rendered.contains("⚠ terminal Ghostty 1.3.1")); + } + + #[test] + fn render_human_report_promotes_notes_without_changing_statuses() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ) + .detail("latest version status: newer version is available") + .detail("latest version: 0.130.0") + .detail("dismissed version: 0.128.0"), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ) + .detail("active rollout files: 1515 files, 2702146365 total bytes, 1783594 average bytes"), + DoctorCheck::new( + "sandbox.helpers", + "sandbox", + CheckStatus::Ok, + "sandbox configuration is readable", + ) + .detail("filesystem sandbox: danger-full-access") + .detail("network sandbox: restricted") + .detail("approval policy: Never"), + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .detail("auth mode: chatgpt"), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ) + .detail("reachability mode: API key auth"), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ) + .detail("status: not running") + .detail("mode: ephemeral"), + ], + }; + + let rendered = render_human_report(&report, summary_no_color_unicode_options()); + + assert!(rendered.contains("Notes\n ↑ updates")); + assert!(rendered.contains("0.130.0 available (current 0.0.0, dismissed 0.128.0)")); + assert!(rendered.contains("⚠ rollouts")); + assert!(rendered.contains("⚠ sandbox")); + assert!(rendered.contains("⚠ mcp")); + assert!(rendered.contains( + "⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode" + )); + assert!(rendered.contains("○ app-server not running (ephemeral mode)")); + assert!(rendered.contains("5 ok · 1 idle · 5 notes · 1 warn · 0 fail degraded")); + } + + #[test] + fn render_human_report_expands_feature_flags_with_all() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Ok, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new("config.load", "config", CheckStatus::Ok, "config loaded") + .detail("model: gpt-5.5") + .detail("model provider: openai") + .detail("feature flags enabled: 3") + .detail("enabled feature flags: shell_tool, memories, goals") + .detail("feature flag overrides: memories=true"), + ], + }; + + let compact = render_human_report(&report, detailed_no_color_unicode_options()); + let expanded = render_human_report(&report, detailed_all_no_color_unicode_options()); + + assert!(!compact.contains("enabled flags")); + assert!( + compact.contains( + "feature flags 3 enabled · 1 overridden (full list with --all)" + ) + ); + assert!(expanded.contains("enabled flags shell_tool, memories, goals")); + } + + #[test] + fn detail_value_colors_inline_statuses_and_low_signal_values() { + let rendered = detail_value( + "npm: no · commit unknown · integrity ok · ~/code/codex/target/debug/codex · ", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("npm: \u{1b}[38;5;240mno")); + assert!(rendered.contains("\u{1b}[38;5;240munknown")); + assert!(rendered.contains("\u{1b}[38;5;10mok")); + assert!(rendered.contains("\u{1b}[38;5;117m~/code/codex/target/debug/codex")); + assert!(rendered.contains("\u{1b}[38;5;244m")); + } + + #[test] + fn update_note_emphasizes_available_version_and_dims_context() { + let rendered = style_update_note_summary( + "0.130.0 available (current 0.0.0, dismissed 0.128.0)", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("\u{1b}[38;5;220m0.130.0 available")); + assert!(rendered.contains("\u{1b}[2m(current 0.0.0, dismissed 0.128.0)")); + } + + #[test] + fn redact_detail_sanitizes_urls() { + let redacted = redact_detail( + "reachability failed: https://user:pass@example.com/mcp?x=abc#frag (connect failed)", + ); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp (connect failed)" + ); + } + + #[test] + fn redact_detail_sanitizes_secret_url_path_segments() { + let redacted = redact_detail("reachability failed: https://example.com/mcp/abc123xyz"); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp/" + ); + } + + #[test] + fn redact_detail_preserves_env_var_names() { + assert_eq!( + redact_detail("auth env vars present: OPENAI_API_KEY, CODEX_API_KEY"), + "auth env vars present: OPENAI_API_KEY, CODEX_API_KEY" + ); + } + + #[test] + fn redact_detail_preserves_secret_presence_booleans() { + assert_eq!( + redact_detail("stored ChatGPT tokens: true"), + "stored ChatGPT tokens: true" + ); + assert_eq!( + redact_detail("stored ChatGPT tokens: false"), + "stored ChatGPT tokens: false" + ); + } + + #[test] + fn render_human_report_can_emit_color() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: true, + }, + ); + assert!(rendered.contains("\u{1b}[")); + } +} diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs new file mode 100644 index 0000000000..4b5f49991b --- /dev/null +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -0,0 +1,648 @@ +//! Converts raw doctor detail strings into human-oriented rows. +//! +//! Checks intentionally store details as simple redacted `label: value` strings +//! so JSON serialization and human rendering share the same source data. This +//! module owns the presentation-only transformations: collapsing noisy booleans, +//! truncating long paths for terminal output, grouping repeated values, and +//! keeping the `--all` expansion behavior out of check construction. + +use std::collections::BTreeSet; +use std::env; + +use super::DoctorCheck; +use super::HumanOutputOptions; +use super::redact_detail; + +const LIST_LIMIT: usize = 7; +const PATH_LIMIT: usize = 48; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum HumanDetail { + Row { + label: String, + value: String, + expected: Option, + }, + Continuation(String), + Bullet(String), + Remedy(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParsedDetail { + label: String, + value: String, +} + +pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec { + let parsed = parsed_details(check); + let details = match check.category.as_str() { + "runtime" => runtime_details(&parsed), + "install" => install_details(&parsed, options), + "config" => config_details(&parsed, options), + "state" => state_details(&parsed), + _ => generic_details(&parsed), + }; + let mut details = details + .into_iter() + .map(|detail| attach_issue_metadata(detail, check)) + .map(|detail| humanize_detail(detail, options)) + .collect::>(); + details.extend(issue_remedies(check)); + details +} + +pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option { + parsed_details(check) + .into_iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value) +} + +pub(super) fn rollout_summary(value: &str) -> Option { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, rest) = rest.split_once(" total bytes, ")?; + let (average_bytes, _) = rest.split_once(" average bytes")?; + let files = files.trim().parse::().ok()?; + let total_bytes = total_bytes.trim().parse::().ok()?; + let average_bytes = average_bytes.trim().parse::().ok()?; + Some(format!( + "{} files · {} (avg {})", + format_count(files), + format_bytes(total_bytes), + format_bytes(average_bytes) + )) +} + +pub(super) fn rollout_files_and_bytes(value: &str) -> Option<(u64, u64)> { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, _) = rest.split_once(" total bytes, ")?; + Some(( + files.trim().parse::().ok()?, + total_bytes.trim().parse::().ok()?, + )) +} + +pub(super) fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + + let bytes = bytes as f64; + if bytes >= GIB { + format!("{:.2} GB", bytes / GIB) + } else if bytes >= MIB { + format!("{:.2} MB", bytes / MIB) + } else if bytes >= KIB { + format!("{:.2} KB", bytes / KIB) + } else { + format!("{} B", bytes as u64) + } +} + +pub(super) fn format_count(count: u64) -> String { + let mut digits = count.to_string(); + let mut out = String::new(); + while digits.len() > 3 { + let tail = digits.split_off(digits.len() - 3); + if out.is_empty() { + out = tail; + } else { + out = format!("{tail},{out}"); + } + } + if out.is_empty() { + digits + } else { + format!("{digits},{out}") + } +} + +fn parsed_details(check: &DoctorCheck) -> Vec { + check + .details + .iter() + .map(|detail| redact_detail(detail)) + .map(|detail| { + detail + .split_once(": ") + .map(|(label, value)| ParsedDetail { + label: label.to_string(), + value: value.to_string(), + }) + .unwrap_or_else(|| ParsedDetail { + label: String::new(), + value: detail, + }) + }) + .collect() +} + +fn runtime_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "version", "version"); + push_row_if_present(&mut out, parsed, "install method", "install method"); + push_row_if_present(&mut out, parsed, "commit", "commit"); + push_row_if_present(&mut out, parsed, "current executable", "executable"); + push_remaining( + &mut out, + parsed, + &[ + "version", + "platform", + "install method", + "commit", + "current executable", + ], + &[], + ); + out +} + +fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "install context", "context"); + if parsed.iter().any(|detail| { + detail.value == "ignored inherited package-manager launch env for cargo-built binary" + }) { + out.push(HumanDetail::Bullet( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + )); + } + + let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); + let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let package_root = value(parsed, "managed package root").unwrap_or("not set"); + out.push(HumanDetail::Row { + label: "managed by".to_string(), + value: format!( + "npm: {} · bun: {} · package root {}", + yes_no(managed_by_npm), + yes_no(managed_by_bun), + if is_falsy(package_root) { + "—".to_string() + } else { + package_root.to_string() + } + ), + expected: None, + }); + + let path_entries = numbered_values(parsed, "PATH codex #"); + if !path_entries.is_empty() { + let total = path_entries.len(); + let shown = if options.show_all { + total + } else { + total.min(3) + }; + out.push(HumanDetail::Row { + label: format!("PATH entries ({total})"), + value: path_entries[0].clone(), + expected: None, + }); + out.extend( + path_entries + .iter() + .skip(1) + .take(shown.saturating_sub(1)) + .cloned() + .map(HumanDetail::Continuation), + ); + if shown < total { + out.push(HumanDetail::Continuation( + "… (full list with --all)".to_string(), + )); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "current executable", + "install context", + "managed by npm", + "managed by bun", + "managed package root", + "PATH codex entries", + ], + &["PATH codex #"], + ); + out +} + +fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + if let Some(model) = value(parsed, "model") { + let value = value(parsed, "model provider").map_or_else( + || model.to_string(), + |provider| format!("{model} · {provider}"), + ); + out.push(HumanDetail::Row { + label: "model".to_string(), + value, + expected: None, + }); + } + push_row_if_present(&mut out, parsed, "cwd", "cwd"); + push_row_if_present(&mut out, parsed, "config.toml", "config.toml"); + push_row_if_present(&mut out, parsed, "config.toml parse", "config.toml parse"); + push_row_if_present(&mut out, parsed, "config.toml read", "config.toml read"); + push_row_if_present(&mut out, parsed, "mcp servers", "MCP servers"); + push_feature_flags(&mut out, parsed, options); + + for detail in parsed + .iter() + .filter(|detail| detail.label == "legacy feature flag") + { + out.push(HumanDetail::Row { + label: "legacy alias".to_string(), + value: detail.value.clone(), + expected: None, + }); + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "cwd", + "model", + "model provider", + "log dir", + "sqlite home", + "mcp servers", + "feature flags enabled", + "enabled feature flags", + "feature flag overrides", + "legacy feature flag", + "config.toml", + "config.toml parse", + "config.toml read", + ], + &[], + ); + out +} + +fn state_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "CODEX_HOME", "CODEX_HOME"); + push_row_if_present(&mut out, parsed, "log dir", "log dir"); + push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); + push_database_row(&mut out, parsed, "state DB"); + push_database_row(&mut out, parsed, "log DB"); + + for (source, label) in [ + ("active rollout files", "active rollouts"), + ("archived rollout files", "archived rollouts"), + ] { + if let Some(value) = value(parsed, source) { + out.push(HumanDetail::Row { + label: label.to_string(), + value: rollout_summary(value).unwrap_or_else(|| value.to_string()), + expected: None, + }); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "log dir", + "sqlite home", + "state DB", + "log DB", + "state DB integrity", + "log DB integrity", + "active rollout files", + "archived rollout files", + ], + &[], + ); + out +} + +fn generic_details(parsed: &[ParsedDetail]) -> Vec { + parsed + .iter() + .map(|detail| { + if detail.label.is_empty() { + HumanDetail::Bullet(detail.value.clone()) + } else { + HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + } + } + }) + .collect() +} + +fn push_feature_flags( + out: &mut Vec, + parsed: &[ParsedDetail], + options: HumanOutputOptions, +) { + let enabled_count = value(parsed, "feature flags enabled") + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + let overrides = list_items(value(parsed, "feature flag overrides").unwrap_or("none")); + let override_count = overrides.len(); + let hint = if !options.show_all && enabled_count > 0 { + " (full list with --all)" + } else { + "" + }; + out.push(HumanDetail::Row { + label: "feature flags".to_string(), + value: format!("{enabled_count} enabled · {override_count} overridden{hint}"), + expected: None, + }); + + if !overrides.is_empty() { + push_list_row(out, "overrides", &override_names(&overrides), options); + } + if options.show_all { + let enabled = list_items(value(parsed, "enabled feature flags").unwrap_or("none")); + if !enabled.is_empty() { + push_list_row(out, "enabled flags", &enabled, options); + } + } +} + +fn push_list_row( + out: &mut Vec, + label: &str, + items: &[String], + options: HumanOutputOptions, +) { + let limit = if options.show_all { + items.len() + } else { + items.len().min(LIST_LIMIT) + }; + let mut value = items + .iter() + .take(limit) + .cloned() + .collect::>() + .join(", "); + if limit < items.len() { + value.push_str(", … (full list with --all)"); + } + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_database_row(out: &mut Vec, parsed: &[ParsedDetail], label: &str) { + let Some(path) = value(parsed, label) else { + return; + }; + let integrity = value(parsed, &format!("{label} integrity")); + let value = integrity.map_or_else( + || path.to_string(), + |integrity| format!("{path} · integrity {integrity}"), + ); + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_row_if_present( + out: &mut Vec, + parsed: &[ParsedDetail], + source_label: &str, + display_label: &str, +) { + if let Some(value) = value(parsed, source_label) { + out.push(HumanDetail::Row { + label: display_label.to_string(), + value: value.to_string(), + expected: None, + }); + } +} + +fn push_remaining( + out: &mut Vec, + parsed: &[ParsedDetail], + consumed_labels: &[&str], + consumed_prefixes: &[&str], +) { + for detail in parsed { + if detail.value == "ignored inherited package-manager launch env for cargo-built binary" { + continue; + } + if consumed_labels.contains(&detail.label.as_str()) + || consumed_prefixes + .iter() + .any(|prefix| detail.label.starts_with(prefix)) + { + continue; + } + if detail.label.is_empty() { + out.push(HumanDetail::Bullet(detail.value.clone())); + } else { + out.push(HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + }); + } + } +} + +fn humanize_detail(detail: HumanDetail, options: HumanOutputOptions) -> HumanDetail { + match detail { + HumanDetail::Row { + label, + value, + expected, + } => HumanDetail::Row { + label, + value: humanize_value(&value, options), + expected, + }, + HumanDetail::Continuation(value) => { + HumanDetail::Continuation(humanize_value(&value, options)) + } + HumanDetail::Bullet(value) => HumanDetail::Bullet(humanize_value(&value, options)), + HumanDetail::Remedy(value) => HumanDetail::Remedy(value), + } +} + +fn attach_issue_metadata(detail: HumanDetail, check: &DoctorCheck) -> HumanDetail { + let HumanDetail::Row { + label, + value, + expected, + } = detail + else { + return detail; + }; + let expected = expected.or_else(|| issue_expected_for_label(check, &label)); + HumanDetail::Row { + label, + value, + expected, + } +} + +fn issue_expected_for_label(check: &DoctorCheck, label: &str) -> Option { + check + .issues + .iter() + .find(|issue| { + issue + .fields + .iter() + .any(|field| display_label(field) == label || field == label) + }) + .and_then(|issue| issue.expected.clone()) +} + +fn issue_remedies(check: &DoctorCheck) -> Vec { + let mut seen = BTreeSet::new(); + check + .issues + .iter() + .filter_map(|issue| issue.remedy.as_ref()) + .filter(|remedy| seen.insert((*remedy).clone())) + .cloned() + .map(HumanDetail::Remedy) + .collect() +} + +fn humanize_value(value: &str, _options: HumanOutputOptions) -> String { + if looks_like_path(value) { + return shorten_path_prefix(value); + } + if let Some(timestamp) = humanize_timestamp(value) { + return timestamp; + } + value.to_string() +} + +fn humanize_timestamp(value: &str) -> Option { + if value.len() < 17 || !value.ends_with('Z') { + return None; + } + let (date, time) = value.split_once('T')?; + let hour_minute = time.get(..5)?; + Some(format!("{date} {hour_minute} UTC")) +} + +fn shorten_path_prefix(value: &str) -> String { + let (path, suffix) = value.split_once(" (").map_or_else( + || (value, String::new()), + |(path, suffix)| (path, format!(" ({suffix}")), + ); + let home_shortened = home_shortened_path(path); + let shortened = middle_truncate(&home_shortened, PATH_LIMIT); + format!("{shortened}{suffix}") +} + +fn home_shortened_path(path: &str) -> String { + let Some(home) = env::var_os("HOME").and_then(|home| home.into_string().ok()) else { + return path.to_string(); + }; + if path == home { + "~".to_string() + } else { + path.strip_prefix(&format!("{home}/")) + .map_or_else(|| path.to_string(), |tail| format!("~/{tail}")) + } +} + +fn middle_truncate(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + let head_len = max_chars / 2; + let tail_len = max_chars.saturating_sub(head_len + 1); + let head = value.chars().take(head_len).collect::(); + let tail = value + .chars() + .rev() + .take(tail_len) + .collect::() + .chars() + .rev() + .collect::(); + format!("{head}…{tail}") +} + +fn looks_like_path(value: &str) -> bool { + value.starts_with('/') + || value.starts_with("~/") + || value.starts_with("./") + || value.starts_with("../") +} + +fn numbered_values(parsed: &[ParsedDetail], prefix: &str) -> Vec { + parsed + .iter() + .filter(|detail| detail.label.starts_with(prefix)) + .map(|detail| detail.value.clone()) + .collect() +} + +fn value<'a>(parsed: &'a [ParsedDetail], label: &str) -> Option<&'a str> { + parsed + .iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value.as_str()) +} + +fn display_label(label: &str) -> String { + match label { + "codex-linux-sandbox helper" => "linux helper", + "optional reachability failed" => "optional reachability", + "check for update on startup" => "startup update check", + other => other, + } + .to_string() +} + +fn list_items(value: &str) -> Vec { + if is_falsy(value) { + return Vec::new(); + } + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +fn override_names(items: &[String]) -> Vec { + items + .iter() + .map(|item| item.split_once('=').map_or(item.as_str(), |(name, _)| name)) + .map(str::to_string) + .collect() +} + +fn yes_no(value: &str) -> &'static str { + if value == "true" { "yes" } else { "no" } +} + +pub(super) fn is_falsy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "false" | "none" | "not set" | "unknown" | "missing" | "absent" | "no" | "—" | "-" + ) +} diff --git a/codex-rs/cli/src/doctor/progress.rs b/codex-rs/cli/src/doctor/progress.rs new file mode 100644 index 0000000000..ba8d65a357 --- /dev/null +++ b/codex-rs/cli/src/doctor/progress.rs @@ -0,0 +1,139 @@ +use std::io; +use std::io::IsTerminal; +use std::io::Write; +use std::sync::Mutex; +use std::time::Duration; + +use super::CheckStatus; + +/// Receives check lifecycle events while doctor builds the final report. +/// +/// Progress implementations must not write to stdout. The final report owns +/// stdout so JSON and redirected human reports stay clean. +pub(super) trait DoctorProgress: Send + Sync { + fn begin(&self, label: &'static str); + fn heartbeat(&self, label: &'static str, elapsed: Duration); + fn finish(&self, label: &'static str, status: CheckStatus); + fn settle(&self); +} + +/// Selects the progress implementation for the current output mode. +/// +/// JSON output is always quiet so stdout remains valid JSON. Human output uses a +/// transient stderr line only for interactive terminals, then clears it before +/// the final report is printed. +pub(super) fn doctor_progress(json: bool) -> std::sync::Arc { + if should_show_progress( + json, + std::env::var("TERM").ok().as_deref(), + io::stderr().is_terminal(), + ) { + std::sync::Arc::new(StderrProgress::default()) + } else { + std::sync::Arc::new(QuietProgress) + } +} + +fn should_show_progress(json: bool, term: Option<&str>, stderr_is_tty: bool) -> bool { + !json && stderr_is_tty && term != Some("dumb") +} + +struct QuietProgress; + +impl DoctorProgress for QuietProgress { + fn begin(&self, _label: &'static str) {} + + fn heartbeat(&self, _label: &'static str, _elapsed: Duration) {} + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) {} +} + +#[derive(Default)] +struct StderrProgress { + state: Mutex, +} + +#[derive(Default)] +struct StderrProgressState { + wrote_line: bool, +} + +impl StderrProgress { + fn render(&self, message: String) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K{message}"); + let _ = stderr.flush(); + state.wrote_line = true; + } +} + +impl DoctorProgress for StderrProgress { + fn begin(&self, label: &'static str) { + self.render(format!("Checking {label}...")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.render(format!("Still checking {label}... {}s", elapsed.as_secs())); + } + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if !state.wrote_line { + return; + } + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K"); + let _ = stderr.flush(); + state.wrote_line = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_is_quiet_for_json() { + assert!(!should_show_progress( + /*json*/ true, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_quiet_for_non_tty() { + assert!(!should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ false, + )); + } + + #[test] + fn progress_is_quiet_for_dumb_terminal() { + assert!(!should_show_progress( + /*json*/ false, + Some("dumb"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_shown_for_human_tty_output() { + assert!(should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } +} diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs new file mode 100644 index 0000000000..96e806762c --- /dev/null +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -0,0 +1,141 @@ +//! Captures how this Codex process was launched. +//! +//! Runtime diagnostics answer provenance questions that are hard to infer from +//! user reports: which binary is running, which install channel it resembles, +//! which platform it targets, and whether the search command comes from bundled +//! standalone resources or from PATH. + +use std::env; +use std::process::Command; + +use codex_install_context::InstallContext; + +use super::CheckStatus; +use super::DoctorCheck; +use super::describe_install_context; +use super::doctor_install_context; +use super::push_path_detail; + +/// Builds the process provenance row for the current Codex executable. +/// +/// This check is informational and should not fail on its own; inconsistent +/// install state is reported by the installation and update checks instead. +pub(super) fn runtime_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let os = env::consts::OS; + let arch = env::consts::ARCH; + let platform = format!("{os}-{arch}"); + let install_method = install_method_name(&install_context); + let mut details = vec![ + format!("version: {}", env!("CARGO_PKG_VERSION")), + format!("platform: {platform}"), + format!( + "install method: {}", + describe_install_context(&install_context) + ), + format!("commit: {}", build_commit()), + ]; + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + format!("running {install_method} on {platform}"), + ) + .details(details) +} + +/// Verifies that the search command selected by the install context is usable. +/// +/// Standalone installs should point at a bundled ripgrep binary, while local or +/// package-managed installs usually resolve rg from PATH. A warning here means +/// features that depend on file search may degrade even when the CLI launches. +pub(super) fn search_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let rg_command = install_context.rg_command(); + let provider = search_provider(&install_context); + let mut details = vec![ + format!("search command: {}", rg_command.display()), + format!("search provider: {provider}"), + ]; + + let status = if rg_command.components().count() > 1 { + match std::fs::metadata(&rg_command) { + Ok(metadata) if metadata.is_file() => { + details.push("search command readiness: file exists".to_string()); + CheckStatus::Ok + } + Ok(_) => { + details.push("search command readiness: path is not a file".to_string()); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + } else { + match Command::new(&rg_command).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("rg version unknown") + .to_string(); + details.push(format!("search command readiness: {version}")); + CheckStatus::Ok + } + Ok(output) => { + details.push(format!( + "search command readiness: exited with status {}", + output.status + )); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + }; + + let summary = match status { + CheckStatus::Ok => format!("search is OK ({provider})"), + CheckStatus::Warning => "search command could not be verified".to_string(), + CheckStatus::Fail => unreachable!(), + }; + let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + } + check +} + +fn install_method_name(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { .. } => "standalone", + InstallContext::Npm => "npm", + InstallContext::Bun => "bun", + InstallContext::Brew => "brew", + InstallContext::Other => "local build", + } +} + +fn search_provider(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { + resources_dir: Some(resources_dir), + .. + } if context.rg_command().starts_with(resources_dir) => "bundled", + _ => "system", + } +} + +fn build_commit() -> &'static str { + option_env!("CODEX_BUILD_COMMIT") + .or(option_env!("GIT_COMMIT")) + .unwrap_or("unknown") +} diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs new file mode 100644 index 0000000000..3d728fc07b --- /dev/null +++ b/codex-rs/cli/src/doctor/updates.rs @@ -0,0 +1,227 @@ +//! Diagnoses whether Codex update paths target the running installation. +//! +//! Update diagnostics combine cached version metadata, install-channel hints, +//! and bounded latest-version probes. For npm-managed launches, this module also +//! verifies that npm install -g would update the package root that launched the +//! current process, which catches PATH and prefix mismatches before the user runs +//! an update command. + +use std::path::Path; + +use codex_core::config::Config; +use codex_install_context::InstallContext; +use serde::Deserialize; + +use super::CheckStatus; +use super::DoctorCheck; +use super::NpmRootCheck; +use super::doctor_install_context; +use super::doctor_managed_by_npm; +use super::npm_global_root_check; +use super::run_command; + +const VERSION_FILE_NAME: &str = "version.json"; +const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; +const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; + +/// Builds the update-health row for the current installation. +/// +/// Network failures while fetching latest-version metadata degrade the row to a +/// warning instead of failing doctor outright; update freshness is useful +/// support context but should not mask more direct install/config failures. +pub(super) fn updates_check(config: &Config) -> DoctorCheck { + let current_exe = std::env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let mut details = vec![ + format!( + "check for update on startup: {}", + config.check_for_update_on_startup + ), + format!("update action: {}", update_action_label(&install_context)), + ]; + let version_file = config.codex_home.join(VERSION_FILE_NAME); + push_cached_version_details(&mut details, &version_file); + + let mut status = CheckStatus::Ok; + let mut summary = "update configuration is locally consistent".to_string(); + let mut remediation = None; + + if doctor_managed_by_npm(current_exe.as_deref()) { + match npm_global_root_check() { + NpmRootCheck::Match { package_root } => { + details.push(format!("npm update target: {}", package_root.display())); + } + NpmRootCheck::Mismatch { + running_package_root, + npm_package_root, + } => { + status = CheckStatus::Fail; + summary = "update would target a different npm install".to_string(); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + remediation = Some(format!( + "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).", + running_package_root.display(), + npm_package_root.display() + )); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be proven".to_string(); + remediation = Some( + "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT." + .to_string(), + ); + } + NpmRootCheck::NpmUnavailable(error) => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be inspected".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + match fetch_latest_version(&install_context) { + Ok(latest_version) => { + details.push(format!("latest version: {latest_version}")); + if is_newer(&latest_version, env!("CARGO_PKG_VERSION")) == Some(true) { + details.push("latest version status: newer version is available".to_string()); + } else { + details.push("latest version status: current version is not older".to_string()); + } + } + Err(err) => { + status = status.max(CheckStatus::Warning); + details.push(format!("latest version probe: {err}")); + } + } + + let mut check = DoctorCheck::new("updates.status", "updates", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn push_cached_version_details(details: &mut Vec, version_file: &Path) { + details.push(format!("version cache: {}", version_file.display())); + match std::fs::read_to_string(version_file) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(info) => { + details.push(format!("cached latest version: {}", info.latest_version)); + if let Some(last_checked_at) = info.last_checked_at { + details.push(format!("last checked at: {last_checked_at}")); + } + if let Some(dismissed_version) = info.dismissed_version { + details.push(format!("dismissed version: {dismissed_version}")); + } + } + Err(err) => details.push(format!("version cache parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("version cache: missing".to_string()); + } + Err(err) => details.push(format!("version cache read: {err}")), + } +} + +fn update_action_label(context: &InstallContext) -> &'static str { + match context { + InstallContext::Npm => "npm install -g @openai/codex", + InstallContext::Bun => "bun install -g @openai/codex", + InstallContext::Brew => "brew upgrade --cask codex", + InstallContext::Standalone { .. } => "standalone installer", + InstallContext::Other => "manual or unknown", + } +} + +fn fetch_latest_version(context: &InstallContext) -> Result { + match context { + InstallContext::Brew => fetch_homebrew_cask_version(), + InstallContext::Npm + | InstallContext::Bun + | InstallContext::Standalone { .. } + | InstallContext::Other => fetch_latest_github_release_version(), + } +} + +fn fetch_latest_github_release_version() -> Result { + #[derive(Deserialize)] + struct ReleaseInfo { + tag_name: String, + } + + let info = http_get_json::(GITHUB_LATEST_RELEASE_URL)?; + info.tag_name + .strip_prefix("rust-v") + .map(str::to_string) + .ok_or_else(|| format!("failed to parse latest tag {}", info.tag_name)) +} + +fn fetch_homebrew_cask_version() -> Result { + #[derive(Deserialize)] + struct HomebrewCaskInfo { + version: String, + } + + http_get_json::(HOMEBREW_CASK_API_URL).map(|info| info.version) +} + +fn http_get_json(url: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?; + serde_json::from_str::(&body).map_err(|err| err.to_string()) +} + +fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => Some(latest > current), + (Some(_), None) | (None, Some(_)) | (None, None) => None, + } +} + +fn parse_version(value: &str) -> Option<(u64, u64, u64)> { + let mut parts = value.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + Some((major, minor, patch)) +} + +#[derive(Deserialize)] +struct VersionInfo { + latest_version: String, + #[serde(default)] + last_checked_at: Option, + #[serde(default)] + dismissed_version: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_compares_plain_semver() { + assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true)); + assert_eq!(is_newer("1.2.3", "1.2.4"), Some(false)); + assert_eq!(is_newer("1.2.3-beta.1", "1.2.2"), None); + } + + #[test] + fn update_action_labels_install_contexts() { + assert_eq!( + update_action_label(&InstallContext::Npm), + "npm install -g @openai/codex" + ); + assert_eq!( + update_action_label(&InstallContext::Other), + "manual or unknown" + ); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 36ce876b88..6e1ccac867 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -37,6 +37,7 @@ use codex_tui::ExitReason; use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::ProfileV2Name; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -46,19 +47,25 @@ use supports_color::Stream; mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; +mod doctor; mod marketplace_cmd; mod mcp_cmd; +mod plugin_cmd; #[cfg(not(windows))] mod wsl_paths; -use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; +use crate::plugin_cmd::PluginCli; +use crate::plugin_cmd::PluginSubcommand; +use doctor::DoctorCommand; +use codex_config::LoaderOverrides; use codex_core::build_models_manager; -use codex_core::config::Config; +use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; +use codex_core::config::resolve_profile_v2_config_path; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; @@ -109,7 +116,7 @@ enum Subcommand { Exec(ExecCli), /// Run a code review non-interactively. - Review(ReviewArgs), + Review(ReviewCommand), /// Manage login. Login(LoginCommand), @@ -124,13 +131,13 @@ enum Subcommand { Plugin(PluginCli), /// Start Codex as an MCP server (stdio). - McpServer, + McpServer(McpServerCommand), /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), - /// [experimental] Ensure the app-server daemon is running with remote control enabled. - RemoteControl, + /// [experimental] Manage the app-server daemon with remote control enabled. + RemoteControl(RemoteControlCommand), /// Launch the Codex desktop app (opens the app installer if missing). #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -142,6 +149,9 @@ enum Subcommand { /// Update Codex to the latest version. Update, + /// Diagnose local Codex installation, config, auth, and runtime health. + Doctor(DoctorCommand), + /// Run commands within a Codex-provided sandbox. Sandbox(SandboxArgs), @@ -181,22 +191,6 @@ enum Subcommand { Features(FeaturesCli), } -#[derive(Debug, Parser)] -#[command(bin_name = "codex plugin")] -struct PluginCli { - #[clap(flatten)] - pub config_overrides: CliConfigOverrides, - - #[command(subcommand)] - subcommand: PluginSubcommand, -} - -#[derive(Debug, clap::Subcommand)] -enum PluginSubcommand { - /// Manage plugin marketplaces for Codex. - Marketplace(MarketplaceCli), -} - #[derive(Debug, Parser)] struct CompletionCommand { /// Shell to generate completions for @@ -266,6 +260,23 @@ struct DebugModelsCommand { bundled: bool, } +#[derive(Debug, Parser)] +struct ReviewCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + + #[clap(flatten)] + args: ReviewArgs, +} + +#[derive(Debug, Parser)] +struct McpServerCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, +} + #[derive(Debug, Parser)] struct DebugTraceReduceCommand { /// Trace bundle directory containing manifest.json and trace.jsonl. @@ -419,6 +430,10 @@ struct AppServerCommand { #[command(subcommand)] subcommand: Option, + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. #[arg( @@ -541,6 +556,21 @@ struct AppServerBootstrapCommand { remote_control: bool, } +#[derive(Debug, Args)] +struct RemoteControlCommand { + #[command(subcommand)] + subcommand: Option, +} + +#[derive(Debug, Clone, Copy, clap::Subcommand)] +enum RemoteControlSubcommand { + /// Start the app-server daemon with remote control enabled. + Start, + + /// Stop the app-server daemon. + Stop, +} + #[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written @@ -810,6 +840,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_config_overrides.raw_overrides.extend(toggle_overrides); let root_remote = remote.remote; let root_remote_auth_token_env = remote.remote_auth_token_env; + let root_strict_config = interactive.strict_config; + reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?; + if let Some(subcommand) = subcommand.as_ref() { + profile_v2_for_subcommand(&interactive, subcommand)?; + } match subcommand { None => { @@ -835,33 +870,46 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { exec_cli .shared .inherit_exec_root_options(&interactive.shared); + exec_cli.strict_config |= root_strict_config; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::Review(review_args)) => { + Some(Subcommand::Review(ReviewCommand { + strict_config, + args: review_args, + })) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), "review", )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; + exec_cli + .shared + .inherit_exec_root_options(&interactive.shared); exec_cli.command = Some(ExecCommand::Review(review_args)); + exec_cli.strict_config = strict_config || root_strict_config; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), ); codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } - Some(Subcommand::McpServer) => { + Some(Subcommand::McpServer(McpServerCommand { strict_config })) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), "mcp-server", )?; - codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; + codex_mcp_server::run_main( + arg0_paths.clone(), + root_config_overrides, + strict_config || root_strict_config, + ) + .await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { reject_remote_mode_for_subcommand( @@ -885,20 +933,41 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } = plugin_cli; prepend_config_flags(&mut config_overrides, root_config_overrides.clone()); match subcommand { + PluginSubcommand::Add(args) => { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + plugin_cmd::run_plugin_add(overrides, args).await?; + } + PluginSubcommand::List(args) => { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + plugin_cmd::run_plugin_list(overrides, args).await?; + } PluginSubcommand::Marketplace(mut marketplace_cli) => { prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides); marketplace_cli.run().await?; } + PluginSubcommand::Remove(args) => { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + plugin_cmd::run_plugin_remove(overrides, args).await?; + } } } Some(Subcommand::AppServer(app_server_cli)) => { let AppServerCommand { subcommand, + strict_config: app_server_strict_config, listen, remote_control, analytics_default_enabled, auth, } = app_server_cli; + let strict_config = app_server_strict_config || root_strict_config; + reject_strict_config_for_app_server_subcommand(strict_config, subcommand.as_ref())?; reject_remote_mode_for_app_server_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), @@ -915,7 +984,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, - codex_config::LoaderOverrides::default(), + LoaderOverrides::default(), + strict_config, analytics_default_enabled, transport, codex_protocol::protocol::SessionSource::VSCode, @@ -991,14 +1061,25 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } } - Some(Subcommand::RemoteControl) => { + Some(Subcommand::RemoteControl(remote_control_cli)) => { + let subcommand_name = remote_control_subcommand_name(&remote_control_cli); reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), - "remote-control", + subcommand_name, )?; - let output = codex_app_server_daemon::ensure_remote_control_started().await?; - println!("{}", serde_json::to_string(&output)?); + match remote_control_cli + .subcommand + .unwrap_or(RemoteControlSubcommand::Start) + { + RemoteControlSubcommand::Start => { + let output = codex_app_server_daemon::ensure_remote_control_started().await?; + println!("{}", serde_json::to_string(&output)?); + } + RemoteControlSubcommand::Stop => { + print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?; + } + } } #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(app_cli)) => { @@ -1135,6 +1216,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; run_update_command()?; } + Some(Subcommand::Doctor(doctor_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "doctor", + )?; + doctor::run_doctor( + doctor_cli, + root_config_overrides.clone(), + &interactive, + &arg0_paths, + ) + .await?; + } Some(Subcommand::Cloud(mut cloud_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1320,11 +1415,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ..Default::default() }; - let config = Config::load_with_cli_overrides_and_harness_overrides( - cli_kv_overrides, - overrides, - ) - .await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let mut rows = Vec::with_capacity(FEATURES.len()); let mut name_width = 0; let mut stage_width = 0; @@ -1364,6 +1459,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { Ok(()) } +fn profile_v2_for_subcommand<'a>( + interactive: &'a TuiCli, + subcommand: &Subcommand, +) -> anyhow::Result> { + let Some(profile_v2) = interactive.config_profile_v2.as_ref() else { + return Ok(None); + }; + + match subcommand { + Subcommand::Exec(_) + | Subcommand::Review(_) + | Subcommand::Resume(_) + | Subcommand::Fork(_) + | Subcommand::Debug(DebugCommand { + subcommand: DebugSubcommand::PromptInput(_), + }) => Ok(Some(profile_v2)), + _ => anyhow::bail!( + "--profile-v2 only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`." + ), + } +} + async fn run_exec_server_command( cmd: ExecServerCommand, arg0_paths: &Arg0DispatchPaths, @@ -1422,6 +1539,22 @@ async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyho Ok(()) } +fn loader_overrides_for_profile( + profile_v2: Option<&ProfileV2Name>, +) -> anyhow::Result { + match profile_v2 { + Some(profile_v2) => { + let codex_home = find_codex_home()?; + Ok(LoaderOverrides { + user_config_path: Some(resolve_profile_v2_config_path(&codex_home, profile_v2)), + user_config_profile: Some(profile_v2.clone()), + ..Default::default() + }) + } + None => Ok(LoaderOverrides::default()), + } +} + fn maybe_print_under_development_feature_warning( codex_home: &std::path::Path, interactive: &TuiCli, @@ -1464,6 +1597,7 @@ async fn run_debug_prompt_input_command( interactive: TuiCli, arg0_paths: Arg0DispatchPaths, ) -> anyhow::Result<()> { + let loader_overrides = loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; let shared = interactive.shared.into_inner(); let mut cli_kv_overrides = root_config_overrides .parse_overrides() @@ -1500,8 +1634,12 @@ async fn run_debug_prompt_input_command( additional_writable_roots: shared.add_dir, ..Default::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .loader_overrides(loader_overrides) + .build() + .await?; let mut input = shared .images @@ -1532,7 +1670,10 @@ async fn run_debug_models_command( let cli_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; - let config = Config::load_with_cli_overrides(cli_overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_overrides) + .build() + .await?; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; let models_manager = build_models_manager(&config, auth_manager); @@ -1557,8 +1698,11 @@ async fn run_debug_clear_memories_command( config_profile: interactive.config_profile.clone(), ..Default::default() }; - let config = - Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + let config = ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await?; let state_path = state_db_path(config.sqlite_home.as_path()); let mut cleared_state_db = false; @@ -1593,9 +1737,7 @@ fn prepend_config_flags( subcommand_config_overrides: &mut CliConfigOverrides, cli_config_overrides: CliConfigOverrides, ) { - subcommand_config_overrides - .raw_overrides - .splice(0..0, cli_config_overrides.raw_overrides); + subcommand_config_overrides.prepend_root_overrides(cli_config_overrides); } fn reject_remote_mode_for_subcommand( @@ -1616,12 +1758,114 @@ fn reject_remote_mode_for_subcommand( Ok(()) } +fn reject_root_strict_config_for_subcommand( + strict_config: bool, + subcommand: &Option, +) -> anyhow::Result<()> { + if !strict_config { + return Ok(()); + } + + match unsupported_subcommand_name_for_strict_config(subcommand) { + Some(subcommand_name) => { + reject_strict_config_for_unsupported_subcommand(strict_config, subcommand_name) + } + None => Ok(()), + } +} + +/// Return the selected subcommand name when a root-level `--strict-config` +/// flag should be rejected after parsing. +/// +/// `--strict-config` is parsed on the root interactive CLI so commands like +/// `codex --strict-config` continue to work for the TUI and for wrappers that +/// forward root options into another command shape. Clap will still accept that +/// root flag before the dispatcher knows which subcommand the user selected, so +/// unsupported subcommands need an explicit post-parse reject path. +/// +/// `Some(...)` returns the user-facing command name fragment to embed in the +/// rejection error, such as `cloud` or `app-server proxy`. `None` means the +/// selected command is allowed to inherit root `--strict-config`. +fn unsupported_subcommand_name_for_strict_config( + subcommand: &Option, +) -> Option<&'static str> { + match subcommand { + None + | Some(Subcommand::Exec(_)) + | Some(Subcommand::Review(_)) + | Some(Subcommand::McpServer(_)) + | Some(Subcommand::Resume(_)) + | Some(Subcommand::Fork(_)) + | Some(Subcommand::Doctor(_)) => None, + Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, + Some(Subcommand::AppServer(app_server)) => { + Some(app_server_subcommand_name(app_server.subcommand.as_ref())) + } + Some(Subcommand::RemoteControl(remote_control)) => { + Some(remote_control_subcommand_name(remote_control)) + } + Some(Subcommand::Mcp(_)) => Some("mcp"), + Some(Subcommand::Plugin(_)) => Some("plugin"), + #[cfg(any(target_os = "macos", target_os = "windows"))] + Some(Subcommand::App(_)) => Some("app"), + Some(Subcommand::Login(_)) => Some("login"), + Some(Subcommand::Logout(_)) => Some("logout"), + Some(Subcommand::Completion(_)) => Some("completion"), + Some(Subcommand::Update) => Some("update"), + Some(Subcommand::Cloud(_)) => Some("cloud"), + Some(Subcommand::Sandbox(_)) => Some("sandbox"), + Some(Subcommand::Debug(_)) => Some("debug"), + Some(Subcommand::Execpolicy(_)) => Some("execpolicy"), + Some(Subcommand::Apply(_)) => Some("apply"), + Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"), + Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"), + Some(Subcommand::ExecServer(_)) => Some("exec-server"), + Some(Subcommand::Features(_)) => Some("features"), + } +} + +fn reject_strict_config_for_app_server_subcommand( + strict_config: bool, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + if subcommand.is_none() { + return Ok(()); + } + reject_strict_config_for_unsupported_subcommand( + strict_config, + app_server_subcommand_name(subcommand), + ) +} + +fn reject_strict_config_for_unsupported_subcommand( + strict_config: bool, + subcommand: &str, +) -> anyhow::Result<()> { + if strict_config { + anyhow::bail!("`--strict-config` is not supported for `codex {subcommand}`"); + } + Ok(()) +} + fn reject_remote_mode_for_app_server_subcommand( remote: Option<&str>, remote_auth_token_env: Option<&str>, subcommand: Option<&AppServerSubcommand>, ) -> anyhow::Result<()> { - let subcommand_name = match subcommand { + let subcommand_name = app_server_subcommand_name(subcommand); + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) +} + +fn remote_control_subcommand_name(command: &RemoteControlCommand) -> &'static str { + match command.subcommand { + None => "remote-control", + Some(RemoteControlSubcommand::Start) => "remote-control start", + Some(RemoteControlSubcommand::Stop) => "remote-control stop", + } +} + +fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { + match subcommand { None => "app-server", Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand { AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap", @@ -1643,8 +1887,7 @@ fn reject_remote_mode_for_app_server_subcommand( Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { "app-server generate-internal-json-schema" } - }; - reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) + } } async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> { @@ -1816,6 +2059,7 @@ fn finalize_fork_interactive( fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { let TuiCli { shared, + strict_config, approval_policy, web_search, prompt, @@ -1831,6 +2075,9 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) if web_search { interactive.web_search = true; } + if strict_config { + interactive.strict_config = true; + } if let Some(prompt) = prompt { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); @@ -1913,6 +2160,49 @@ mod tests { finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } + fn profile_v2_for_args(args: &[&str]) -> anyhow::Result> { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let Some(subcommand) = cli.subcommand.as_ref() else { + return Ok(cli + .interactive + .config_profile_v2 + .as_ref() + .map(std::string::ToString::to_string)); + }; + Ok(profile_v2_for_subcommand(&cli.interactive, subcommand)?.map(ToString::to_string)) + } + + #[test] + fn profile_v2_is_rejected_for_config_management_subcommands() { + assert!( + profile_v2_for_args(&["codex", "--profile-v2", "work", "features", "list"]).is_err() + ); + } + + #[test] + fn profile_v2_is_allowed_for_runtime_subcommands() { + assert_eq!( + profile_v2_for_args(&["codex", "--profile-v2", "work", "resume"]) + .expect("resume supports profile-v2") + .as_deref(), + Some("work") + ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile-v2", "work", "debug", "prompt-input"]) + .expect("debug prompt-input supports profile-v2") + .as_deref(), + Some("work") + ); + } + + #[test] + fn profile_v2_rejects_non_plain_names_at_parse_time() { + assert!( + MultitoolCli::try_parse_from(["codex", "--profile-v2", "nested/work", "resume"]) + .is_err() + ); + } + #[test] fn exec_resume_last_accepts_prompt_positional() { let cli = @@ -2053,6 +2343,7 @@ mod tests { for (subcommand, usage) in [ ("add", "Usage: codex plugin marketplace add"), + ("list", "Usage: codex plugin marketplace list"), ("upgrade", "Usage: codex plugin marketplace upgrade"), ("remove", "Usage: codex plugin marketplace remove"), ] { @@ -2079,6 +2370,45 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); } + #[test] + fn plugin_add_parses_under_plugin() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "plugin", + "add", + "sample", + "--marketplace", + "debug", + ]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + + #[test] + fn plugin_list_parses_under_plugin() { + let cli = + MultitoolCli::try_parse_from(["codex", "plugin", "list", "--marketplace", "debug"]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + + #[test] + fn plugin_remove_parses_under_plugin() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "plugin", + "remove", + "sample", + "--marketplace", + "debug", + ]) + .expect("parse"); + + assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_)))); + } + #[test] fn update_parses_as_update_subcommand() { let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse"); @@ -2316,8 +2646,11 @@ mod tests { "gpt-5.1-test", "-p", "my-profile", + "--profile-v2", + "my-config", "-C", "/tmp", + "--strict-config", "-i", "/tmp/a.png,/tmp/b.png", ] @@ -2327,6 +2660,7 @@ mod tests { assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); assert!(interactive.oss); assert_eq!(interactive.config_profile.as_deref(), Some("my-profile")); + assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config")); assert_matches!( interactive.sandbox_mode, Some(codex_utils_cli::SandboxModeCliArg::WorkspaceWrite) @@ -2340,6 +2674,7 @@ mod tests { Some(std::path::Path::new("/tmp")) ); assert!(interactive.web_search); + assert!(interactive.strict_config); let has_a = interactive .images .iter() @@ -2434,11 +2769,87 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn strict_config_parses_for_supported_commands() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config"]).expect("parse"); + assert!(cli.interactive.strict_config); + + let cli = MultitoolCli::try_parse_from(["codex", "mcp-server", "--strict-config"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::McpServer(McpServerCommand { + strict_config: true, + })) + ); + + let cli = + MultitoolCli::try_parse_from(["codex", "review", "--strict-config", "--uncommitted"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::Review(ReviewCommand { + strict_config: true, + .. + })) + ); + } + + #[test] + fn root_strict_config_is_rejected_for_unsupported_subcommands() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "mcp", "list"]) + .expect("parse"); + let err = reject_root_strict_config_for_subcommand( + cli.interactive.strict_config, + &cli.subcommand, + ) + .expect_err("mcp should not support root --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex mcp`" + ); + + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "remote-control"]) + .expect("parse"); + let err = reject_root_strict_config_for_subcommand( + cli.interactive.strict_config, + &cli.subcommand, + ) + .expect_err("remote-control should not support root --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex remote-control`" + ); + } + + #[test] + fn app_server_subcommands_reject_strict_config() { + let app_server = + app_server_from_args(["codex", "app-server", "--strict-config", "proxy"].as_ref()); + let err = reject_strict_config_for_app_server_subcommand( + app_server.strict_config, + app_server.subcommand.as_ref(), + ) + .expect_err("app-server proxy should not support --strict-config"); + + assert_eq!( + err.to_string(), + "`--strict-config` is not supported for `codex app-server proxy`" + ); + } + #[test] fn reject_remote_flag_for_remote_control() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) .expect("parse"); - assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl)); + assert_matches!( + cli.subcommand, + Some(Subcommand::RemoteControl(RemoteControlCommand { + subcommand: None + })) + ); let err = reject_remote_mode_for_subcommand( cli.remote.remote.as_deref(), @@ -2880,4 +3291,38 @@ mod tests { .expect_err("feature should be rejected"); assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); } + + #[test] + fn strict_config_with_unknown_enable_errors() { + let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref()); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } + + #[test] + fn strict_config_with_unknown_disable_errors() { + let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref()); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } + + #[test] + fn strict_config_with_compound_enable_errors() { + let err = strict_config_feature_toggle_error( + ["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(), + ); + assert_eq!( + err.to_string(), + "Unknown feature flag: multi_agent_v2.subagent_usage_hint_text" + ); + } + + fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error { + let cli_args = std::iter::once("codex") + .chain(std::iter::once("--strict-config")) + .chain(args.iter().copied()); + let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed"); + assert!(cli.interactive.strict_config); + cli.feature_toggles + .to_overrides() + .expect_err("feature should be rejected") + } } diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index fcd9049d59..b4132a0127 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -6,10 +6,13 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; +use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; #[derive(Debug, Parser)] @@ -24,21 +27,36 @@ pub struct MarketplaceCli { #[derive(Debug, clap::Subcommand)] enum MarketplaceSubcommand { + /// Add a local or Git marketplace to the configured marketplace sources. Add(AddMarketplaceArgs), + + /// List configured marketplace names and their local snapshot roots. + List, + + /// Refresh configured Git marketplace snapshots. + /// + /// Omit MARKETPLACE_NAME to upgrade all configured Git marketplaces. Upgrade(UpgradeMarketplaceArgs), + + /// Remove a configured marketplace source by name. Remove(RemoveMarketplaceArgs), } #[derive(Debug, Parser)] -#[command(bin_name = "codex plugin marketplace add")] +#[command( + bin_name = "codex plugin marketplace add", + after_help = "Examples:\n codex plugin marketplace add ./path/to/marketplace\n codex plugin marketplace add owner/repo --ref main\n codex plugin marketplace add https://github.com/owner/repo --sparse plugins/foo" +)] struct AddMarketplaceArgs { - /// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, SSH URLs, - /// or local marketplace root directories. + /// Marketplace source: a local path, owner/repo[@ref], HTTPS Git URL, or SSH Git URL. + #[arg(value_name = "SOURCE")] source: String, + /// Git ref to fetch for Git marketplace sources. #[arg(long = "ref", value_name = "REF")] ref_name: Option, + /// Sparse checkout path for Git marketplace sources. Can be repeated. #[arg( long = "sparse", value_name = "PATH", @@ -48,15 +66,24 @@ struct AddMarketplaceArgs { } #[derive(Debug, Parser)] -#[command(bin_name = "codex plugin marketplace upgrade")] +#[command( + bin_name = "codex plugin marketplace upgrade", + after_help = "Examples:\n codex plugin marketplace upgrade\n codex plugin marketplace upgrade debug" +)] struct UpgradeMarketplaceArgs { + /// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces. + #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: Option, } #[derive(Debug, Parser)] -#[command(bin_name = "codex plugin marketplace remove")] +#[command( + bin_name = "codex plugin marketplace remove", + after_help = "Example:\n codex plugin marketplace remove debug" +)] struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. + #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: String, } @@ -73,6 +100,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, + MarketplaceSubcommand::List => run_list(overrides).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -118,6 +146,48 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } +async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let configured_marketplaces = config + .config_layer_stack + .get_user_layer() + .and_then(|layer| layer.config.get("marketplaces")) + .and_then(toml::Value::as_table); + let Some(configured_marketplaces) = configured_marketplaces else { + println!("No configured plugin marketplaces."); + return Ok(()); + }; + + if configured_marketplaces.is_empty() { + println!("No configured plugin marketplaces."); + return Ok(()); + } + + let default_install_root = marketplace_install_root(config.codex_home.as_path()); + for (marketplace_name, marketplace) in configured_marketplaces { + if !marketplace.is_table() { + eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table."); + continue; + } + if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") { + eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}."); + continue; + } + let root = resolve_configured_marketplace_root( + marketplace_name, + marketplace, + default_install_root.as_path(), + ) + .map(|root| root.display().to_string()) + .unwrap_or_else(|| "".to_string()); + println!("{marketplace_name}\t{root}"); + } + + Ok(()) +} + async fn run_upgrade( overrides: Vec<(String, toml::Value)>, args: UpgradeMarketplaceArgs, diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs new file mode 100644 index 0000000000..dba06313e5 --- /dev/null +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -0,0 +1,405 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use clap::Parser; +use codex_core::config::Config; +use codex_core::config::find_codex_home; +use codex_core_plugins::ConfiguredMarketplace; +use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginsConfigInput; +use codex_core_plugins::PluginsManager; +use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; +use codex_core_plugins::marketplace::MarketplaceListError; +use codex_core_plugins::marketplace::find_marketplace_manifest_path; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; +use codex_utils_cli::CliConfigOverrides; +use std::path::PathBuf; + +use crate::marketplace_cmd::MarketplaceCli; + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin")] +pub struct PluginCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, + + #[command(subcommand)] + pub subcommand: PluginSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum PluginSubcommand { + /// Install a plugin from a configured marketplace snapshot. + /// + /// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with + /// `--marketplace MARKETPLACE`. + Add(AddPluginArgs), + + /// List plugins available from configured marketplace snapshots. + List(ListPluginsArgs), + + /// Add, list, upgrade, or remove configured plugin marketplaces. + Marketplace(MarketplaceCli), + + /// Remove an installed plugin from local config and cache. + /// + /// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with + /// `--marketplace MARKETPLACE`. + Remove(RemovePluginArgs), +} + +#[derive(Debug, Parser)] +#[command( + bin_name = "codex plugin add", + after_help = "Examples:\n codex plugin add sample@debug\n codex plugin add sample --marketplace debug" +)] +pub struct AddPluginArgs { + /// Plugin selector to install: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace. + #[arg(value_name = "PLUGIN[@MARKETPLACE]")] + plugin: String, + + /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. + #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command( + bin_name = "codex plugin list", + after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug" +)] +pub struct ListPluginsArgs { + /// Only list plugins from this configured marketplace name. + #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] + marketplace_name: Option, +} + +#[derive(Debug, Parser)] +#[command( + bin_name = "codex plugin remove", + after_help = "Examples:\n codex plugin remove sample@debug\n codex plugin remove sample --marketplace debug" +)] +pub struct RemovePluginArgs { + /// Plugin selector to remove: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace. + #[arg(value_name = "PLUGIN[@MARKETPLACE]")] + plugin: String, + + /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. + #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] + marketplace_name: Option, +} + +pub async fn run_plugin_add( + overrides: Vec<(String, toml::Value)>, + args: AddPluginArgs, +) -> Result<()> { + let PluginCommandContext { + codex_home, + plugins_input, + manager, + } = load_plugin_command_context(overrides).await?; + let PluginSelection { + plugin_name, + marketplace_name, + .. + } = parse_plugin_selection(args.plugin, args.marketplace_name)?; + let marketplace = find_marketplace_for_plugin( + &manager, + codex_home.as_path(), + &plugins_input, + &marketplace_name, + &plugin_name, + )?; + let outcome = manager + .install_plugin(PluginInstallRequest { + plugin_name, + marketplace_path: marketplace.path, + }) + .await?; + + println!( + "Added plugin `{}` from marketplace `{}`.", + outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name + ); + println!( + "Installed plugin root: {}", + outcome.installed_path.as_path().display() + ); + + Ok(()) +} + +pub async fn run_plugin_list( + overrides: Vec<(String, toml::Value)>, + args: ListPluginsArgs, +) -> Result<()> { + let PluginCommandContext { + codex_home, + plugins_input, + manager, + .. + } = load_plugin_command_context(overrides).await?; + let outcome = manager + .list_marketplaces_for_config(&plugins_input, &[]) + .context("failed to list marketplace plugins")?; + ensure_configured_marketplace_snapshots_loaded( + codex_home.as_path(), + &plugins_input, + &outcome.errors, + /*marketplace_name*/ None, + )?; + + let marketplaces = outcome + .marketplaces + .into_iter() + .filter(|marketplace| { + args.marketplace_name + .as_ref() + .is_none_or(|name| marketplace.name == *name) + }) + .collect::>(); + + if marketplaces.is_empty() { + if let Some(marketplace_name) = args.marketplace_name { + println!("No plugins found in marketplace `{marketplace_name}`."); + } else { + println!("No marketplace plugins found."); + } + } else { + for marketplace in marketplaces { + println!("Marketplace `{}`", marketplace.name); + println!("Path: {}", marketplace.path.as_path().display()); + for plugin in &marketplace.plugins { + let state = if plugin.installed && plugin.enabled { + "installed, enabled" + } else if plugin.installed { + "installed, disabled" + } else { + "not installed" + }; + println!(" {} ({state})", plugin.id); + } + } + } + + Ok(()) +} + +pub async fn run_plugin_remove( + overrides: Vec<(String, toml::Value)>, + args: RemovePluginArgs, +) -> Result<()> { + let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; + let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?; + + manager.uninstall_plugin(selection.plugin_key).await?; + println!( + "Removed plugin `{}` from marketplace `{}`.", + selection.plugin_name, selection.marketplace_name + ); + + Ok(()) +} + +struct PluginCommandContext { + codex_home: PathBuf, + plugins_input: PluginsConfigInput, + manager: PluginsManager, +} + +async fn load_plugin_command_context( + overrides: Vec<(String, toml::Value)>, +) -> Result { + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let config = Config::load_with_cli_overrides(overrides) + .await + .context("failed to load configuration")?; + let plugins_input = config.plugins_config_input(); + let manager = PluginsManager::new(codex_home.to_path_buf()); + Ok(PluginCommandContext { + codex_home: codex_home.to_path_buf(), + plugins_input, + manager, + }) +} + +struct PluginSelection { + plugin_name: String, + marketplace_name: String, + plugin_key: String, +} + +impl PluginSelection { + fn from_plugin_id(plugin_id: PluginId) -> Self { + let plugin_key = plugin_id.as_key(); + Self { + plugin_name: plugin_id.plugin_name, + marketplace_name: plugin_id.marketplace_name, + plugin_key, + } + } +} + +fn parse_plugin_selection( + plugin: String, + marketplace_name: Option, +) -> Result { + match (PluginId::parse(&plugin), marketplace_name) { + (Ok(plugin_id), None) => Ok(PluginSelection::from_plugin_id(plugin_id)), + (Ok(plugin_id), Some(marketplace_name)) => { + if plugin_id.marketplace_name != marketplace_name { + bail!( + "plugin id `{}` belongs to marketplace `{}`, but --marketplace specified `{}`", + plugin, + plugin_id.marketplace_name, + marketplace_name + ); + } + Ok(PluginSelection::from_plugin_id(plugin_id)) + } + (Err(_), Some(marketplace_name)) => Ok(PluginSelection::from_plugin_id(PluginId::new( + plugin, + marketplace_name, + )?)), + (Err(_), None) => { + bail!("plugin requires --marketplace unless passed as @") + } + } +} + +fn find_marketplace_for_plugin( + manager: &PluginsManager, + codex_home: &std::path::Path, + plugins_input: &PluginsConfigInput, + marketplace_name: &str, + plugin_name: &str, +) -> Result { + let outcome = manager + .list_marketplaces_for_config(plugins_input, &[]) + .context("failed to list marketplace plugins")?; + ensure_configured_marketplace_snapshots_loaded( + codex_home, + plugins_input, + &outcome.errors, + Some(marketplace_name), + )?; + let matches = outcome + .marketplaces + .into_iter() + .filter(|marketplace| marketplace.name == marketplace_name) + .filter(|marketplace| { + marketplace + .plugins + .iter() + .any(|plugin| plugin.name == plugin_name) + }) + .collect::>(); + + match matches.as_slice() { + [] => bail!("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`"), + [marketplace] => Ok(marketplace.clone()), + _ => bail!( + "plugin `{plugin_name}` in marketplace `{marketplace_name}` matched multiple marketplace roots" + ), + } +} + +struct ConfiguredMarketplaceSnapshotIssue { + marketplace_name: String, + path: PathBuf, + message: String, +} + +fn ensure_configured_marketplace_snapshots_loaded( + codex_home: &std::path::Path, + plugins_input: &PluginsConfigInput, + load_errors: &[MarketplaceListError], + marketplace_name: Option<&str>, +) -> Result<()> { + let issues = configured_marketplace_snapshot_issues( + codex_home, + plugins_input, + load_errors, + marketplace_name, + ); + if issues.is_empty() { + return Ok(()); + } + + let issue_lines = issues + .iter() + .map(|issue| { + format!( + "- `{}` at {}: {}", + issue.marketplace_name, + issue.path.display(), + issue.message + ) + }) + .collect::>() + .join("\n"); + bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}"); +} + +fn configured_marketplace_snapshot_issues( + codex_home: &std::path::Path, + plugins_input: &PluginsConfigInput, + load_errors: &[MarketplaceListError], + marketplace_name: Option<&str>, +) -> Vec { + let Some(user_layer) = plugins_input.config_layer_stack.get_user_layer() else { + return Vec::new(); + }; + let Some(configured_marketplaces) = user_layer + .config + .get("marketplaces") + .and_then(toml::Value::as_table) + else { + return Vec::new(); + }; + + let default_install_root = marketplace_install_root(codex_home); + let mut manifest_paths = Vec::new(); + let mut issues = Vec::new(); + for (configured_name, marketplace) in configured_marketplaces { + if marketplace_name.is_some_and(|name| configured_name != name) { + continue; + } + if !marketplace.is_table() + || validate_plugin_segment(configured_name, "marketplace name").is_err() + { + continue; + } + let Some(root) = resolve_configured_marketplace_root( + configured_name, + marketplace, + &default_install_root, + ) else { + continue; + }; + match find_marketplace_manifest_path(&root) { + Some(path) => manifest_paths.push((configured_name.clone(), path)), + None => issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: root, + message: "marketplace root does not contain a supported manifest".to_string(), + }), + } + } + + for error in load_errors { + if let Some((configured_name, _)) = manifest_paths + .iter() + .find(|(_, path)| path.as_path() == error.path.as_path()) + { + issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: error.path.to_path_buf(), + message: error.message.clone(), + }); + } + } + issues +} diff --git a/codex-rs/cli/tests/app_server.rs b/codex-rs/cli/tests/app_server.rs new file mode 100644 index 0000000000..4a2642379d --- /dev/null +++ b/codex-rs/cli/tests/app_server.rs @@ -0,0 +1,30 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn strict_config_rejects_unknown_config_fields_for_app_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["app-server", "--strict-config", "--listen", "off"]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 17a7eff679..596cfaa8f8 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -11,6 +11,34 @@ fn codex_command(codex_home: &Path) -> Result { Ok(cmd) } +#[test] +fn strict_config_rejects_unknown_config_override() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["--strict-config", "-c", "foo=bar", "mcp-server"]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} + +#[test] +fn strict_config_is_not_supported_for_cloud_command() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["--strict-config", "-c", "foo=bar", "cloud", "list"]) + .assert() + .failure() + .stderr(contains( + "`--strict-config` is not supported for `codex cloud`", + )); + + Ok(()) +} + #[tokio::test] async fn features_enable_writes_feature_flag_to_config() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs new file mode 100644 index 0000000000..9829e4df07 --- /dev/null +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -0,0 +1,327 @@ +use anyhow::Result; +use codex_config::CONFIG_TOML_FILE; +use codex_config::MarketplaceConfigUpdate; +use codex_config::record_user_marketplace; +use predicates::prelude::PredicateBooleanExt; +use predicates::str::contains; +use std::path::Path; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +fn codex_command_in(codex_home: &Path, current_dir: &Path) -> Result { + let mut cmd = codex_command(codex_home)?; + cmd.current_dir(current_dir); + Ok(cmd) +} + +fn configured_local_marketplace(source: &str) -> MarketplaceConfigUpdate<'_> { + MarketplaceConfigUpdate { + last_updated: "2026-05-06T00:00:00Z", + last_revision: None, + source_type: "local", + source, + ref_name: None, + sparse_paths: &[], + } +} + +fn write_plugins_enabled_config(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + )?; + Ok(()) +} + +fn write_marketplace_source(source: &Path) -> Result<()> { + std::fs::create_dir_all(source.join(".agents/plugins"))?; + std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; + std::fs::write( + source.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + } + } + ] +}"#, + )?; + std::fs::write( + source.join("plugins/sample/.codex-plugin/plugin.json"), + r#"{"name":"sample","description":"Sample plugin"}"#, + )?; + Ok(()) +} + +fn setup_local_marketplace() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(source.path())?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + +fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source(source.path())?; + Ok((codex_home, source)) +} + +fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + +fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::create_dir_all(source.path().join(".agents/plugins"))?; + std::fs::write( + source.path().join(".agents/plugins/marketplace.json"), + "{not valid json", + )?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + +#[tokio::test] +async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert() + .success() + .stdout(contains("debug")) + .stdout(contains(source.path().display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_shows_plugins_grouped_by_marketplace() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("Marketplace `debug`")) + .stdout(contains("sample@debug (not installed)")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<()> { + let (codex_home, source) = setup_unconfigured_local_marketplace()?; + + codex_command_in(codex_home.path(), source.path())? + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("No marketplace plugins found.")) + .stdout(predicates::str::is_match("sample@debug").unwrap().not()); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> { + let (codex_home, source) = setup_configured_marketplace_without_manifest()?; + + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert() + .failure() + .stderr(contains( + "failed to load configured marketplace snapshot(s):", + )) + .stderr(contains("`debug`")) + .stderr(contains(source.path().display().to_string())) + .stderr(contains( + "marketplace root does not contain a supported manifest", + )); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success() + .stdout(contains("Added plugin `sample` from marketplace `debug`.")); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + assert!(config.contains("[plugins.\"sample@debug\"]")); + + codex_command(codex_home.path())? + .args(["plugin", "remove", "sample", "--marketplace", "debug"]) + .assert() + .success() + .stdout(contains( + "Removed plugin `sample` from marketplace `debug`.", + )); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + assert!(!config.contains("[plugins.\"sample@debug\"]")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { + let (codex_home, source) = setup_unconfigured_local_marketplace()?; + + codex_command_in(codex_home.path(), source.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .failure() + .stderr(contains( + "plugin `sample` was not found in marketplace `debug`", + )); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> { + let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .failure() + .stderr(contains( + "failed to load configured marketplace snapshot(s):", + )) + .stderr(contains("`debug`")) + .stderr(contains("invalid marketplace file")) + .stderr(contains("key must be a string")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_reinstalls_from_configured_marketplace_snapshot() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success() + .stdout(contains("Added plugin `sample` from marketplace `debug`.")); + + assert!( + codex_home + .path() + .join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json") + .is_file() + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_remove_works_after_marketplace_is_removed() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample", "--marketplace", "debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "remove", "sample@debug"]) + .assert() + .success() + .stdout(contains( + "Removed plugin `sample` from marketplace `debug`.", + )); + + let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?; + assert!(!config.contains("[plugins.\"sample@debug\"]")); + + Ok(()) +} + +#[tokio::test] +async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snapshot() -> Result<()> +{ + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug"]) + .assert() + .success(); + + assert!( + codex_home + .path() + .join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json") + .is_file() + ); + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .failure() + .stderr(contains( + "plugin `sample` was not found in marketplace `debug`", + )); + + Ok(()) +} diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 4c5eb6fbdc..0c2813e51a 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -7,10 +7,8 @@ use std::collections::BTreeMap; use crate::PUBLIC_TOOL_NAME; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; -const CODE_MODE_ONLY_PREFACE: &str = - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. -To find one, filter `ALL_TOOLS` by `name` and `description`; do not print the full `ALL_TOOLS` array. Print only a small set of relevant matches if you need to inspect them."#; +To find one, filter `ALL_TOOLS` by `name` and `description`."#; const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. - All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. @@ -256,9 +254,6 @@ pub fn build_exec_tool_description( deferred_tools_available: bool, ) -> String { let mut sections = Vec::new(); - if code_mode_only { - sections.push(CODE_MODE_ONLY_PREFACE.to_string()); - } sections.push(EXEC_DESCRIPTION_TEMPLATE.to_string()); if deferred_tools_available { sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string()); @@ -875,6 +870,7 @@ mod tests { "### `foo` bar" )); + assert!(!description.contains("do not attempt to use any other tools directly")); } #[test] @@ -1097,5 +1093,6 @@ bar" assert!(description.contains("Some nested MCP/app tools may be omitted")); assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`")); + assert!(!description.contains("do not print the full `ALL_TOOLS` array")); } } diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index c16687ff28..70c52b9580 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -24,4 +24,6 @@ pub use realtime_websocket::session_update_session_json; pub use responses::ResponsesClient; pub use responses::ResponsesOptions; pub use responses_websocket::ResponsesWebsocketClient; +pub use responses_websocket::ResponsesWebsocketClose; pub use responses_websocket::ResponsesWebsocketConnection; +pub use responses_websocket::ResponsesWebsocketProbe; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c5a682b328..f0a0019817 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -35,6 +35,7 @@ use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tracing::Instrument; use tracing::Span; use tracing::debug; @@ -321,12 +322,40 @@ impl ResponsesWebsocketConnection { } } +/// Client for connecting to the Responses WebSocket endpoint for one provider. pub struct ResponsesWebsocketClient { provider: Provider, auth: SharedAuthProvider, } +/// Close frame information captured by a handshake probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketClose { + /// WebSocket close code returned by the server. + pub code: String, + /// Human-readable close reason returned by the server. + pub reason: String, +} + +/// Result of a handshake-only Responses WebSocket probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketProbe { + /// Redacted by callers before displaying or serializing support reports. + pub url: String, + /// HTTP status returned by the successful WebSocket upgrade. + pub status: StatusCode, + /// Whether the server reported reasoning support in the upgrade response. + pub reasoning_included: bool, + /// Whether the server returned a model catalog ETag in the upgrade response. + pub models_etag_present: bool, + /// Whether the server returned a server-selected model in the upgrade response. + pub server_model_present: bool, + /// Close frame received immediately after upgrade, when one arrives quickly. + pub immediate_close: Option, +} + impl ResponsesWebsocketClient { + /// Creates a Responses WebSocket client for an already-resolved provider and auth source. pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self { Self { provider, auth } } @@ -353,7 +382,7 @@ impl ResponsesWebsocketClient { merge_request_headers(&self.provider.headers, extra_headers, default_headers); self.auth.add_auth_headers(&mut headers); - let (stream, server_reasoning_included, models_etag, server_model) = + let (stream, _status, server_reasoning_included, models_etag, server_model) = connect_websocket(ws_url, headers, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, @@ -364,6 +393,64 @@ impl ResponsesWebsocketClient { telemetry, )) } + + /// Opens a WebSocket connection long enough to validate the upgrade response. + /// + /// The probe uses the same URL construction, headers, authentication, TLS, + /// and custom-CA path as a real Responses WebSocket connection, but it does + /// not send a request frame. After the HTTP 101 upgrade succeeds, it waits + /// briefly for an immediate server close frame so diagnostics can distinguish + /// a usable connection from a policy rejection that closes right away. + pub async fn probe_handshake( + &self, + extra_headers: HeaderMap, + default_headers: HeaderMap, + immediate_close_timeout: Duration, + ) -> Result { + let ws_url = self + .provider + .websocket_url_for_path("responses") + .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; + + let mut headers = + merge_request_headers(&self.provider.headers, extra_headers, default_headers); + self.auth.add_auth_headers(&mut headers); + + let (mut stream, status, reasoning_included, models_etag, server_model) = + connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?; + let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next()) + .await + .ok() + .flatten() + .transpose() + .map_err(|err| { + ApiError::Stream(format!("failed to read websocket probe event: {err}")) + })? + .and_then(immediate_close_from_message); + + Ok(ResponsesWebsocketProbe { + url: ws_url.to_string(), + status, + reasoning_included, + models_etag_present: models_etag.is_some(), + server_model_present: server_model.is_some(), + immediate_close, + }) + } +} + +fn immediate_close_from_message(message: Message) -> Option { + let Message::Close(frame) = message else { + return None; + }; + frame.map(close_frame_to_probe) +} + +fn close_frame_to_probe(frame: CloseFrame) -> ResponsesWebsocketClose { + ResponsesWebsocketClose { + code: frame.code.to_string(), + reason: frame.reason.to_string(), + } } fn merge_request_headers( @@ -385,7 +472,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result<(WsStream, bool, Option, Option), ApiError> { +) -> Result<(WsStream, StatusCode, bool, Option, Option), ApiError> { ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); @@ -445,6 +532,7 @@ async fn connect_websocket( } Ok(( WsStream::new(stream), + response.status(), reasoning_included, models_etag, server_model, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index d7017c8d3f..99470cac59 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -55,7 +55,9 @@ pub use crate::endpoint::RealtimeWebsocketWriter; pub use crate::endpoint::ResponsesClient; pub use crate::endpoint::ResponsesOptions; pub use crate::endpoint::ResponsesWebsocketClient; +pub use crate::endpoint::ResponsesWebsocketClose; pub use crate::endpoint::ResponsesWebsocketConnection; +pub use crate::endpoint::ResponsesWebsocketProbe; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index ed5a5ad647..c5cede8ac6 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -5,11 +5,9 @@ use http::HeaderValue; pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); if let Some(id) = session_id { - insert_header(&mut headers, "session_id", &id); insert_header(&mut headers, "session-id", &id); } if let Some(id) = thread_id { - insert_header(&mut headers, "thread_id", &id); insert_header(&mut headers, "thread-id", &id); } headers diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index a78073b302..afaf82389f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -458,18 +458,10 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { assert_eq!(requests.len(), 1); let req = &requests[0]; - assert_eq!( - req.headers.get("session_id").and_then(|v| v.to_str().ok()), - Some("sess_123") - ); assert_eq!( req.headers.get("session-id").and_then(|v| v.to_str().ok()), Some("sess_123") ); - assert_eq!( - req.headers.get("thread_id").and_then(|v| v.to_str().ok()), - Some("thread_123") - ); assert_eq!( req.headers.get("thread-id").and_then(|v| v.to_str().ok()), Some("thread_123") diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 5d04dc2a36..976bd4c768 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -596,8 +596,8 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() { create_test_tool_with_connector( CODEX_APPS_MCP_SERVER_NAME, "blocked_tool", - "connector_openai_hidden", - Some("Hidden"), + "connector_2b0a9009c9c64bf9933a3dae3f2b1254", + Some("Blocked"), ), create_test_tool_with_connector( CODEX_APPS_MCP_SERVER_NAME, diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 9583a57c62..c345f31c6e 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -32,6 +32,7 @@ multimap = { workspace = true } prost = "0.14.3" schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_ignored = { workspace = true } serde_json = { workspace = true } serde_path_to_error = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 06993e046a..591b259676 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -177,14 +177,6 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, - /// Optional commit attribution text for commit message co-author trailers. - /// This top-level setting only takes effect when `[features].codex_git_commit` - /// is enabled. - /// - /// When enabled and unset, Codex uses `Codex `. - /// Set to an empty string to disable automatic commit attribution. - pub commit_attribution: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] pub forced_chatgpt_workspace_id: Option, @@ -451,7 +443,6 @@ pub struct ConfigToml { pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, - pub experimental_use_freeform_apply_patch: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". pub oss_provider: Option, } @@ -606,10 +597,6 @@ pub struct ToolsToml { deserialize_with = "deserialize_optional_web_search_tool_config" )] pub web_search: Option, - - /// Enable the `view_image` tool that lets the agent attach local images. - #[serde(default)] - pub view_image: Option, } #[derive(Deserialize)] @@ -687,7 +674,6 @@ impl From for Tools { fn from(tools_toml: ToolsToml) -> Self { Self { web_search: tools_toml.web_search.is_some().then_some(true), - view_image: tools_toml.view_image, } } } diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 899114d6d7..3fc593dcdf 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -86,6 +86,23 @@ impl std::error::Error for ConfigLoadError { } } +#[derive(Clone, Copy)] +pub(crate) enum ConfigDiagnosticSource<'a> { + Path(&'a Path), + #[cfg(any(target_os = "macos", test))] + DisplayName(&'a str), +} + +impl ConfigDiagnosticSource<'_> { + pub(crate) fn to_path_buf(self) -> PathBuf { + match self { + ConfigDiagnosticSource::Path(path) => path.to_path_buf(), + #[cfg(any(target_os = "macos", test))] + ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name), + } + } +} + pub fn io_error_from_config_error( kind: io::ErrorKind, error: ConfigError, @@ -98,21 +115,39 @@ pub fn config_error_from_toml( path: impl AsRef, contents: &str, err: toml::de::Error, +) -> ConfigError { + config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err) +} + +pub(crate) fn config_error_from_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + err: toml::de::Error, ) -> ConfigError { let range = err .span() .map(|span| text_range_from_span(contents, span)) .unwrap_or_else(default_range); - ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) + ConfigError::new(source.to_path_buf(), range, err.message()) } pub fn config_error_from_typed_toml( path: impl AsRef, contents: &str, +) -> Option { + config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + ) +} + +fn config_error_from_typed_toml_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, ) -> Option { let deserializer = match toml::de::Deserializer::parse(contents) { Ok(deserializer) => deserializer, - Err(err) => return Some(config_error_from_toml(path, contents, err)), + Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)), }; let result: Result = serde_path_to_error::deserialize(deserializer); @@ -126,7 +161,7 @@ pub fn config_error_from_typed_toml( .map(|span| text_range_from_span(contents, span)) .unwrap_or_else(default_range); Some(ConfigError::new( - path.as_ref().to_path_buf(), + source.to_path_buf(), range, toml_err.message(), )) @@ -194,7 +229,7 @@ where fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option { match &layer.name { ConfigLayerSource::System { file } => Some(file.to_path_buf()), - ConfigLayerSource::User { file } => Some(file.to_path_buf()), + ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()), ConfigLayerSource::Project { dot_codex_folder } => { Some(dot_codex_folder.as_path().join(config_toml_file)) } @@ -205,7 +240,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op } } -fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { +pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { let start = position_for_offset(contents, span.start); let end_index = if span.end > span.start { span.end - 1 @@ -290,7 +325,7 @@ fn position_for_offset(contents: &str, index: usize) -> TextPosition { } } -fn default_range() -> TextRange { +pub(crate) fn default_range() -> TextRange { let position = TextPosition { line: 1, column: 1 }; TextRange { start: position, @@ -314,7 +349,10 @@ fn span_for_path(contents: &str, path: &SerdePath) -> Option Option> { +pub(crate) fn span_for_config_path( + contents: &str, + path: &SerdePath, +) -> Option> { if is_features_table_path(path) && let Some(span) = span_for_features_value(contents) { @@ -323,6 +361,48 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option Option> { + let doc = contents.parse::>().ok()?; + let mut node = TomlNode::Item(doc.as_item()); + for (index, segment) in path.iter().enumerate() { + if index + 1 == path.len() { + let key_span = match &node { + TomlNode::Item(item) => item + .as_table_like() + .and_then(|table| table.get_key_value(segment)) + .and_then(|(key, _)| key.span()), + TomlNode::Table(table) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + TomlNode::Value(Value::InlineTable(table)) => { + table.get_key_value(segment).and_then(|(key, _)| key.span()) + } + _ => None, + }; + if key_span.is_some() { + return key_span; + } + } + + if let Some(next) = map_child(&node, segment) { + node = next; + continue; + } + + let index = segment.parse::().ok()?; + node = seq_child(&node, index)?; + } + + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + fn is_features_table_path(path: &SerdePath) -> bool { let mut segments = path.iter(); matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 2d4c62013e..371c3ffba0 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -21,6 +21,7 @@ mod requirements_exec_policy; pub mod schema; mod skills_config; mod state; +mod strict_config; mod thread_config; mod tui_keymap; pub mod types; @@ -31,6 +32,8 @@ pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; +pub use codex_protocol::config_types::ProfileV2Name; +pub use codex_protocol::config_types::ProfileV2NameParseError; pub use codex_utils_absolute_path::AbsolutePathBuf; pub use config_requirements::AppRequirementToml; pub use config_requirements::AppToolRequirementToml; @@ -116,7 +119,9 @@ pub use skills_config::SkillsConfig; pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; +pub use state::ConfigLoadOptions; pub use state::LoaderOverrides; +pub use strict_config::config_error_from_ignored_toml_fields; pub use thread_config::NoopThreadConfigLoader; pub use thread_config::RemoteThreadConfigLoader; pub use thread_config::SessionThreadConfig; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index 28750c4929..e004cea87c 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -10,13 +10,14 @@ This module is the canonical place to **load and describe Codex configuration la Exported from `codex_config::loader`: -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack` +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack` - `ConfigLayerStack` - `effective_config() -> toml::Value` - `origins() -> HashMap` - `layers_high_to_low() -> Vec` - `with_user_config(user_config) -> ConfigLayerStack` - `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) +- `ConfigLoadOptions` (user-facing load behavior such as strict config validation) - `LoaderOverrides` (test/override hooks for managed config sources) - `merge_toml_values(base, overlay)` (public helper used elsewhere) diff --git a/codex-rs/config/src/loader/layer_io.rs b/codex-rs/config/src/loader/layer_io.rs index 9c15df7271..415d82e405 100644 --- a/codex-rs/config/src/loader/layer_io.rs +++ b/codex-rs/config/src/loader/layer_io.rs @@ -2,11 +2,14 @@ use super::macos::ManagedAdminConfigLayer; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; +use crate::config_toml::ConfigToml; use crate::diagnostics::config_error_from_toml; use crate::diagnostics::io_error_from_config_error; use crate::state::LoaderOverrides; +use crate::strict_config::config_error_from_ignored_toml_value_fields; use codex_file_system::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; use std::io; use std::path::Path; use std::path::PathBuf; @@ -39,6 +42,7 @@ pub(super) async fn load_config_layers_internal( fs: &dyn ExecutorFileSystem, codex_home: &Path, overrides: LoaderOverrides, + strict_config: bool, ) -> io::Result { #[cfg(target_os = "macos")] let LoaderOverrides { @@ -57,19 +61,26 @@ pub(super) async fn load_config_layers_internal( managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)), )?; - let managed_config = - read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false) - .await? - .map(|managed_config| MangedConfigFromFile { - managed_config, - file: managed_config_path.clone(), - }); + let managed_config = read_config_from_path( + fs, + &managed_config_path, + /*log_missing_as_info*/ false, + strict_config, + ) + .await? + .map(|loaded| MangedConfigFromFile { + managed_config: loaded, + file: managed_config_path.clone(), + }); #[cfg(target_os = "macos")] - let managed_preferences = - load_managed_admin_config_layer(managed_preferences_base64.as_deref()) - .await? - .map(map_managed_admin_layer); + let managed_preferences = load_managed_admin_config_layer( + managed_preferences_base64.as_deref(), + strict_config, + codex_home, + ) + .await? + .map(map_managed_admin_layer); #[cfg(not(target_os = "macos"))] let managed_preferences = None; @@ -93,10 +104,16 @@ pub(super) async fn read_config_from_path( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, log_missing_as_info: bool, + strict_config: bool, ) -> io::Result> { match fs.read_file_text(path, /*sandbox*/ None).await { Ok(contents) => match toml::from_str::(&contents) { - Ok(value) => Ok(Some(value)), + Ok(value) => { + if strict_config { + validate_config_toml_strictly(path, &contents, &value)?; + } + Ok(Some(value)) + } Err(err) => { tracing::error!("Failed to parse {}: {err}", path.as_path().display()); let config_error = config_error_from_toml(path.as_path(), &contents, err.clone()); @@ -122,6 +139,33 @@ pub(super) async fn read_config_from_path( } } +fn validate_config_toml_strictly( + path: &AbsolutePathBuf, + contents: &str, + value: &TomlValue, +) -> io::Result<()> { + let Some(base_dir) = path.as_path().parent() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Config file {} has no parent directory", path.display()), + )); + }; + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields::( + path.as_path(), + contents, + value.clone(), + ) { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )); + } + + Ok(()) +} + /// Return the default managed config path. pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf { #[cfg(unix)] diff --git a/codex-rs/config/src/loader/macos.rs b/codex-rs/config/src/loader/macos.rs index 3a9fc3a0ea..199e7f6daa 100644 --- a/codex-rs/config/src/loader/macos.rs +++ b/codex-rs/config/src/loader/macos.rs @@ -2,13 +2,20 @@ use super::merge_requirements_with_remote_sandbox_config; use crate::config_requirements::ConfigRequirementsToml; use crate::config_requirements::ConfigRequirementsWithSources; use crate::config_requirements::RequirementSource; +use crate::config_toml::ConfigToml; +use crate::diagnostics::ConfigDiagnosticSource; +use crate::diagnostics::config_error_from_toml_for_source; +use crate::diagnostics::io_error_from_config_error; +use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name; use base64::Engine; use base64::prelude::BASE64_STANDARD; +use codex_utils_absolute_path::AbsolutePathBufGuard; use core_foundation::base::TCFType; use core_foundation::string::CFString; use core_foundation::string::CFStringRef; use std::ffi::c_void; use std::io; +use std::path::Path; use tokio::task; use toml::Value as TomlValue; @@ -31,17 +38,20 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource { pub(crate) async fn load_managed_admin_config_layer( override_base64: Option<&str>, + strict_config: bool, + base_dir: &Path, ) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); return if trimmed.is_empty() { Ok(None) } else { - parse_managed_config_base64(trimmed).map(Some) + parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some) }; } - match task::spawn_blocking(load_managed_admin_config).await { + let base_dir = base_dir.to_path_buf(); + match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await { Ok(result) => result, Err(join_err) => { if join_err.is_cancelled() { @@ -54,11 +64,14 @@ pub(crate) async fn load_managed_admin_config_layer( } } -fn load_managed_admin_config() -> io::Result> { +fn load_managed_admin_config( + strict_config: bool, + base_dir: &Path, +) -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)? .as_deref() .map(str::trim) - .map(parse_managed_config_base64) + .map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir)) .transpose() } @@ -134,24 +147,73 @@ fn load_managed_preference(key_name: &str) -> io::Result> { Ok(Some(value)) } -fn parse_managed_config_base64(encoded: &str) -> io::Result { +fn parse_managed_config_base64( + encoded: &str, + strict_config: bool, + base_dir: &Path, +) -> io::Result { let raw_toml = decode_managed_preferences_base64(encoded)?; - match toml::from_str::(&raw_toml) { - Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer { + let source_name = + format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}"); + let parsed = toml::from_str::(&raw_toml).map_err(|err| { + tracing::error!("Failed to parse managed config TOML: {err}"); + if strict_config { + let config_error = config_error_from_toml_for_source( + ConfigDiagnosticSource::DisplayName(&source_name), + &raw_toml, + err.clone(), + ); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + } else { + io::Error::new(io::ErrorKind::InvalidData, err) + } + })?; + + validate_managed_config_toml_strictly_if_requested( + strict_config, + &source_name, + &raw_toml, + &parsed, + base_dir, + )?; + match parsed { + TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer { config: TomlValue::Table(parsed), raw_toml, }), - Ok(other) => { + other => { tracing::error!("Managed config TOML must have a table at the root, found {other:?}",); Err(io::Error::new( io::ErrorKind::InvalidData, "managed config root must be a table", )) } - Err(err) => { - tracing::error!("Failed to parse managed config TOML: {err}"); - Err(io::Error::new(io::ErrorKind::InvalidData, err)) - } + } +} + +fn validate_managed_config_toml_strictly_if_requested( + strict_config: bool, + source_name: &str, + raw_toml: &str, + parsed: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + if !strict_config { + return Ok(()); + } + + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::< + ConfigToml, + >(source_name, raw_toml, parsed.clone()) + { + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )) + } else { + Ok(()) } } diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 6e98691465..37cb41b3e1 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -4,6 +4,7 @@ mod macos; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; +use crate::ProfileV2Name; use crate::cloud_requirements::CloudRequirementsLoader; use crate::config_requirements::ConfigRequirementsToml; use crate::config_requirements::ConfigRequirementsWithSources; @@ -21,7 +22,11 @@ use crate::project_root_markers::default_project_root_markers; use crate::project_root_markers::project_root_markers_from_config; use crate::state::ConfigLayerEntry; use crate::state::ConfigLayerStack; +use crate::state::ConfigLoadOptions; use crate::state::LoaderOverrides; +use crate::strict_config::config_error_from_ignored_toml_value_fields; +use crate::strict_config::ignored_toml_value_field; +use crate::strict_config::unknown_feature_toml_value_field; use crate::thread_config::ThreadConfigContext; use crate::thread_config::ThreadConfigLoader; use codex_app_server_protocol::ConfigLayerSource; @@ -85,6 +90,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) /// - user `${CODEX_HOME}/config.toml` +/// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) /// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted) /// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted) @@ -104,10 +110,15 @@ pub async fn load_config_layers_state( codex_home: &Path, cwd: Option, cli_overrides: &[(String, TomlValue)], - overrides: LoaderOverrides, + options: impl Into, cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { + let ConfigLoadOptions { + loader_overrides: overrides, + strict_config, + } = options.into(); + let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -140,7 +151,8 @@ pub async fn load_config_layers_state( // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. let loaded_config_layers = - layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?; + layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) + .await?; if !ignore_managed_requirements { load_requirements_from_legacy_scheme( &mut config_requirements_toml, @@ -168,6 +180,9 @@ pub async fn load_config_layers_state( .as_ref() .map(AbsolutePathBuf::as_path) .unwrap_or(codex_home); + if strict_config { + validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?; + } Some(resolve_relative_paths_in_config_toml( cli_overrides_layer, base_dir, @@ -177,41 +192,50 @@ pub async fn load_config_layers_state( // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?; - let system_layer = - load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| { + let system_layer = load_config_toml_for_required_layer( + fs, + &system_config_toml_file, + strict_config, + |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::System { file: system_config_toml_file.clone(), }, config_toml, ) - }) - .await?; + }, + ) + .await?; layers.push(system_layer); - // Add a layer for $CODEX_HOME/config.toml so folder-derived resources such - // as rules/ can still be discovered. When user config is ignored, preserve - // the layer metadata without reading config.toml. - let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); - let user_layer = if ignore_user_config { - ConfigLayerEntry::new( - ConfigLayerSource::User { - file: user_file.clone(), - }, - TomlValue::Table(toml::map::Map::new()), + // Add the base user config layer. When profile-v2 is selected, add the + // profile config as a second user layer on top so the profile only needs to + // contain overrides. + let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); + layers.push( + load_user_config_layer( + fs, + &base_user_file, + /*profile*/ None, + ignore_user_config, + strict_config, ) - } else { - load_config_toml_for_required_layer(fs, &user_file, |config_toml| { - ConfigLayerEntry::new( - ConfigLayerSource::User { - file: user_file.clone(), - }, - config_toml, + .await?, + ); + + let active_user_file = overrides.user_config_path(codex_home)?; + if active_user_file != base_user_file { + layers.push( + load_user_config_layer( + fs, + &active_user_file, + active_user_profile.as_ref(), + ignore_user_config, + strict_config, ) - }) - .await? - }; - layers.push(user_layer); + .await?, + ); + } let mut startup_warnings = None; if let Some(cwd) = cwd { @@ -242,7 +266,7 @@ pub async fn load_config_layers_state( &cwd, &project_root_markers, codex_home, - &user_file, + &active_user_file, ) .await { @@ -268,6 +292,7 @@ pub async fn load_config_layers_state( &project_trust_context.project_root, &project_trust_context, codex_home, + strict_config, ) .await?; layers.extend(project_layers.layers); @@ -339,6 +364,36 @@ pub async fn load_config_layers_state( }) } +async fn load_user_config_layer( + fs: &dyn ExecutorFileSystem, + user_file: &AbsolutePathBuf, + profile: Option<&ProfileV2Name>, + ignore_user_config: bool, + strict_config: bool, +) -> io::Result { + let profile = profile.map(ToString::to_string); + if ignore_user_config { + return Ok(ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile, + }, + TomlValue::Table(toml::map::Map::new()), + )); + } + + load_config_toml_for_required_layer(fs, user_file, strict_config, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile: profile.clone(), + }, + config_toml, + ) + }) + .await +} + fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigLayerEntry) { match layers .iter() @@ -359,15 +414,11 @@ fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigL async fn load_config_toml_for_required_layer( fs: &dyn ExecutorFileSystem, toml_file: &AbsolutePathBuf, + strict_config: bool, create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, ) -> io::Result { let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|err| { - let config_error = - config_error_from_toml(toml_file.as_path(), &contents, err.clone()); - io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) - })?; let config_parent = toml_file.as_path().parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, @@ -377,6 +428,19 @@ async fn load_config_toml_for_required_layer( ), ) })?; + let config: TomlValue = toml::from_str(&contents).map_err(|err| { + let config_error = + config_error_from_toml(toml_file.as_path(), &contents, err.clone()); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) + })?; + if strict_config { + validate_config_toml_strictly( + toml_file.as_path(), + &contents, + &config, + config_parent, + )?; + } resolve_relative_paths_in_config_toml(config, config_parent) } Err(e) => { @@ -397,6 +461,51 @@ async fn load_config_toml_for_required_layer( Ok(create_entry(toml_value)) } +fn validate_config_toml_strictly( + toml_file: &Path, + contents: &str, + value: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(config_error) = config_error_from_ignored_toml_value_fields::( + toml_file, + contents, + value.clone(), + ) { + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + /*source*/ None, + )) + } else { + Ok(()) + } +} + +fn validate_cli_overrides_strictly( + cli_overrides_layer: &TomlValue, + base_dir: &Path, +) -> io::Result<()> { + let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(ignored_path) = ignored_toml_value_field::(cli_overrides_layer.clone()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown configuration field `{ignored_path}` in -c/--config override"), + )); + } + + if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown configuration field `{ignored_path}` in -c/--config override"), + )); + } + + Ok(()) +} + /// If available, apply requirements from the platform system /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. @@ -998,6 +1107,7 @@ async fn load_project_layers( project_root: &AbsolutePathBuf, trust_context: &ProjectTrustContext, codex_home: &Path, + strict_config: bool, ) -> io::Result { let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?; let codex_home_normalized = @@ -1063,6 +1173,14 @@ async fn load_project_layers( } }; let mut config = config; + if disabled_reason.is_none() && strict_config { + validate_config_toml_strictly( + config_file.as_path(), + &contents, + &config, + dot_codex_abs.as_path(), + )?; + } let ignored_project_config_keys = sanitize_project_config(&mut config); let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 11d3a6a403..e8e6320490 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -54,14 +54,11 @@ pub struct ConfigProfile { #[schemars(skip)] pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, - pub include_apply_patch_tool: Option, pub include_permissions_instructions: Option, pub include_apps_instructions: Option, pub include_collaboration_mode_instructions: Option, pub include_environment_context: Option, pub experimental_use_unified_exec_tool: Option, - pub experimental_use_freeform_apply_patch: Option, - pub tools_view_image: Option, pub tools: Option, pub web_search: Option, pub analytics: Option, diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index e718ecae67..3ec0bfb314 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -5,18 +5,38 @@ use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; use super::key_aliases::normalized_with_key_aliases; use super::merge::merge_toml_values; +use crate::ProfileV2Name; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; use codex_app_server_protocol::ConfigLayerSource; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; +/// User-facing config loading behavior that is not part of the config document. +#[derive(Debug, Default, Clone)] +pub struct ConfigLoadOptions { + pub loader_overrides: LoaderOverrides, + pub strict_config: bool, +} + +impl From for ConfigLoadOptions { + fn from(loader_overrides: LoaderOverrides) -> Self { + Self { + loader_overrides, + strict_config: false, + } + } +} + /// LoaderOverrides overrides managed configuration inputs (primarily for tests). #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { + pub user_config_path: Option, + pub user_config_profile: Option, pub managed_config_path: Option, pub system_config_path: Option, pub system_requirements_path: Option, @@ -36,6 +56,8 @@ impl LoaderOverrides { pub fn without_managed_config_for_tests() -> Self { let base = std::env::temp_dir().join("codex-config-tests"); Self { + user_config_path: None, + user_config_profile: None, managed_config_path: Some(base.join("managed_config.toml")), system_config_path: Some(base.join("config.toml")), system_requirements_path: Some(base.join("requirements.toml")), @@ -53,10 +75,22 @@ impl LoaderOverrides { /// This is intended for tests that supply an explicit managed config fixture. pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { Self { + user_config_path: None, + user_config_profile: None, managed_config_path: Some(managed_config_path), ..Self::without_managed_config_for_tests() } } + + pub fn user_config_path(&self, codex_home: &Path) -> std::io::Result { + match self.user_config_path.as_ref() { + Some(path) => Ok(path.clone()), + None => Ok(AbsolutePathBuf::resolve_path_against_base( + crate::CONFIG_TOML_FILE, + codex_home, + )), + } + } } #[derive(Debug, Clone, PartialEq)] @@ -147,7 +181,7 @@ impl ConfigLayerEntry { match &self.name { ConfigLayerSource::Mdm { .. } => None, ConfigLayerSource::System { file } => file.parent(), - ConfigLayerSource::User { file } => file.parent(), + ConfigLayerSource::User { file, .. } => file.parent(), ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), ConfigLayerSource::SessionFlags => None, ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None, @@ -180,7 +214,12 @@ pub struct ConfigLayerStack { /// later entries in the Vec override earlier ones. layers: Vec, - /// Index into [layers] of the user config layer, if any. + /// Index into [layers] of the active user config layer, if any. + /// + /// When profile config is active, there can be more than one user layer: + /// the base `$CODEX_HOME/config.toml` layer followed by the profile override + /// layer. This index points at the highest-precedence user layer because that + /// is the writable layer for profile-aware edits. user_layer_index: Option, /// Constraints that must be enforced when deriving a [Config] from the @@ -240,14 +279,61 @@ impl ConfigLayerStack { self.startup_warnings.as_deref() } - /// Returns the raw user config layer, if any. + /// Returns the active raw user config layer, if any. /// - /// This does not merge other config layers or apply any requirements. - pub fn get_user_layer(&self) -> Option<&ConfigLayerEntry> { + /// This does not merge other config layers or apply any requirements. When + /// a profile-v2 layer is active, this returns that profile layer rather than + /// the base `$CODEX_HOME/config.toml` layer because the active layer is the + /// writable target for profile-aware edits. + pub fn get_active_user_layer(&self) -> Option<&ConfigLayerEntry> { self.user_layer_index .and_then(|index| self.layers.get(index)) } + pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> { + let layer = self.get_active_user_layer()?; + let ConfigLayerSource::User { file, .. } = &layer.name else { + return None; + }; + Some(file) + } + + /// Returns all user config layers in the requested precedence order. + /// + /// With profile-v2 enabled, `LowestPrecedenceFirst` returns the base user + /// config before the profile overlay, while `HighestPrecedenceFirst` returns + /// the profile overlay before the base user config. + pub fn get_user_layers( + &self, + ordering: ConfigLayerStackOrdering, + include_disabled: bool, + ) -> Vec<&ConfigLayerEntry> { + self.get_layers(ordering, include_disabled) + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. })) + .collect() + } + + /// Returns the merged config from enabled user layers only. + /// + /// When profile config is active, this includes the base user config followed + /// by the profile override config. + pub fn effective_user_config(&self) -> Option { + let user_layers = self.get_user_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ); + if user_layers.is_empty() { + return None; + } + + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in user_layers { + merge_toml_values(&mut merged, &layer.config); + } + Some(merged) + } + pub fn requirements(&self) -> &ConfigRequirements { &self.requirements } @@ -256,54 +342,101 @@ impl ConfigLayerStack { &self.requirements_toml } - /// Creates a new [ConfigLayerStack] using the specified values to inject a - /// "user layer" into the stack. If such a layer already exists, it is - /// replaced; otherwise, it is inserted into the stack at the appropriate - /// position based on precedence rules. + /// Creates a new [ConfigLayerStack] using the specified values to inject one + /// user layer into the stack. If such a layer already exists, it is replaced; + /// otherwise, it is inserted into the stack at the appropriate position + /// based on precedence rules. When the stack has both base and profile-v2 + /// user layers, this updates only the layer whose file matches + /// `config_toml`. pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self { - self.with_user_layer(Some(ConfigLayerEntry::new( + let profile = self.layers.iter().find_map(|layer| match &layer.name { + ConfigLayerSource::User { file, profile } if file == config_toml => profile + .as_deref() + .and_then(|profile| profile.parse::().ok()), + _ => None, + }); + self.with_user_config_profile(config_toml, profile.as_ref(), user_config) + } + + pub fn with_user_config_profile( + &self, + config_toml: &AbsolutePathBuf, + profile: Option<&ProfileV2Name>, + user_config: TomlValue, + ) -> Self { + let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: config_toml.clone(), + profile: profile.map(ToString::to_string), }, user_config, - ))) + ); + + let mut layers = self.layers.clone(); + if let Some(index) = layers.iter().position(|layer| { + matches!( + &layer.name, + ConfigLayerSource::User { file, .. } if file == config_toml + ) + }) { + layers.remove(index); + } + match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => layers.insert(index, user_layer), + None => layers.push(user_layer), + } + let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| { + if matches!(layer.name, ConfigLayerSource::User { .. }) { + Some(index) + } else { + None + } + }); + Self { + layers, + user_layer_index, + requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, + startup_warnings: self.startup_warnings.clone(), + } } /// Returns a new stack with the user layer copied from `other`, preserving /// every non-user layer already present in this stack. pub fn with_user_layer_from(&self, other: &Self) -> Self { - self.with_user_layer(other.get_user_layer().cloned()) - } - - fn with_user_layer(&self, user_layer: Option) -> Self { - let mut layers = self.layers.clone(); - let user_layer_index = match (self.user_layer_index, user_layer) { - (Some(index), Some(user_layer)) => { - layers[index] = user_layer; - Some(index) + let user_layers = other + .layers + .iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. })) + .cloned() + .collect::>(); + let mut layers = self + .layers + .iter() + .filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. })) + .cloned() + .collect::>(); + for user_layer in user_layers { + match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => layers.insert(index, user_layer), + None => layers.push(user_layer), } - (Some(index), None) => { - layers.remove(index); + } + let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| { + if matches!(layer.name, ConfigLayerSource::User { .. }) { + Some(index) + } else { None } - (None, Some(user_layer)) => { - let user_layer_index = match layers - .iter() - .position(|layer| layer.name.precedence() > user_layer.name.precedence()) - { - Some(index) => { - layers.insert(index, user_layer); - index - } - None => { - layers.push(user_layer); - layers.len() - 1 - } - }; - Some(user_layer_index) - } - (None, None) => None, - }; + }); Self { layers, user_layer_index, @@ -379,7 +512,7 @@ impl ConfigLayerStack { } /// Ensures precedence ordering of config layers is correct. Returns the index -/// of the user config layer, if any (at most one should exist). +/// of the active user config layer, if any. fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result> { if !layers.iter().map(|layer| &layer.name).is_sorted() { return Err(std::io::Error::new( @@ -389,19 +522,13 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result = None; let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None; for (index, layer) in layers.iter().enumerate() { if matches!(layer.name, ConfigLayerSource::User { .. }) { - if user_layer_index.is_some() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "multiple user config layers found", - )); - } user_layer_index = Some(index); } diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs index 81e1fa63fc..fb6e26968b 100644 --- a/codex-rs/config/src/state_tests.rs +++ b/codex-rs/config/src/state_tests.rs @@ -1,5 +1,11 @@ use super::*; use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn test_user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name)) + .expect("test user config path should be absolute") +} #[test] fn origins_use_canonical_key_aliases() { @@ -32,3 +38,104 @@ no_memories_if_mcp_or_web_search = true "legacy key should be canonicalized before origin recording" ); } + +#[test] +fn active_user_layer_is_highest_precedence_user_layer() { + let temp_dir = TempDir::new().expect("tempdir"); + let base_file = test_user_config_path(&temp_dir, "config.toml"); + let profile_file = test_user_config_path(&temp_dir, "work.config.toml"); + let base_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file, + profile: None, + }, + toml::from_str( + r#" +model = "base" +approval_policy = "on-failure" +"#, + ) + .expect("base config"), + ); + let profile_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: profile_file.clone(), + profile: Some("work".to_string()), + }, + toml::from_str(r#"model = "profile""#).expect("profile config"), + ); + let stack = ConfigLayerStack::new( + vec![base_layer, profile_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("multiple user layers should be valid"); + + assert_eq!(stack.get_user_config_file(), Some(&profile_file)); + assert_eq!( + stack + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("profile") + ); + assert_eq!( + stack + .effective_user_config() + .expect("merged user config") + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("on-failure") + ); +} + +#[test] +fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() { + let temp_dir = TempDir::new().expect("tempdir"); + let base_file = test_user_config_path(&temp_dir, "config.toml"); + let profile_file = test_user_config_path(&temp_dir, "work.config.toml"); + let base_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file.clone(), + profile: None, + }, + toml::from_str(r#"model = "base""#).expect("base config"), + ); + let profile_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: profile_file.clone(), + profile: Some("work".to_string()), + }, + toml::from_str(r#"approval_policy = "on-failure""#).expect("profile config"), + ); + let stack = ConfigLayerStack::new( + vec![base_layer, profile_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("multiple user layers should be valid"); + + let updated = stack.with_user_config( + &base_file, + toml::from_str(r#"model = "updated-base""#).expect("updated base config"), + ); + + assert_eq!(updated.get_user_config_file(), Some(&profile_file)); + assert_eq!( + updated + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("updated-base") + ); + assert_eq!( + updated + .effective_user_config() + .expect("merged user config") + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("on-failure") + ); +} diff --git a/codex-rs/config/src/strict_config.rs b/codex-rs/config/src/strict_config.rs new file mode 100644 index 0000000000..fb64458e07 --- /dev/null +++ b/codex-rs/config/src/strict_config.rs @@ -0,0 +1,201 @@ +//! Strict config validation built on top of serde's ignored-field tracking. + +use crate::diagnostics::ConfigDiagnosticSource; +use crate::diagnostics::ConfigError; +use crate::diagnostics::config_error_from_toml_for_source; +use crate::diagnostics::default_range; +use crate::diagnostics::span_for_config_path; +use crate::diagnostics::span_for_toml_key_path; +use crate::diagnostics::text_range_from_span; +use codex_features::is_known_feature_key; +use serde::de::DeserializeOwned; +use std::path::Path; +use toml::Value as TomlValue; + +pub fn config_error_from_ignored_toml_fields( + path: impl AsRef, + contents: &str, +) -> Option { + let source = ConfigDiagnosticSource::Path(path.as_ref()); + match toml::from_str::(contents) { + Ok(value) => { + config_error_from_ignored_toml_value_fields_for_source::(source, contents, value) + } + Err(err) => Some(config_error_from_toml_for_source(source, contents, err)), + } +} + +pub(crate) fn config_error_from_ignored_toml_value_fields( + path: impl AsRef, + contents: &str, + value: TomlValue, +) -> Option { + config_error_from_ignored_toml_value_fields_for_source::( + ConfigDiagnosticSource::Path(path.as_ref()), + contents, + value, + ) +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name( + source_name: &str, + contents: &str, + value: TomlValue, +) -> Option { + config_error_from_ignored_toml_value_fields_for_source::( + ConfigDiagnosticSource::DisplayName(source_name), + contents, + value, + ) +} + +fn config_error_from_ignored_toml_value_fields_for_source( + source: ConfigDiagnosticSource<'_>, + contents: &str, + value: TomlValue, +) -> Option { + let unknown_feature_paths = unknown_feature_toml_value_path(&value); + let mut ignored_paths = Vec::new(); + let mut ignored_callback = |ignored_path: serde_ignored::Path<'_>| { + let path_segments = ignored_path_segments(&ignored_path); + if !path_segments.is_empty() { + ignored_paths.push(path_segments); + } + }; + let deserializer = serde_ignored::Deserializer::new(value, &mut ignored_callback); + let result: Result = serde_path_to_error::deserialize(deserializer); + + match result { + Ok(_) => unknown_field_error_from_paths(source, contents, ignored_paths) + .or_else(|| unknown_field_error_from_paths(source, contents, unknown_feature_paths)), + Err(err) => { + let path_hint = err.path().clone(); + let toml_err = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub(crate) fn ignored_toml_value_field(value: TomlValue) -> Option { + let mut ignored_paths = Vec::new(); + let result: Result = serde_ignored::deserialize(value, |ignored_path| { + let path_segments = ignored_path_segments(&ignored_path); + if !path_segments.is_empty() { + ignored_paths.push(path_segments); + } + }); + if result.is_err() { + return None; + } + + ignored_paths + .into_iter() + .next() + .map(|path_segments| path_segments.join(".")) +} + +pub(crate) fn unknown_feature_toml_value_field(value: &TomlValue) -> Option { + unknown_feature_toml_value_path(value) + .into_iter() + .next() + .map(|path_segments| path_segments.join(".")) +} + +fn unknown_field_error_from_paths( + source: ConfigDiagnosticSource<'_>, + contents: &str, + ignored_paths: Vec>, +) -> Option { + let path_segments = ignored_paths.into_iter().next()?; + let ignored_path = path_segments.join("."); + let range = span_for_toml_key_path(contents, &path_segments) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + source.to_path_buf(), + range, + format!("unknown configuration field `{ignored_path}`"), + )) +} + +fn unknown_feature_toml_value_path(value: &TomlValue) -> Vec> { + let Some(root) = value.as_table() else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + push_unknown_feature_paths(&mut paths, &["features"], root.get("features")); + + if let Some(profiles) = root.get("profiles").and_then(TomlValue::as_table) { + for (profile_name, profile) in profiles { + let prefix = ["profiles", profile_name.as_str(), "features"]; + let features = profile + .as_table() + .and_then(|profile| profile.get("features")); + push_unknown_feature_paths(&mut paths, &prefix, features); + } + } + + paths +} + +fn push_unknown_feature_paths( + paths: &mut Vec>, + prefix: &[&str], + features: Option<&TomlValue>, +) { + let Some(features) = features.and_then(TomlValue::as_table) else { + return; + }; + + for feature_key in features + .keys() + .map(String::as_str) + .filter(|key| !is_known_feature_key(key)) + { + let mut path = prefix + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); + path.push(feature_key.to_string()); + paths.push(path); + } +} + +fn ignored_path_segments(path: &serde_ignored::Path<'_>) -> Vec { + let mut segments = Vec::new(); + push_ignored_path_segments(path, &mut segments); + segments +} + +fn push_ignored_path_segments(path: &serde_ignored::Path<'_>, segments: &mut Vec) { + match path { + serde_ignored::Path::Root => {} + serde_ignored::Path::Seq { parent, index } => { + push_ignored_path_segments(parent, segments); + segments.push(index.to_string()); + } + serde_ignored::Path::Map { parent, key } => { + push_ignored_path_segments(parent, segments); + segments.push(key.clone()); + } + serde_ignored::Path::Some { parent } + | serde_ignored::Path::NewtypeStruct { parent } + | serde_ignored::Path::NewtypeVariant { parent } => { + push_ignored_path_segments(parent, segments); + } + } +} + +#[cfg(test)] +#[path = "strict_config_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs new file mode 100644 index 0000000000..4621b6b2e5 --- /dev/null +++ b/codex-rs/config/src/strict_config_tests.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::config_toml::ConfigToml; +use crate::diagnostics::TextPosition; +use crate::diagnostics::TextRange; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +#[test] +fn ignored_toml_field_errors_accept_non_file_source_names() { + let source_name = "com.openai.codex:config_toml_base64"; + let contents = r#" +model = "gpt-5" +unknown_key = true"#; + + let value = toml::from_str::(contents).expect("valid TOML"); + let error = config_error_from_ignored_toml_value_fields_for_source_name::( + source_name, + contents, + value, + ) + .expect("unknown field error"); + + assert_eq!( + error, + ConfigError::new( + PathBuf::from(source_name), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { + line: 3, + column: 11, + }, + }, + "unknown configuration field `unknown_key`", + ) + ); +} + +#[test] +fn type_errors_take_precedence_over_ignored_fields() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +model_context_window = "wide" +unknown_key = true"#; + + let error = + config_error_from_ignored_toml_fields::(path, contents).expect("type error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { + line: 2, + column: 24, + }, + end: TextPosition { + line: 2, + column: 29, + }, + }, + "invalid type: string \"wide\", expected i64", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `features.foo`", + ) + ); +} + +#[test] +fn strict_config_rejects_unknown_profile_feature_key() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[profiles.work.features] +foo = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents) + .expect("unknown feature error"); + + assert_eq!( + error, + ConfigError::new( + path.to_path_buf(), + TextRange { + start: TextPosition { line: 3, column: 1 }, + end: TextPosition { line: 3, column: 3 }, + }, + "unknown configuration field `profiles.work.features.foo`", + ) + ); +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 21d9b44752..74e3394f93 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -892,8 +892,7 @@ impl From for codex_app_server_protocol::SandboxSettings } } -/// Policy for building the `env` when spawning a process via either the -/// `shell` or `local_shell` tool. +/// Policy for building the `env` when spawning a process via shell-like tools. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ShellEnvironmentPolicyToml { diff --git a/codex-rs/connectors/src/filter.rs b/codex-rs/connectors/src/filter.rs index 82c334f82d..e26291794f 100644 --- a/codex-rs/connectors/src/filter.rs +++ b/codex-rs/connectors/src/filter.rs @@ -37,7 +37,6 @@ const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ ]; const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; -const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; pub fn filter_disallowed_connectors( connectors: Vec, @@ -63,6 +62,166 @@ fn is_connector_id_allowed(connector_id: &str, first_party_chat_originator: bool DISALLOWED_CONNECTOR_IDS }; - !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) - && !disallowed_connector_ids.contains(&connector_id) + !disallowed_connector_ids.contains(&connector_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::connector_install_url; + use pretty_assertions::assert_eq; + + fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + branding: None, + app_metadata: None, + labels: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } + } + + fn named_app(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + install_url: Some(connector_install_url(name, id)), + ..app(id) + } + } + + #[test] + fn filter_disallowed_connectors_allows_non_disallowed_connectors() { + let filtered = + filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")], "codex_cli"); + assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); + } + + #[test] + fn filter_disallowed_connectors_allows_openai_prefix() { + let filtered = filter_disallowed_connectors( + vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma"), + ], + "codex_cli", + ); + assert_eq!( + filtered, + vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma") + ] + ); + } + + #[test] + fn filter_disallowed_connectors_filters_disallowed_connector_ids() { + let filtered = filter_disallowed_connectors( + vec![ + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"), + app("delta"), + ], + "codex_cli", + ); + assert_eq!(filtered, vec![app("delta")]); + } + + #[test] + fn first_party_chat_originator_filters_target_connector_ids() { + let filtered = filter_disallowed_connectors( + vec![ + app("connector_openai_foo"), + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), + ], + "codex_atlas", + ); + assert_eq!( + filtered, + vec![ + app("connector_openai_foo"), + app("asdk_app_6938a94a61d881918ef32cb999ff937c") + ] + ); + } + + #[test] + fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { + let filtered = filter_tool_suggest_discoverable_connectors( + vec![ + named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ), + named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), + named_app("connector_other", "Other"), + ], + &[AppInfo { + is_accessible: true, + ..named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ) + }], + &HashSet::from([ + "connector_2128aebfecb84f64a069897515042a44".to_string(), + "connector_68df038e0ba48191908c8434991bbac2".to_string(), + ]), + "codex_cli", + ); + + assert_eq!( + filtered, + vec![named_app( + "connector_68df038e0ba48191908c8434991bbac2", + "Gmail", + )] + ); + } + + #[test] + fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() { + let filtered = filter_tool_suggest_discoverable_connectors( + vec![ + named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ), + named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), + ], + &[ + AppInfo { + is_accessible: true, + ..named_app( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + ) + }, + AppInfo { + is_accessible: true, + is_enabled: false, + ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") + }, + ], + &HashSet::from([ + "connector_2128aebfecb84f64a069897515042a44".to_string(), + "connector_68df038e0ba48191908c8434991bbac2".to_string(), + ]), + "codex_cli", + ); + + assert_eq!(filtered, Vec::::new()); + } } diff --git a/codex-rs/connectors/src/merge.rs b/codex-rs/connectors/src/merge.rs index b41ee63add..a8bee18737 100644 --- a/codex-rs/connectors/src/merge.rs +++ b/codex-rs/connectors/src/merge.rs @@ -117,3 +117,90 @@ pub fn plugin_connector_to_app_info(connector_id: String) -> AppInfo { plugin_display_names: Vec::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::connector_install_url; + use crate::metadata::connector_mention_slug; + use pretty_assertions::assert_eq; + + fn plugin_names(names: &[&str]) -> Vec { + names.iter().map(ToString::to_string).collect() + } + + fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo { + AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(plugin_display_names), + } + } + + #[test] + fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { + let plugin = plugin_connector_to_app_info("calendar".to_string()); + let accessible = google_calendar_accessible_connector(&[]); + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }] + ); + assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); + } + + #[test] + fn merge_connectors_unions_and_dedupes_plugin_display_names() { + let mut plugin = plugin_connector_to_app_info("calendar".to_string()); + plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]); + + let accessible = google_calendar_accessible_connector(&["beta", "alpha"]); + + let merged = merge_connectors(vec![plugin], vec![accessible]); + + assert_eq!( + merged, + vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: Some("Plan events".to_string()), + logo_url: Some("https://example.com/logo.png".to_string()), + logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), + distribution_channel: Some("workspace".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: Some(connector_install_url("calendar", "calendar")), + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["alpha", "beta", "sample"]), + }] + ); + } +} diff --git a/codex-rs/core-plugins/src/installed_marketplaces.rs b/codex-rs/core-plugins/src/installed_marketplaces.rs index f313a06276..0377fac9ed 100644 --- a/codex-rs/core-plugins/src/installed_marketplaces.rs +++ b/codex-rs/core-plugins/src/installed_marketplaces.rs @@ -18,10 +18,10 @@ pub fn installed_marketplace_roots_from_layer_stack( config_layer_stack: &ConfigLayerStack, codex_home: &Path, ) -> Vec { - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return Vec::new(); }; - let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { + let Some(marketplaces_value) = user_config.get("marketplaces") else { return Vec::new(); }; let Some(marketplaces) = marketplaces_value.as_table() else { diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f348a3414d..b26f1c21b2 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -378,10 +378,10 @@ fn refresh_non_curated_plugin_cache_with_mode( fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return HashMap::new(); }; - configured_plugins_from_user_config_value(&user_layer.config) + configured_plugins_from_user_config_value(&user_config) } fn is_full_git_sha(value: &str) -> bool { diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index d9029c584e..a360d54ccb 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -1,7 +1,69 @@ use super::*; use crate::manifest::load_plugin_manifest; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerSource; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; use codex_plugin::PluginId; use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name)) + .expect("test user config path should be absolute") +} + +fn user_layer(path: AbsolutePathBuf, config: &str) -> ConfigLayerEntry { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: path, + profile: None, + }, + toml::from_str(config).expect("user config toml"), + ) +} + +#[test] +fn configured_plugins_from_stack_merges_user_layers() { + let temp_dir = TempDir::new().expect("tempdir"); + let stack = ConfigLayerStack::new( + vec![ + user_layer( + user_config_path(&temp_dir, "config.toml"), + "[plugins.base]\nenabled = true\n", + ), + user_layer( + user_config_path(&temp_dir, "work.config.toml"), + "[plugins.profile]\nenabled = false\n", + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let plugins = configured_plugins_from_stack(&stack); + + assert_eq!( + plugins, + HashMap::from([ + ( + "base".to_string(), + PluginConfig { + enabled: true, + mcp_servers: HashMap::new(), + }, + ), + ( + "profile".to_string(), + PluginConfig { + enabled: false, + mcp_servers: HashMap::new(), + }, + ), + ]) + ); +} #[test] fn plugin_mcp_file_supports_mcp_servers_object_format() { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 2df35b401f..f6d5a6d88a 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -494,7 +494,7 @@ impl PluginsManager { let outcome = load_plugins_from_layer_stack( &config.config_layer_stack, - self.remote_installed_plugin_configs(config), + self.remote_installed_plugin_configs(), &self.store, self.restriction_product, plugin_hooks_enabled, @@ -542,7 +542,7 @@ impl PluginsManager { } load_plugins_from_layer_stack( config_layer_stack, - self.remote_installed_plugin_configs(config), + self.remote_installed_plugin_configs(), &self.store, self.restriction_product, plugin_hooks_feature_enabled, @@ -585,14 +585,7 @@ impl PluginsManager { } } - fn remote_installed_plugin_configs( - &self, - config: &PluginsConfigInput, - ) -> HashMap { - if !config.remote_plugin_enabled { - return HashMap::new(); - } - + fn remote_installed_plugin_configs(&self) -> HashMap { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), @@ -667,7 +660,7 @@ impl PluginsManager { notify: RemoteInstalledPluginsCacheRefreshNotify, on_effective_plugins_changed: Option>, ) { - if !config.plugins_enabled || !config.remote_plugin_enabled { + if !config.plugins_enabled { return; } @@ -687,7 +680,7 @@ impl PluginsManager { auth: Option, on_effective_plugins_changed: Option>, ) { - if !config.plugins_enabled || !config.remote_plugin_enabled { + if !config.plugins_enabled { return; } @@ -1504,25 +1497,23 @@ impl PluginsManager { auth_manager.clone(), ); - if config.remote_plugin_enabled { - let config = config.clone(); - let manager = Arc::clone(self); - let auth_manager = auth_manager.clone(); - let on_effective_plugins_changed = on_effective_plugins_changed.clone(); - tokio::spawn(async move { - let auth = auth_manager.auth().await; - manager.maybe_start_remote_installed_plugins_cache_refresh( - &config, - auth.clone(), - on_effective_plugins_changed.clone(), - ); - manager.maybe_start_remote_installed_plugin_bundle_sync( - &config, - auth, - on_effective_plugins_changed, - ); - }); - } + let config_for_remote_sync = config.clone(); + let manager = Arc::clone(self); + let auth_manager_for_remote_sync = auth_manager.clone(); + let on_effective_plugins_changed = on_effective_plugins_changed.clone(); + tokio::spawn(async move { + let auth = auth_manager_for_remote_sync.auth().await; + manager.maybe_start_remote_installed_plugins_cache_refresh( + &config_for_remote_sync, + auth.clone(), + on_effective_plugins_changed.clone(), + ); + manager.maybe_start_remote_installed_plugin_bundle_sync( + &config_for_remote_sync, + auth, + on_effective_plugins_changed, + ); + }); let config = config.clone(); let manager = Arc::clone(self); @@ -2006,10 +1997,10 @@ pub(crate) fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { // Plugin entries remain persisted user config only. - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return HashMap::new(); }; - configured_plugins_from_user_config_value(&user_layer.config) + configured_plugins_from_user_config_value(&user_config) } fn configured_plugins_from_user_config_value( diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d04852ea13..e0b22c0040 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -331,7 +331,7 @@ approval_mode = "approve" } #[tokio::test] -async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() { +async fn remote_installed_cache_adds_plugin_skill_roots_without_remote_plugin_flag() { let codex_home = TempDir::new().unwrap(); let plugin_base = codex_home .path() @@ -341,7 +341,6 @@ async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_conf &codex_home.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true -remote_plugin = true "#, ); diff --git a/codex-rs/core-plugins/src/marketplace_upgrade.rs b/codex-rs/core-plugins/src/marketplace_upgrade.rs index 81474cf66f..9dda8a9a3e 100644 --- a/codex-rs/core-plugins/src/marketplace_upgrade.rs +++ b/codex-rs/core-plugins/src/marketplace_upgrade.rs @@ -108,10 +108,10 @@ fn marketplace_install_root(codex_home: &Path) -> PathBuf { fn configured_git_marketplaces( config_layer_stack: &ConfigLayerStack, ) -> Vec { - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return Vec::new(); }; - let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { + let Some(marketplaces_value) = user_config.get("marketplaces") else { return Vec::new(); }; let marketplaces = match marketplaces_value diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index fc6fedbe23..4dde224039 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -48,6 +48,7 @@ pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = "workspace-shared-with-me-private"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = @@ -296,6 +297,7 @@ impl RemotePluginScope { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Some(Self::Workspace), _ => None, @@ -397,11 +399,9 @@ fn remote_plugin_canonical_marketplace_name( RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), - RemotePluginShareDiscoverability::Unlisted => { - Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME) - } - RemotePluginShareDiscoverability::Private => { - Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) + RemotePluginShareDiscoverability::Private + | RemotePluginShareDiscoverability::Unlisted => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME) } }, } @@ -505,20 +505,29 @@ pub async fn fetch_remote_marketplaces( } } RemoteMarketplaceSource::SharedWithMe => { - let private_plugins = fetch_shared_workspace_plugins(config, auth) - .await? + // The shared endpoint is the source of truth for plugins explicitly shared + // with the user. Installed unlisted plugins that are not returned there are + // link-installed and stay in the separate unlisted bucket. + let shared_plugins = fetch_shared_workspace_plugins(config, auth).await?; + let shared_plugin_ids = shared_plugins + .iter() + .map(|plugin| plugin.id.clone()) + .collect::>(); + let directly_shared_plugins = shared_plugins .into_iter() .filter_map(|plugin| match workspace_plugin_discoverability(&plugin) { - Ok(RemotePluginShareDiscoverability::Private) => Some(Ok(plugin)), - Ok(RemotePluginShareDiscoverability::Listed) - | Ok(RemotePluginShareDiscoverability::Unlisted) => None, + Ok( + RemotePluginShareDiscoverability::Private + | RemotePluginShareDiscoverability::Unlisted, + ) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) => None, Err(err) => Some(Err(err)), }) .collect::, _>>()?; if let Some(marketplace) = build_remote_marketplace( REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, - private_plugins, + directly_shared_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, )? { @@ -531,7 +540,12 @@ pub async fn fetch_remote_marketplaces( .into_iter() .filter_map( |plugin| match workspace_plugin_discoverability(&plugin.plugin) { - Ok(RemotePluginShareDiscoverability::Unlisted) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Unlisted) + if !shared_plugin_ids.contains(&plugin.plugin.id) => + { + Some(Ok(plugin)) + } + Ok(RemotePluginShareDiscoverability::Unlisted) => None, Ok(RemotePluginShareDiscoverability::Listed) | Ok(RemotePluginShareDiscoverability::Private) => None, Err(err) => Some(Err(err)), diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index c94eba428e..b13ca7a630 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -1,5 +1,6 @@ use super::REMOTE_GLOBAL_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use super::RemotePluginCatalogError; @@ -153,6 +154,10 @@ pub async fn sync_remote_installed_plugin_bundles_once( REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ( REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), @@ -303,6 +308,7 @@ fn remove_stale_remote_plugin_caches( for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, ] { @@ -510,7 +516,7 @@ mod tests { } #[test] - fn stale_remote_plugin_cleanup_removes_private_shared_with_me_cache() { + fn stale_remote_plugin_cleanup_removes_old_shared_with_me_cache_and_keeps_canonical_cache() { let codex_home = tempfile::tempdir().expect("create codex home"); let cached_manifest = codex_home .path() @@ -524,6 +530,18 @@ mod tests { .expect("create cached plugin manifest parent"); std::fs::write(&cached_manifest, r#"{"name":"private-plugin"}"#) .expect("write cached plugin manifest"); + let canonical_cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME) + .join("shared-plugin") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(canonical_cached_manifest.parent().expect("manifest parent")) + .expect("create canonical cached plugin manifest parent"); + std::fs::write(&canonical_cached_manifest, r#"{"name":"shared-plugin"}"#) + .expect("write canonical cached plugin manifest"); let installed_plugin_names_by_marketplace = BTreeMap::>::from_iter([ (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), @@ -531,6 +549,10 @@ mod tests { REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::from(["shared-plugin".to_string()]), + ), ( REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), @@ -552,5 +574,6 @@ mod tests { vec!["private-plugin@workspace-shared-with-me-private".to_string()] ); assert!(!cached_manifest.exists()); + assert!(canonical_cached_manifest.is_file()); } } diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 9ccdbee851..8aabccff8b 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -1,3 +1,4 @@ +use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; use super::super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use super::super::RemotePluginCatalogError; @@ -162,7 +163,8 @@ pub async fn checkout_remote_plugin_share( fn is_checkout_supported_share_marketplace(marketplace_name: &str) -> bool { matches!( marketplace_name, - REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME ) } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 0b8f8f2fb5..f62051374e 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -586,7 +586,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { vec![ RemotePluginShareSummary { summary: RemotePluginSummary { - id: "demo-plugin@workspace-shared-with-me-private".to_string(), + id: "demo-plugin@workspace-shared-with-me".to_string(), remote_plugin_id: "plugins_123".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { @@ -625,7 +625,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { }, RemotePluginShareSummary { summary: RemotePluginSummary { - id: "demo-plugin@workspace-shared-with-me-private".to_string(), + id: "demo-plugin@workspace-shared-with-me".to_string(), remote_plugin_id: "plugins_456".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index c80585871e..65a0b5a4f1 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -99,6 +99,7 @@ async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig { ConfigLayerEntry::new( ConfigLayerSource::User { file: config_file(user_config_path), + profile: None, }, TomlValue::Table(toml::map::Map::new()), ), @@ -164,7 +165,10 @@ async fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_sys TomlValue::Table(toml::map::Map::new()), ), ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, TomlValue::Table(toml::map::Map::new()), ), ]; @@ -222,7 +226,10 @@ async fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyh let layers = vec![ ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, TomlValue::Table(toml::map::Map::new()), ), ConfigLayerEntry::new_disabled( @@ -281,7 +288,10 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<() let user_file = user_folder.join("config.toml").abs(); let layers = vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, TomlValue::Table(toml::map::Map::new()), )]; let stack = ConfigLayerStack::new( diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 338da0c398..15b06b393a 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -87,7 +87,10 @@ fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntr let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)) .expect("user config path should be absolute"); ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, toml::from_str(config_toml).expect("user layer toml"), ) } @@ -495,7 +498,10 @@ fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) .expect("user layer toml"), ); @@ -527,7 +533,10 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill( let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ true)) .expect("user layer toml"), ); @@ -562,7 +571,10 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() { let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&name_toggle_config("github:yeet", /*enabled*/ false)) .expect("user layer toml"), ); @@ -592,7 +604,10 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) .expect("user layer toml"), ); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8a7cb01b5f..d8c64a0d13 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -347,9 +347,6 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, - "experimental_use_freeform_apply_patch": { - "type": "boolean" - }, "experimental_use_unified_exec_tool": { "type": "boolean" }, @@ -430,9 +427,6 @@ "exec_permission_approvals": { "type": "boolean" }, - "experimental_use_freeform_apply_patch": { - "type": "boolean" - }, "experimental_use_unified_exec_tool": { "type": "boolean" }, @@ -619,9 +613,6 @@ }, "type": "object" }, - "include_apply_patch_tool": { - "type": "boolean" - }, "include_apps_instructions": { "type": "boolean" }, @@ -685,9 +676,6 @@ "tools": { "$ref": "#/definitions/ToolsToml" }, - "tools_view_image": { - "type": "boolean" - }, "tui": { "allOf": [ { @@ -1468,6 +1456,12 @@ "MultiAgentV2ConfigToml": { "additionalProperties": false, "properties": { + "default_wait_timeout_ms": { + "format": "int64", + "maximum": 3600000.0, + "minimum": 0.0, + "type": "integer" + }, "enabled": { "type": "boolean" }, @@ -1479,12 +1473,21 @@ "minimum": 1.0, "type": "integer" }, + "max_wait_timeout_ms": { + "format": "int64", + "maximum": 3600000.0, + "minimum": 0.0, + "type": "integer" + }, "min_wait_timeout_ms": { "format": "int64", "maximum": 3600000.0, - "minimum": 1.0, + "minimum": 0.0, "type": "integer" }, + "non_code_mode_only": { + "type": "boolean" + }, "root_agent_usage_hint_text": { "type": "string" }, @@ -2344,7 +2347,7 @@ }, "ShellEnvironmentPolicyToml": { "additionalProperties": false, - "description": "Policy for building the `env` when spawning a process via either the `shell` or `local_shell` tool.", + "description": "Policy for building the `env` when spawning a process via shell-like tools.", "properties": { "exclude": { "description": "List of regular expressions.", @@ -2501,11 +2504,6 @@ "ToolsToml": { "additionalProperties": false, "properties": { - "view_image": { - "default": null, - "description": "Enable the `view_image` tool that lets the agent attach local images.", - "type": "boolean" - }, "web_search": { "allOf": [ { @@ -4023,10 +4021,6 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, - "commit_attribution": { - "description": "Optional commit attribution text for commit message co-author trailers. This top-level setting only takes effect when `[features].codex_git_commit` is enabled.\n\nWhen enabled and unset, Codex uses `Codex `. Set to an empty string to disable automatic commit attribution.", - "type": "string" - }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" @@ -4087,9 +4081,6 @@ ], "description": "Experimental / do not use. Selects the thread store implementation." }, - "experimental_use_freeform_apply_patch": { - "type": "boolean" - }, "experimental_use_unified_exec_tool": { "type": "boolean" }, @@ -4170,9 +4161,6 @@ "exec_permission_approvals": { "type": "boolean" }, - "experimental_use_freeform_apply_patch": { - "type": "boolean" - }, "experimental_use_unified_exec_tool": { "type": "boolean" }, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index efe2670652..2061d53c80 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -126,7 +126,7 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) { } fn is_shell_tool_name(name: &str) -> bool { - matches!(name, "shell" | "container.exec") + name == "shell" } #[derive(Deserialize)] diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5a01384bf0..493771e28c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -530,7 +530,10 @@ async fn handle_patch_approval( let guardian_decision = if routes_approval_to_guardian(parent_ctx) { let files = changes .keys() - .map(|path| parent_ctx.cwd.join(path)) + .map(|path| { + #[allow(deprecated)] + parent_ctx.cwd.join(path) + }) .collect::>(); let review_cancel = cancel_token.child_token(); let patch = changes @@ -566,6 +569,7 @@ async fn handle_patch_approval( new_guardian_review_id(), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), + #[allow(deprecated)] cwd: parent_ctx.cwd.clone(), files, patch, @@ -739,7 +743,10 @@ async fn handle_request_permissions( reason: event.reason, permissions: event.permissions, }; - let cwd = event.cwd.unwrap_or_else(|| parent_ctx.cwd.clone()); + let cwd = event.cwd.unwrap_or_else(|| { + #[allow(deprecated)] + parent_ctx.cwd.clone() + }); let response_fut = parent_session.request_permissions_for_cwd( parent_ctx, call_id.clone(), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index ecd392e3e7..66cde8d1ea 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -207,6 +207,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { scope: PermissionGrantScope::Turn, strict_auto_review: false, }; + #[allow(deprecated)] let delegated_cwd = parent_ctx.cwd.join("delegated-cwd"); let cancel_token = CancellationToken::new(); let request_call_id = call_id.clone(); diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 7f6ea61a5b..4dafb95f8e 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -401,7 +401,7 @@ mod tests { message("assistant", "final", Some(MessagePhase::FinalAnswer)), ResponseItem::FunctionCall { id: None, - name: "shell".to_string(), + name: "shell_command".to_string(), namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 9eec97773d..b5c5c2ec18 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -18,6 +18,7 @@ use codex_config::RequirementSource; use codex_config::SessionThreadConfig; use codex_config::StaticThreadConfigLoader; use codex_config::ThreadConfigSource; +use codex_config::config_error_from_ignored_toml_fields; use codex_config::config_error_from_toml; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; @@ -133,7 +134,8 @@ async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<( #[tokio::test] async fn returns_config_error_for_invalid_user_config_toml() { let tmp = tempdir().expect("tempdir"); - let contents = "model = \"gpt-4\"\ninvalid = ["; + let contents = r#"model = "gpt-4" +invalid = ["#; let config_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, contents).expect("write config"); @@ -161,7 +163,8 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { let tmp = tempdir().expect("tempdir"); std::fs::write( tmp.path().join(CONFIG_TOML_FILE), - "model = \"from-user-config\"\ninvalid = [", + r#"model = "from-user-config" +invalid = ["#, ) .expect("write config"); @@ -181,7 +184,7 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { .await?; let user_layer = layers - .get_user_layer() + .get_active_user_layer() .expect("expected a user layer even when CODEX_HOME/config.toml is ignored"); assert_eq!( user_layer.config, @@ -219,7 +222,8 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io: async fn returns_config_error_for_invalid_managed_config_toml() { let tmp = tempdir().expect("tempdir"); let managed_path = tmp.path().join("managed_config.toml"); - let contents = "model = \"gpt-4\"\ninvalid = ["; + let contents = r#"model = "gpt-4" +invalid = ["#; std::fs::write(&managed_path, contents).expect("write managed config"); let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()); @@ -325,7 +329,7 @@ command = "python3 /tmp/user-hook.py" assert!( layers - .get_user_layer() + .get_active_user_layer() .and_then(|layer| layer.config.get("hooks")) .is_some(), "hooks should still deserialize from config.toml" @@ -336,10 +340,151 @@ command = "python3 /tmp/user-hook.py" Ok(()) } +#[tokio::test] +async fn strict_config_rejects_unknown_user_config_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"model = "gpt-5" +unknown_key = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_config_error = + config_error_from_ignored_toml_fields::(&config_path, contents) + .expect("unknown field error"); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cli_override_key() { + let tmp = tempdir().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![( + "foo".to_string(), + TomlValue::String("bar".to_string()), + )]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_cli_override_key_with_relative_path_override() { + let tmp = tempdir().expect("tempdir"); + let instructions_path = tmp.path().join("instructions.md"); + std::fs::write(&instructions_path, "instructions").expect("write instructions"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![ + ( + "model_instructions_file".to_string(), + TomlValue::String("instructions.md".to_string()), + ), + ("foo".to_string(), TomlValue::String("bar".to_string())), + ]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_feature_cli_override_key() { + let tmp = tempdir().expect("tempdir"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![("features.foo".to_string(), TomlValue::Boolean(true))]) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + assert_eq!( + err.to_string(), + "unknown configuration field `features.foo` in -c/--config override" + ); +} + +#[tokio::test] +async fn strict_config_rejects_unknown_feature_user_config_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"[features] +foo = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .strict_config(/*strict_config*/ true) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + assert_eq!( + config_error.message, + "unknown configuration field `features.foo`" + ); + assert_eq!(config_error.range.start.line, 2); + assert_eq!(config_error.range.start.column, 1); +} + +#[test] +fn strict_config_points_to_unknown_nested_key() { + let tmp = tempdir().expect("tempdir"); + let contents = r#"[mcp_servers.local] +command = "echo" +unknown_key = true"#; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let error = config_error_from_ignored_toml_fields::(&config_path, contents) + .expect("unknown field error"); + + assert_eq!( + error.message, + "unknown configuration field `mcp_servers.local.unknown_key`" + ); + assert_eq!(error.range.start.line, 3); + assert_eq!(error.range.start.column, 1); +} #[test] fn schema_error_points_to_feature_value() { let tmp = tempdir().expect("tempdir"); - let contents = "[features]\ncollaboration_modes = \"true\""; + let contents = r#"[features] +collaboration_modes = "true""#; let config_path = tmp.path().join(CONFIG_TOML_FILE); std::fs::write(&config_path, contents).expect("write config"); @@ -427,11 +572,12 @@ async fn returns_empty_when_all_layers_missing() { .await .expect("load layers"); let user_layer = layers - .get_user_layer() + .get_active_user_layer() .expect("expected a user layer even when CODEX_HOME/config.toml does not exist"); let expected_user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), + profile: None, }, TomlValue::Table(toml::map::Map::new()), ); @@ -469,6 +615,78 @@ async fn returns_empty_when_all_layers_missing() { } } +#[tokio::test] +async fn selected_user_config_file_layers_over_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" +approval_policy = "on-failure" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work""#).expect("write selected user config"); + + let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); + overrides.user_config_path = + Some(AbsolutePathBuf::from_absolute_path(&selected_config).expect("selected config path")); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + overrides, + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await + .expect("load layers"); + + let user_layers = layers.get_user_layers( + super::ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ); + assert_eq!(user_layers.len(), 2); + assert_eq!( + user_layers[0].name, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(tmp.path().join(CONFIG_TOML_FILE)) + .expect("base user config path"), + profile: None, + } + ); + let user_layer = layers.get_active_user_layer().expect("selected user layer"); + assert_eq!( + user_layer.name, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(&selected_config) + .expect("selected user config path"), + profile: Some("work".to_string()), + } + ); + assert_eq!( + layers + .effective_config() + .get("model") + .and_then(TomlValue::as_str), + Some("gpt-work") + ); + assert_eq!( + layers + .effective_config() + .get("approval_policy") + .and_then(TomlValue::as_str), + Some("on-failure") + ); +} + #[tokio::test] async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -508,6 +726,7 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { ConfigLayerSource::SessionFlags, ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), + profile: None, }, ConfigLayerSource::System { file: expected_system_config, @@ -716,7 +935,12 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<() let tmp = tempdir()?; let managed_path = tmp.path().join("managed_config.toml"); - tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?; + tokio::fs::write( + &managed_path, + r#"approval_policy = "on-request" +"#, + ) + .await?; let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path); loader_overrides.macos_managed_config_requirements_base64 = Some( @@ -1201,11 +1425,17 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result< let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; let managed_config_path = tmp.path().join("managed_config.toml"); - tokio::fs::write(&managed_config_path, "approval_policy = \"never\"\n").await?; + tokio::fs::write( + &managed_config_path, + r#"approval_policy = "never" +"#, + ) + .await?; let system_requirements_path = tmp.path().join("requirements.toml"); tokio::fs::write( &system_requirements_path, - "allowed_sandbox_modes = [\"read-only\"]\n", + r#"allowed_sandbox_modes = ["read-only"] +"#, ) .await?; @@ -1391,12 +1621,14 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"root\"\n", + r#"foo = "root" +"#, ) .await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\n", + r#"foo = "child" +"#, ) .await?; @@ -1867,7 +2099,12 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R let home_dir = tmp.path().join("home"); let codex_home = home_dir.join(".codex"); tokio::fs::create_dir_all(&codex_home).await?; - tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "foo = \"user\"\n").await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"foo = "user" +"#, + ) + .await?; let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?; let layers = load_config_layers_state( @@ -1909,7 +2146,12 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul tokio::fs::create_dir_all(&nested_dot_codex).await?; tokio::fs::create_dir_all(project_root.join(".git")).await?; - tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?; + tokio::fs::write( + nested_dot_codex.join(CONFIG_TOML_FILE), + r#"foo = "child" +"#, + ) + .await?; tokio::fs::create_dir_all(&project_dot_codex).await?; make_config_for_test( @@ -1923,7 +2165,10 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?; tokio::fs::write( &user_config_path, - format!("foo = \"user\"\n{user_config_contents}"), + format!( + r#"foo = "user" +{user_config_contents}"# + ), ) .await?; @@ -1948,7 +2193,11 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); - let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config"); + let child_config: TomlValue = toml::from_str( + r#"foo = "child" +"#, + ) + .expect("parse child config"); let expected_project_layer = ConfigLayerEntry::new( ConfigLayerSource::Project { dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, @@ -1972,7 +2221,9 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< tokio::fs::create_dir_all(nested.join(".codex")).await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\nprofile = \"ignored\"\n", + r#"foo = "child" +profile = "ignored" +"#, ) .await?; @@ -1991,7 +2242,10 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< let untrusted_config_contents = tokio::fs::read_to_string(&untrusted_config_path).await?; tokio::fs::write( &untrusted_config_path, - format!("foo = \"user\"\n{untrusted_config_contents}"), + format!( + r#"foo = "user" +{untrusted_config_contents}"# + ), ) .await?; @@ -2037,7 +2291,8 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result< tokio::fs::create_dir_all(&codex_home_unknown).await?; tokio::fs::write( codex_home_unknown.join(CONFIG_TOML_FILE), - "foo = \"user\"\n", + r#"foo = "user" +"#, ) .await?; @@ -2207,7 +2462,8 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"project\"\n", + r#"foo = "project" +"#, ) .await?; std::os::unix::fs::symlink(&project_root, &alias_root)?; @@ -2378,9 +2634,21 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io:: ) .await?; let config_contents = tokio::fs::read_to_string(&config_path).await?; - tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?; + tokio::fs::write( + &config_path, + format!( + r#"foo = "user" +{config_contents}"# + ), + ) + .await?; } else { - tokio::fs::write(&config_path, "foo = \"user\"\n").await?; + tokio::fs::write( + &config_path, + r#"foo = "user" +"#, + ) + .await?; } let layers = load_config_layers_state( @@ -2537,12 +2805,14 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<() tokio::fs::write(project_root.join(".hg"), "hg").await?; tokio::fs::write( project_root.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"root\"\n", + r#"foo = "root" +"#, ) .await?; tokio::fs::write( nested.join(".codex").join(CONFIG_TOML_FILE), - "foo = \"child\"\n", + r#"foo = "child" +"#, ) .await?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b56ad5afc2..27a67da68d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7,6 +7,7 @@ use crate::config::edit::apply_blocking; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerEntry; +use codex_config::ProfileV2Name; use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; @@ -69,6 +70,9 @@ use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -376,13 +380,7 @@ web_search = true ) .expect("TOML deserialization should succeed"); - assert_eq!( - cfg.tools, - Some(ToolsToml { - web_search: None, - view_image: None, - }) - ); + assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); } #[test] @@ -395,13 +393,7 @@ web_search = false ) .expect("TOML deserialization should succeed"); - assert_eq!( - cfg.tools, - Some(ToolsToml { - web_search: None, - view_image: None, - }) - ); + assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); } #[test] @@ -730,7 +722,7 @@ default_permissions = "workspace" [permissions.workspace.filesystem] ":minimal" = "read" -[permissions.workspace.filesystem.":project_roots"] +[permissions.workspace.filesystem.":workspace_roots"] "." = "write" "docs" = "read" @@ -761,7 +753,7 @@ allow_upstream_proxy = false FilesystemPermissionToml::Access(FileSystemAccessMode::Read), ), ( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), ("docs".to_string(), FileSystemAccessMode::Read), @@ -1315,7 +1307,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: FilesystemPermissionToml::Access(FileSystemAccessMode::Read), ), ( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), ("docs".to_string(), FileSystemAccessMode::Read), @@ -1615,7 +1607,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without } #[tokio::test] -async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { +async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?; @@ -1630,7 +1622,7 @@ async fn project_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::i filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), entries: BTreeMap::from([( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), ("**/*.env".to_string(), FileSystemAccessMode::None), @@ -1740,7 +1732,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some(":workspace".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), ..Default::default() }, ConfigOverrides { @@ -1758,7 +1750,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), - Some(":workspace") + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) ); assert!( policy.can_write_path_with_cwd(cwd.path(), cwd.path()), @@ -1781,7 +1773,7 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some(":read-only".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), ..Default::default() }, ConfigOverrides { @@ -1801,9 +1793,13 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi assert_eq!( config.permissions.active_permission_profile(), Some( - ActivePermissionProfile::new(":read-only").with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { path: extra_root }, - ]) + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY).with_modifications( + vec![ + ActivePermissionProfileModification::AdditionalWritableRoot { + path: extra_root, + }, + ] + ) ) ); Ok(()) @@ -1818,7 +1814,7 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some(":workspace".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), sandbox_workspace_write: Some(SandboxWorkspaceWrite { writable_roots: vec![extra_root.path().abs()], network_access: true, @@ -1883,9 +1879,9 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: .as_ref() .map(|active| active.id.as_str()), Some(if cfg!(target_os = "windows") { - ":read-only" + BUILT_IN_PERMISSION_PROFILE_READ_ONLY } else { - ":workspace" + BUILT_IN_PERMISSION_PROFILE_WORKSPACE }) ); if cfg!(target_os = "windows") { @@ -2054,13 +2050,13 @@ async fn empty_config_defaults_to_builtin_read_only_without_trust_decision() -> } #[tokio::test] -async fn default_permissions_can_select_builtin_no_sandbox_profile() -> std::io::Result<()> { +async fn default_permissions_can_select_builtin_full_access_profile() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some(":danger-no-sandbox".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }, ConfigOverrides { @@ -2081,7 +2077,33 @@ async fn default_permissions_can_select_builtin_no_sandbox_profile() -> std::io: .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), - Some(":danger-no-sandbox") + Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS) + ); + Ok(()) +} + +#[tokio::test] +async fn legacy_danger_no_sandbox_is_rejected() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let err = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await + .expect_err("legacy full-access alias should be rejected"); + + assert_eq!( + err.to_string(), + "default_permissions refers to unknown built-in profile `:danger-no-sandbox`" ); Ok(()) } @@ -2206,7 +2228,8 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() } #[tokio::test] -async fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> std::io::Result<()> { +async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> std::io::Result<()> +{ let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; @@ -2241,7 +2264,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_project_roots() -> s codex_home.abs(), ) .await - .expect_err("nested entries outside :project_roots should be rejected"); + .expect_err("nested entries outside :workspace_roots should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( @@ -2408,7 +2431,7 @@ async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io:: } #[tokio::test] -async fn permissions_profiles_reject_project_root_parent_traversal() -> std::io::Result<()> { +async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; @@ -2423,7 +2446,7 @@ async fn permissions_profiles_reject_project_root_parent_traversal() -> std::io: filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([( "../sibling".to_string(), FileSystemAccessMode::Read, @@ -3346,6 +3369,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io:: ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: user_file.clone(), + profile: None, }, toml::toml! { [mcp_servers.session_overrides_user] @@ -3400,6 +3424,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io:: ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: user_file.clone(), + profile: None, }, toml::toml! { [mcp_servers.session_overrides_user] @@ -3530,6 +3555,7 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() vec![ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: user_file.clone(), + profile: None, }, toml::toml! { [features] @@ -3556,7 +3582,10 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() .await?; let thread_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - codex_app_server_protocol::ConfigLayerSource::User { file: user_file }, + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::toml! { [features] plugins = false @@ -4249,7 +4278,6 @@ async fn legacy_toggles_map_to_features() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { experimental_use_unified_exec_tool: Some(true), - experimental_use_freeform_apply_patch: Some(true), ..Default::default() }; @@ -4260,11 +4288,8 @@ async fn legacy_toggles_map_to_features() -> std::io::Result<()> { ) .await?; - assert!(config.features.enabled(Feature::ApplyPatchFreeform)); assert!(config.features.enabled(Feature::UnifiedExec)); - assert!(config.include_apply_patch_tool); - assert!(config.use_experimental_unified_exec_tool); Ok(()) @@ -5505,6 +5530,52 @@ async fn set_model_updates_defaults() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn for_config_writes_selected_user_config_file() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let base_config = codex_home.path().join(CONFIG_TOML_FILE); + let selected_config = codex_home.path().join("work.config.toml"); + tokio::fs::write(&base_config, r#"model_provider = "openai""#).await?; + tokio::fs::write(&selected_config, r#"model = "gpt-old""#).await?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config.abs()), + user_config_profile: Some("work".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await?; + + ConfigEditsBuilder::for_config(&config) + .set_model(Some("gpt-new"), Some(ReasoningEffort::High)) + .apply() + .await?; + + let selected_serialized = tokio::fs::read_to_string(&selected_config).await?; + let selected: ConfigToml = toml::from_str(&selected_serialized)?; + assert_eq!(selected.model.as_deref(), Some("gpt-new")); + assert_eq!(selected.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + tokio::fs::read_to_string(&base_config).await?, + r#"model_provider = "openai""# + ); + + Ok(()) +} + +#[test] +fn profile_v2_config_path_resolves_validated_names() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let profile_name: ProfileV2Name = "work".parse()?; + assert_eq!( + resolve_profile_v2_config_path(codex_home.path(), &profile_name), + codex_home.path().join("work.config.toml").abs() + ); + Ok(()) +} + #[tokio::test] async fn set_model_overwrites_existing_model() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -6096,6 +6167,7 @@ config_file = "./agents/researcher.toml" vec![codex_config::ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: codex_home.path().join(CONFIG_TOML_FILE).abs(), + profile: None, }, layer_config, )], @@ -7281,7 +7353,9 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), + active_permission_profile: Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7357,7 +7431,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -7729,7 +7802,9 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), + active_permission_profile: Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7805,7 +7880,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -7891,7 +7965,9 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), + active_permission_profile: Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7967,7 +8043,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -8038,7 +8113,9 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new(":read-only")), + active_permission_profile: Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )), network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8114,7 +8191,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -8769,6 +8845,58 @@ path = "/custom/mcp" Ok(()) } +#[tokio::test] +async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features] +apps_mcp_path_override = true +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::AppsMcpPathOverride)); + assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp")); + Ok(()) +} + +#[tokio::test] +async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features.apps_mcp_path_override] +enabled = true +path = "/custom/mcp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); + assert!(config.features.enabled(Feature::AppsMcpPathOverride)); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -8954,7 +9082,7 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .harness_overrides(ConfigOverrides { - default_permissions: Some(":danger-no-sandbox".to_string()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) .cloud_requirements(CloudRequirementsLoader::new(async move { @@ -9651,11 +9779,14 @@ async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { enabled = true max_concurrent_threads_per_session = 5 min_wait_timeout_ms = 2500 +max_wait_timeout_ms = 120000 +default_wait_timeout_ms = 30000 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." hide_spawn_agent_metadata = true +non_code_mode_only = true "#, )?; @@ -9668,6 +9799,8 @@ hide_spawn_agent_metadata = true assert!(config.features.enabled(Feature::MultiAgentV2)); assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 5); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 2500); + assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 120000); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30000); assert_eq!(config.agent_max_threads, Some(4)); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( @@ -9683,6 +9816,7 @@ hide_spawn_agent_metadata = true Some("Subagent guidance.") ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9697,20 +9831,26 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { [features.multi_agent_v2] max_concurrent_threads_per_session = 4 min_wait_timeout_ms = 3000 +max_wait_timeout_ms = 120000 +default_wait_timeout_ms = 30000 usage_hint_enabled = true usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" subagent_usage_hint_text = "base subagent hint" hide_spawn_agent_metadata = true +non_code_mode_only = false [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 min_wait_timeout_ms = 1500 +max_wait_timeout_ms = 90000 +default_wait_timeout_ms = 15000 usage_hint_enabled = false usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" subagent_usage_hint_text = "profile subagent hint" hide_spawn_agent_metadata = false +non_code_mode_only = true "#, )?; @@ -9722,6 +9862,8 @@ hide_spawn_agent_metadata = false assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500); + assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 90000); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 15000); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -9736,6 +9878,7 @@ hide_spawn_agent_metadata = false Some("profile subagent hint") ); assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9758,7 +9901,10 @@ enabled = true assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); + assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000); assert_eq!(config.agent_max_threads, Some(3)); + assert!(!config.multi_agent_v2.non_code_mode_only); Ok(()) } @@ -9793,13 +9939,33 @@ max_threads = 3 } #[tokio::test] -async fn multi_agent_v2_rejects_invalid_min_wait_timeout() -> std::io::Result<()> { +async fn multi_agent_v2_rejects_invalid_wait_timeouts() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true min_wait_timeout_ms = 0 +max_wait_timeout_ms = 0 +default_wait_timeout_ms = 0 +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 0); + assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 0); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 0); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = -1 "#, )?; @@ -9808,12 +9974,12 @@ min_wait_timeout_ms = 0 .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await - .expect_err("zero min_wait_timeout_ms should be rejected"); + .expect_err("negative min_wait_timeout_ms should be rejected"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert_eq!( err.to_string(), - "features.multi_agent_v2.min_wait_timeout_ms must be at least 1" + "features.multi_agent_v2.min_wait_timeout_ms must be at least 0" ); std::fs::write( @@ -9837,6 +10003,137 @@ min_wait_timeout_ms = 3600001 "features.multi_agent_v2.min_wait_timeout_ms must be at most 3600000" ); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +max_wait_timeout_ms = -1 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("negative max_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.max_wait_timeout_ms must be at least 0" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +max_wait_timeout_ms = 3600001 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("too large max_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.max_wait_timeout_ms must be at most 3600000" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +default_wait_timeout_ms = -1 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("negative default_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.default_wait_timeout_ms must be at least 0" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 1000 +max_wait_timeout_ms = 500 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("min greater than max should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.min_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 1000 +max_wait_timeout_ms = 2000 +default_wait_timeout_ms = 500 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("default less than min should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.default_wait_timeout_ms must be at least features.multi_agent_v2.min_wait_timeout_ms" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 1000 +max_wait_timeout_ms = 2000 +default_wait_timeout_ms = 2500 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("default greater than max should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms" + ); + Ok(()) } diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 4dde2c0cb0..4e5b46e182 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1037,13 +1037,21 @@ pub fn apply_blocking( codex_home: &Path, profile: Option<&str>, edits: &[ConfigEdit], +) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + apply_blocking_to_resolved_file(&config_path, profile, edits) +} + +fn apply_blocking_to_resolved_file( + resolved_config_file: &Path, + legacy_profile: Option<&str>, + edits: &[ConfigEdit], ) -> anyhow::Result<()> { if edits.is_empty() { return Ok(()); } - let config_path = codex_home.join(CONFIG_TOML_FILE); - let write_paths = resolve_symlink_write_paths(&config_path)?; + let write_paths = resolve_symlink_write_paths(resolved_config_file)?; let serialized = match write_paths.read_path { Some(path) => match std::fs::read_to_string(&path) { Ok(contents) => contents, @@ -1059,7 +1067,7 @@ pub fn apply_blocking( serialized.parse::()? }; - let profile = profile.map(ToOwned::to_owned).or_else(|| { + let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| { doc.get("profile") .and_then(|item| item.as_str()) .map(ToOwned::to_owned) @@ -1078,7 +1086,7 @@ pub fn apply_blocking( write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| { format!( - "failed to persist config.toml at {}", + "failed to persist config at {}", write_paths.write_path.display() ) })?; @@ -1087,30 +1095,50 @@ pub fn apply_blocking( } /// Persist edits asynchronously by offloading the blocking writer. +/// +/// `profile` selects a legacy `[profiles.]` section inside +/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target +/// file before constructing a [ConfigEditsBuilder]. pub async fn apply( codex_home: &Path, profile: Option<&str>, edits: Vec, ) -> anyhow::Result<()> { let codex_home = codex_home.to_path_buf(); + let config_path = codex_home.join(CONFIG_TOML_FILE); let profile = profile.map(ToOwned::to_owned); - task::spawn_blocking(move || apply_blocking(&codex_home, profile.as_deref(), &edits)) - .await - .context("config persistence task panicked")? + task::spawn_blocking(move || { + apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits) + }) + .await + .context("config persistence task panicked")? } /// Fluent builder to batch config edits and apply them atomically. #[derive(Default)] pub struct ConfigEditsBuilder { - codex_home: PathBuf, + config_path: PathBuf, profile: Option, edits: Vec, } impl ConfigEditsBuilder { pub fn new(codex_home: &Path) -> Self { + Self::for_config_path(&codex_home.join(CONFIG_TOML_FILE)) + } + + pub fn for_config(config: &crate::config::Config) -> Self { + let config_path = config + .config_layer_stack + .get_user_config_file() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .unwrap_or_else(|| config.codex_home.join(CONFIG_TOML_FILE).to_path_buf()); + Self::for_config_path(&config_path) + } + + pub fn for_config_path(config_path: &Path) -> Self { Self { - codex_home: codex_home.to_path_buf(), + config_path: config_path.to_path_buf(), profile: None, edits: Vec::new(), } @@ -1369,13 +1397,13 @@ impl ConfigEditsBuilder { /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { - apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) } /// Apply edits asynchronously via a blocking offload. pub async fn apply(self) -> anyhow::Result<()> { task::spawn_blocking(move || { - apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) }) .await .context("config persistence task panicked")? diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index a575b0a45b..78b88d2f3a 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -269,13 +269,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature enabled, )); } - if let Some(enabled) = cfg.experimental_use_freeform_apply_patch { - explicit_settings.push(( - "experimental_use_freeform_apply_patch".to_string(), - Feature::ApplyPatchFreeform, - enabled, - )); - } for (profile_name, profile) in &cfg.profiles { if let Some(features) = profile.features.as_ref() { for (key, enabled) in features.entries() { @@ -288,13 +281,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature } } } - if let Some(enabled) = profile.include_apply_patch_tool { - explicit_settings.push(( - format!("profiles.{profile_name}.include_apply_patch_tool"), - Feature::ApplyPatchFreeform, - enabled, - )); - } if let Some(enabled) = profile.experimental_use_unified_exec_tool { explicit_settings.push(( format!("profiles.{profile_name}.experimental_use_unified_exec_tool"), @@ -302,13 +288,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature enabled, )); } - if let Some(enabled) = profile.experimental_use_freeform_apply_patch { - explicit_settings.push(( - format!("profiles.{profile_name}.experimental_use_freeform_apply_patch"), - Feature::ApplyPatchFreeform, - enabled, - )); - } } explicit_settings @@ -370,14 +349,11 @@ pub(crate) fn validate_feature_requirements_in_config_toml( FeatureConfigSource { features: cfg.features.as_ref(), include_apply_patch_tool: None, - experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { features: profile.features.as_ref(), - include_apply_patch_tool: profile.include_apply_patch_tool, - experimental_use_freeform_apply_patch: profile - .experimental_use_freeform_apply_patch, + include_apply_patch_tool: None, experimental_use_unified_exec_tool: profile.experimental_use_unified_exec_tool, }, FeatureOverrides::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d74f83102c..cc5203251c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -15,10 +15,10 @@ use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_config::ConstrainedWithSource; use codex_config::FeatureRequirementsToml; -use codex_config::LoaderOverrides; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; use codex_config::PluginRequirementsToml; +use codex_config::ProfileV2Name; use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::Sourced; @@ -136,9 +136,11 @@ mod otel; mod permissions; #[cfg(test)] mod schema; +pub use codex_config::ConfigLoadOptions; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; +pub use codex_config::LoaderOverrides; pub use codex_network_proxy::NetworkProxyAuditMetadata; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; pub use codex_sandboxing::system_bwrap_warning; @@ -176,12 +178,17 @@ pub(crate) const AGENTS_MD_MAX_BYTES: usize = DEFAULT_PROJECT_DOC_MAX_BYTES; // pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4; pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000; -pub(crate) const MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; +pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; +pub(crate) const DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0; +pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 = + DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0"; pub const CONFIG_TOML_FILE: &str = "config.toml"; +const CONFIG_PROFILE_V2_SUFFIX: &str = ".config.toml"; fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; @@ -477,15 +484,6 @@ pub struct Config { /// Compact prompt override. pub compact_prompt: Option, - /// Optional commit attribution text for commit message co-author trailers. - /// This top-level setting only takes effect when `[features].codex_git_commit` - /// is enabled. - /// - /// - `None`: use default attribution (`Codex `) - /// - `Some("")` or whitespace-only: disable commit attribution - /// - `Some("...")`: use the provided attribution text verbatim - pub commit_attribution: Option, - /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -840,11 +838,14 @@ pub struct Config { pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, pub min_wait_timeout_ms: i64, + pub max_wait_timeout_ms: i64, + pub default_wait_timeout_ms: i64, pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, + pub non_code_mode_only: bool, } impl Default for MultiAgentV2Config { @@ -853,11 +854,14 @@ impl Default for MultiAgentV2Config { max_concurrent_threads_per_session: DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION, min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS, + max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS, + default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, usage_hint_enabled: true, usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, hide_spawn_agent_metadata: false, + non_code_mode_only: false, } } } @@ -902,6 +906,7 @@ pub struct ConfigBuilder { cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, + strict_config: bool, cloud_requirements: CloudRequirementsLoader, thread_config_loader: Option>, fallback_cwd: Option, @@ -928,6 +933,11 @@ impl ConfigBuilder { self } + pub fn strict_config(mut self, strict_config: bool) -> Self { + self.strict_config = strict_config; + self + } + pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self { self.cloud_requirements = cloud_requirements; self @@ -957,6 +967,7 @@ impl ConfigBuilder { cli_overrides, harness_overrides, loader_overrides, + strict_config, cloud_requirements, thread_config_loader, fallback_cwd, @@ -979,7 +990,10 @@ impl ConfigBuilder { &codex_home, Some(cwd), &cli_overrides, - loader_overrides, + ConfigLoadOptions { + loader_overrides, + strict_config, + }, cloud_requirements, thread_config_loader .as_deref() @@ -1260,7 +1274,6 @@ impl Config { ) .await } - /// This is a secondary way of creating [Config], which is appropriate when /// the harness is meant to be used with a specific configuration that /// ignores user settings. For example, the `codex exec` subcommand is @@ -1281,6 +1294,16 @@ impl Config { } } +pub fn resolve_profile_v2_config_path( + codex_home: &Path, + profile_name: &ProfileV2Name, +) -> AbsolutePathBuf { + AbsolutePathBuf::resolve_path_against_base( + format!("{profile_name}{CONFIG_PROFILE_V2_SUFFIX}"), + codex_home, + ) +} + /// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working /// with [ConfigToml] directly means that [ConfigRequirements] have not been /// applied yet, which risks failing to enforce required constraints. @@ -1288,28 +1311,47 @@ pub async fn load_config_as_toml_with_cli_overrides( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, ) -> std::io::Result { load_config_as_toml_with_cli_and_loader_overrides( codex_home, cwd, cli_overrides, - LoaderOverrides::default(), + loader_overrides, ) .await } +/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or +/// [ConfigBuilder] because working with [ConfigToml] directly means +/// [ConfigRequirements] have not been applied yet, which risks skipping +/// required constraints. pub async fn load_config_as_toml_with_cli_and_loader_overrides( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, +) -> std::io::Result { + load_config_as_toml_with_cli_and_load_options(codex_home, cwd, cli_overrides, loader_overrides) + .await +} + +/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or +/// [ConfigBuilder] because working with [ConfigToml] directly means +/// [ConfigRequirements] have not been applied yet, which risks skipping +/// required constraints. +pub async fn load_config_as_toml_with_cli_and_load_options( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + options: impl Into, ) -> std::io::Result { let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), codex_home, cwd.cloned(), &cli_overrides, - loader_overrides, + options, CloudRequirementsLoader::default(), &codex_config::NoopThreadConfigLoader, ) @@ -1887,7 +1929,6 @@ pub struct ConfigOverrides { pub developer_instructions: Option, pub personality: Option, pub compact_prompt: Option, - pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, pub ephemeral: Option, @@ -1979,6 +2020,14 @@ fn resolve_multi_agent_v2_config( .and_then(|config| config.min_wait_timeout_ms) .or_else(|| base.and_then(|config| config.min_wait_timeout_ms)) .unwrap_or(default.min_wait_timeout_ms); + let max_wait_timeout_ms = profile + .and_then(|config| config.max_wait_timeout_ms) + .or_else(|| base.and_then(|config| config.max_wait_timeout_ms)) + .unwrap_or(default.max_wait_timeout_ms); + let default_wait_timeout_ms = profile + .and_then(|config| config.default_wait_timeout_ms) + .or_else(|| base.and_then(|config| config.default_wait_timeout_ms)) + .unwrap_or(default.default_wait_timeout_ms); let usage_hint_enabled = profile .and_then(|config| config.usage_hint_enabled) .or_else(|| base.and_then(|config| config.usage_hint_enabled)) @@ -2002,15 +2051,22 @@ fn resolve_multi_agent_v2_config( .and_then(|config| config.hide_spawn_agent_metadata) .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); + let non_code_mode_only = profile + .and_then(|config| config.non_code_mode_only) + .or_else(|| base.and_then(|config| config.non_code_mode_only)) + .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { max_concurrent_threads_per_session, min_wait_timeout_ms, + max_wait_timeout_ms, + default_wait_timeout_ms, usage_hint_enabled, usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, hide_spawn_agent_metadata, + non_code_mode_only, } } @@ -2087,6 +2143,22 @@ pub(crate) fn resolve_web_search_mode_for_turn( WebSearchMode::Disabled } +fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Result<()> { + if value < HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{label} must be at least {HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS}"), + )); + } + if value > HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{label} must be at most {HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS}"), + )); + } + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -2171,7 +2243,6 @@ impl Config { developer_instructions, personality, compact_prompt, - include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, ephemeral, @@ -2225,7 +2296,6 @@ impl Config { }; let tool_suggest = resolve_tool_suggest_config(&cfg, &config_layer_stack); let feature_overrides = FeatureOverrides { - include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, }; @@ -2233,14 +2303,11 @@ impl Config { FeatureConfigSource { features: cfg.features.as_ref(), include_apply_patch_tool: None, - experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { features: config_profile.features.as_ref(), - include_apply_patch_tool: config_profile.include_apply_patch_tool, - experimental_use_freeform_apply_patch: config_profile - .experimental_use_freeform_apply_patch, + include_apply_patch_tool: None, experimental_use_unified_exec_tool: config_profile .experimental_use_unified_exec_tool, }, @@ -2531,7 +2598,7 @@ impl Config { // Keep legacy behavior for extra writable roots while storing // the result as the canonical permission profile. Explicit // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:project_roots` + // are also concrete rather than symbolic `:workspace_roots` // entries. file_system_sandbox_policy = file_system_sandbox_policy .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); @@ -2615,6 +2682,7 @@ impl Config { .and_then(|config| config.path.as_ref()) .or_else(|| base.and_then(|config| config.path.as_ref())) .cloned() + .or_else(|| Some("/ps/mcp".to_string())) } else { None }; @@ -2660,18 +2728,34 @@ impl Config { "features.multi_agent_v2.max_concurrent_threads_per_session must be at least 1", )); } - if multi_agent_v2.min_wait_timeout_ms <= 0 { + validate_multi_agent_v2_wait_timeout( + "features.multi_agent_v2.min_wait_timeout_ms", + multi_agent_v2.min_wait_timeout_ms, + )?; + validate_multi_agent_v2_wait_timeout( + "features.multi_agent_v2.max_wait_timeout_ms", + multi_agent_v2.max_wait_timeout_ms, + )?; + validate_multi_agent_v2_wait_timeout( + "features.multi_agent_v2.default_wait_timeout_ms", + multi_agent_v2.default_wait_timeout_ms, + )?; + if multi_agent_v2.min_wait_timeout_ms > multi_agent_v2.max_wait_timeout_ms { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "features.multi_agent_v2.min_wait_timeout_ms must be at least 1", + "features.multi_agent_v2.min_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } - if multi_agent_v2.min_wait_timeout_ms > MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS { + if multi_agent_v2.default_wait_timeout_ms < multi_agent_v2.min_wait_timeout_ms { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - format!( - "features.multi_agent_v2.min_wait_timeout_ms must be at most {MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS}" - ), + "features.multi_agent_v2.default_wait_timeout_ms must be at least features.multi_agent_v2.min_wait_timeout_ms", + )); + } + if multi_agent_v2.default_wait_timeout_ms > multi_agent_v2.max_wait_timeout_ms { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); @@ -2808,8 +2892,6 @@ impl Config { } }); - let commit_attribution = cfg.commit_attribution; - // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -3051,7 +3133,6 @@ impl Config { personality, developer_instructions, compact_prompt, - commit_attribution, include_permissions_instructions, include_apps_instructions, include_collaboration_mode_instructions, diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 6b6021ad34..b93b8745d7 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -23,6 +23,9 @@ use codex_network_proxy::NetworkProxyConfig; #[cfg(test)] use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -34,9 +37,10 @@ use codex_utils_absolute_path::AbsolutePathBuf; use super::ProjectConfig; -pub(crate) const BUILT_IN_READ_ONLY_PROFILE: &str = ":read-only"; -pub(crate) const BUILT_IN_WORKSPACE_PROFILE: &str = ":workspace"; -pub(crate) const BUILT_IN_DANGER_NO_SANDBOX_PROFILE: &str = ":danger-no-sandbox"; +pub(crate) const BUILT_IN_READ_ONLY_PROFILE: &str = BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +pub(crate) const BUILT_IN_WORKSPACE_PROFILE: &str = BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +pub(crate) const BUILT_IN_DANGER_FULL_ACCESS_PROFILE: &str = + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; pub(crate) fn default_builtin_permission_profile_name( active_project: &ProjectConfig, @@ -56,7 +60,7 @@ pub(crate) fn is_builtin_permission_profile_name(profile_name: &str) -> bool { profile_name, BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE - | BUILT_IN_DANGER_NO_SANDBOX_PROFILE + | BUILT_IN_DANGER_FULL_ACCESS_PROFILE ) } @@ -84,7 +88,7 @@ pub(crate) fn builtin_permission_profile( ), None => PermissionProfile::workspace_write(), }), - BUILT_IN_DANGER_NO_SANDBOX_PROFILE => Some(PermissionProfile::Disabled), + BUILT_IN_DANGER_FULL_ACCESS_PROFILE => Some(PermissionProfile::Disabled), _ => None, } } @@ -489,7 +493,7 @@ fn compile_scoped_filesystem_pattern( match parse_special_path(path) { Some(FileSystemSpecialPath::ProjectRoots { .. }) => { - // `:project_roots` is represented as a special path, but current + // `:workspace_roots` is represented as a special path, but current // filesystem-policy resolution defines it relative to the session // cwd. Use the same policy cwd here so glob entries and exact // scoped entries resolve consistently. @@ -616,7 +620,7 @@ fn parse_special_path(path: &str) -> Option { match path { ":root" => Some(FileSystemSpecialPath::Root), ":minimal" => Some(FileSystemSpecialPath::Minimal), - ":project_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)), + ":workspace_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), _ if path.starts_with(':') => { Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None)) diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 6ae9307f02..86a3c604dd 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -289,7 +289,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths FilesystemPermissionToml::Access(FileSystemAccessMode::Write), ), ( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ ("**/*.env".to_string(), FileSystemAccessMode::None), ("docs/**".to_string(), FileSystemAccessMode::Read), @@ -303,7 +303,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths unsupported_read_write_glob_paths(&filesystem), vec![ "/tmp/**/*.log".to_string(), - ":project_roots/src/**/*.rs".to_string() + ":workspace_roots/src/**/*.rs".to_string() ], "`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn" ); @@ -314,7 +314,7 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() { let filesystem = FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ ("**/*.env".to_string(), FileSystemAccessMode::None), ("*.pem".to_string(), FileSystemAccessMode::None), @@ -324,7 +324,7 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() { assert_eq!( unbounded_unreadable_globstar_paths(&filesystem), - vec![":project_roots/**/*.env".to_string()] + vec![":workspace_roots/**/*.env".to_string()] ); let configured_filesystem = FilesystemPermissionsToml { @@ -362,7 +362,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( - ":project_roots".to_string(), + ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([( "docs/**".to_string(), FileSystemAccessMode::Read, diff --git a/codex-rs/core/src/config_lock.rs b/codex-rs/core/src/config_lock.rs index ff8f1e761d..14ee7e1105 100644 --- a/codex-rs/core/src/config_lock.rs +++ b/codex-rs/core/src/config_lock.rs @@ -84,6 +84,7 @@ pub(crate) fn lock_layer_from_config( Ok(ConfigLayerEntry::new( ConfigLayerSource::User { file: lock_path.clone(), + profile: None, }, value, )) diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index c5798d7c14..cce2491679 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -13,12 +13,8 @@ use codex_config::types::AppConfig; use codex_config::types::AppToolConfig; use codex_config::types::AppToolsConfig; use codex_config::types::AppsDefaultConfig; -use codex_connectors::filter::filter_disallowed_connectors; -use codex_connectors::filter::filter_tool_suggest_discoverable_connectors; -use codex_connectors::merge::merge_connectors; use codex_connectors::merge::plugin_connector_to_app_info; use codex_connectors::metadata::connector_install_url; -use codex_connectors::metadata::connector_mention_slug; use codex_connectors::metadata::sanitize_name; use codex_features::Feature; use codex_login::CodexAuth; @@ -62,15 +58,6 @@ fn app(id: &str) -> AppInfo { } } -fn named_app(id: &str, name: &str) -> AppInfo { - AppInfo { - id: id.to_string(), - name: name.to_string(), - install_url: Some(connector_install_url(name, id)), - ..app(id) - } -} - fn plugin_names(names: &[&str]) -> Vec { names.iter().map(ToString::to_string).collect() } @@ -89,24 +76,6 @@ fn test_tool_definition(tool_name: &str) -> Tool { } } -fn google_calendar_accessible_connector(plugin_display_names: &[&str]) -> AppInfo { - AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events".to_string()), - logo_url: Some("https://example.com/logo.png".to_string()), - logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), - distribution_channel: Some("workspace".to_string()), - branding: None, - app_metadata: None, - labels: None, - install_url: None, - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_names(plugin_display_names), - } -} - fn codex_app_tool( tool_name: &str, connector_id: &str, @@ -147,34 +116,6 @@ fn with_accessible_connectors_cache_cleared(f: impl FnOnce() -> R) -> R { result } -#[test] -fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { - let plugin = plugin_connector_to_app_info("calendar".to_string()); - let accessible = google_calendar_accessible_connector(&[]); - - let merged = merge_connectors(vec![plugin], vec![accessible]); - - assert_eq!( - merged, - vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events".to_string()), - logo_url: Some("https://example.com/logo.png".to_string()), - logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), - distribution_channel: Some("workspace".to_string()), - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url("calendar", "calendar")), - is_accessible: true, - is_enabled: true, - plugin_display_names: Vec::new(), - }] - ); - assert_eq!(connector_mention_slug(&merged[0]), "google-calendar"); -} - #[test] fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { let tools = vec![ @@ -258,50 +199,38 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal assert_eq!( cached, - vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: None, - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - install_url: Some(connector_install_url("Google Calendar", "calendar")), - branding: None, - app_metadata: None, - labels: None, - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_names(&["calendar-plugin"]), - }] - ); -} - -#[test] -fn merge_connectors_unions_and_dedupes_plugin_display_names() { - let mut plugin = plugin_connector_to_app_info("calendar".to_string()); - plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]); - - let accessible = google_calendar_accessible_connector(&["beta", "alpha"]); - - let merged = merge_connectors(vec![plugin], vec![accessible]); - - assert_eq!( - merged, - vec![AppInfo { - id: "calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Plan events".to_string()), - logo_url: Some("https://example.com/logo.png".to_string()), - logo_url_dark: Some("https://example.com/logo-dark.png".to_string()), - distribution_channel: Some("workspace".to_string()), - branding: None, - app_metadata: None, - labels: None, - install_url: Some(connector_install_url("calendar", "calendar")), - is_accessible: true, - is_enabled: true, - plugin_display_names: plugin_names(&["alpha", "beta", "sample"]), - }] + vec![ + AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Google Calendar", "calendar")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: plugin_names(&["calendar-plugin"]), + }, + AppInfo { + id: "connector_openai_hidden".to_string(), + name: "Hidden".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url("Hidden", "connector_openai_hidden")), + branding: None, + app_metadata: None, + labels: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + ] ); } @@ -1245,55 +1174,6 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { ); } -#[test] -fn filter_disallowed_connectors_allows_non_disallowed_connectors() { - let filtered = - filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")], "codex_cli"); - assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]); -} - -#[test] -fn filter_disallowed_connectors_filters_openai_prefix() { - let filtered = filter_disallowed_connectors( - vec![ - app("connector_openai_foo"), - app("connector_openai_bar"), - app("gamma"), - ], - "codex_cli", - ); - assert_eq!(filtered, vec![app("gamma")]); -} - -#[test] -fn filter_disallowed_connectors_filters_disallowed_connector_ids() { - let filtered = filter_disallowed_connectors( - vec![ - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"), - app("delta"), - ], - "codex_cli", - ); - assert_eq!(filtered, vec![app("delta")]); -} - -#[test] -fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { - let filtered = filter_disallowed_connectors( - vec![ - app("connector_openai_foo"), - app("asdk_app_6938a94a61d881918ef32cb999ff937c"), - app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"), - ], - "codex_atlas", - ); - assert_eq!( - filtered, - vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")] - ); -} - #[tokio::test] async fn tool_suggest_connector_ids_include_configured_tool_suggest_discoverables() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -1385,71 +1265,3 @@ discoverables = [ ))] ); } - -#[test] -fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { - let filtered = filter_tool_suggest_discoverable_connectors( - vec![ - named_app( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - ), - named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), - named_app("connector_other", "Other"), - ], - &[AppInfo { - is_accessible: true, - ..named_app( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - ) - }], - &HashSet::from([ - "connector_2128aebfecb84f64a069897515042a44".to_string(), - "connector_68df038e0ba48191908c8434991bbac2".to_string(), - ]), - "codex_cli", - ); - - assert_eq!( - filtered, - vec![named_app( - "connector_68df038e0ba48191908c8434991bbac2", - "Gmail", - )] - ); -} - -#[test] -fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() { - let filtered = filter_tool_suggest_discoverable_connectors( - vec![ - named_app( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - ), - named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"), - ], - &[ - AppInfo { - is_accessible: true, - ..named_app( - "connector_2128aebfecb84f64a069897515042a44", - "Google Calendar", - ) - }, - AppInfo { - is_accessible: true, - is_enabled: false, - ..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail") - }, - ], - &HashSet::from([ - "connector_2128aebfecb84f64a069897515042a44".to_string(), - "connector_68df038e0ba48191908c8434991bbac2".to_string(), - ]), - "codex_cli", - ); - - assert_eq!(filtered, Vec::::new()); -} diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0cdbc0b010..d7302bbbf8 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -61,6 +61,7 @@ fn build_permissions_update_item( next.approval_policy.value(), next.config.approvals_reviewer, exec_policy, + #[allow(deprecated)] &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), next.features.enabled(Feature::RequestPermissionsTool), diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 776aa8e54f..8c424c541c 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -260,6 +260,26 @@ async fn returns_empty_policy_when_no_policy_files_exist() { assert!(!temp_dir.path().join(RULES_DIR_NAME).exists()); } +#[tokio::test] +async fn rules_path_file_returns_read_dir_error() { + let temp_dir = tempdir().expect("create temp dir"); + let rules_path = temp_dir.path().join(RULES_DIR_NAME); + fs::write(&rules_path, "rules should be a directory").expect("write malformed rules path"); + let config_stack = config_stack_for_dot_codex_folder(temp_dir.path()); + + let err = load_exec_policy(&config_stack) + .await + .expect_err("rules file should fail policy loading"); + + assert!( + matches!( + err, + ExecPolicyError::ReadDir { ref dir, .. } if dir == &rules_path + ), + "expected malformed rules path to surface as ReadDir, got {err:?}" + ); +} + #[tokio::test] async fn collect_policy_files_returns_empty_when_dir_missing() { let temp_dir = tempdir().expect("create temp dir"); @@ -592,6 +612,7 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_config_toml, + profile: None, }, TomlValue::Table(Default::default()), ), diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index 0ffdd1c4df..b13db36d1e 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -11,6 +11,8 @@ use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::skip_if_sandbox; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use tempfile::TempDir; use tokio::process::Command; @@ -337,6 +339,46 @@ async fn test_get_has_changes_with_untracked_change_returns_true() { assert_eq!(get_has_changes(&repo_path).await, Some(true)); } +#[cfg(unix)] +#[tokio::test] +async fn test_get_has_changes_ignores_repo_fsmonitor_config() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + let helper_path = repo_path.join("fsmonitor-helper.sh"); + let marker_path = repo_path.join("fsmonitor-ran"); + + fs::write( + &helper_path, + format!( + "#!/bin/sh\nprintf ran > \"{}\"\n", + marker_path.to_string_lossy() + ), + ) + .expect("write fsmonitor helper"); + let mut permissions = fs::metadata(&helper_path) + .expect("read fsmonitor helper metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&helper_path, permissions).expect("mark fsmonitor helper executable"); + + Command::new("git") + .args([ + "config", + "core.fsmonitor", + helper_path.to_string_lossy().as_ref(), + ]) + .current_dir(&repo_path) + .output() + .await + .expect("configure fsmonitor helper"); + + assert_eq!(get_has_changes(&repo_path).await, Some(true)); + assert!( + !marker_path.exists(), + "metadata collection should not invoke repository fsmonitor helpers" + ); +} + #[tokio::test] async fn test_get_git_working_tree_state_clean_repo() { let temp_dir = TempDir::new().expect("Failed to create temp dir"); diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index a419d7cbfa..d3fae0f2e5 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -708,6 +708,7 @@ async fn run_review_on_session( Box::pin(review_session.codex.submit(Op::UserTurn { environments: None, items: prompt_items.items, + #[allow(deprecated)] cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, @@ -1086,6 +1087,7 @@ mod tests { let reasoning_effort = turn.reasoning_effort; let reasoning_summary = turn.reasoning_summary; let personality = turn.personality; + #[allow(deprecated)] let cwd = turn.cwd.clone(); let spawn_config = build_guardian_review_session_config( turn.config.as_ref(), diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 3d72e4a7c6..78fc24833e 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -116,6 +116,7 @@ pub(crate) async fn run_pending_session_start_hooks( let request = codex_hooks::SessionStartRequest { session_id: sess.session_id().into(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -150,6 +151,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -209,6 +211,7 @@ pub(crate) async fn run_permission_request_hooks( let request = PermissionRequestRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -249,6 +252,7 @@ pub(crate) async fn run_post_tool_use_hooks( let request = PostToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -276,6 +280,7 @@ pub(crate) async fn run_pre_compact_hooks( let request = codex_hooks::PreCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -313,6 +318,7 @@ pub(crate) async fn run_post_compact_hooks( let request = codex_hooks::PostCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -338,6 +344,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 0e0d4a6008..ae44515c62 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -102,6 +102,7 @@ async fn build_uploaded_local_argument_value( index: Option, file_path: &str, ) -> Result { + #[allow(deprecated)] let resolved_path = turn_context.resolve_path(Some(file_path.to_string())); let Some(auth) = auth else { return Err( @@ -216,7 +217,10 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -297,7 +301,10 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -411,7 +418,10 @@ mod tests { tokio::fs::write(dir.path().join("two.csv"), b"two") .await .expect("write second local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8855aae62e..fc8ce4d8ca 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -15,7 +15,6 @@ use crate::arc_monitor::monitor_action; use crate::config::Config; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; -use crate::config::load_global_mcp_servers; use crate::connectors; use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianMcpAnnotations; @@ -732,6 +731,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), + #[allow(deprecated)] sandbox_cwd: turn_context.cwd.to_path_buf(), use_legacy_landlock: turn_context.features.use_legacy_landlock(), })?; @@ -2003,8 +2003,7 @@ async fn maybe_persist_mcp_tool_approval( remember_mcp_tool_approval(sess, key).await; return; }; - persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name) - .await + persist_codex_app_tool_approval(&turn_context.config, &connector_id, &tool_name).await } else { persist_non_app_mcp_tool_approval(sess, &turn_context.config, &key.server, &tool_name).await }; @@ -2025,11 +2024,11 @@ async fn maybe_persist_mcp_tool_approval( } async fn persist_codex_app_tool_approval( - codex_home: &AbsolutePathBuf, + config: &Config, connector_id: &str, tool_name: &str, ) -> anyhow::Result<()> { - ConfigEditsBuilder::new(codex_home) + ConfigEditsBuilder::for_config(config) .with_edits([ConfigEdit::SetPath { segments: vec![ "apps".to_string(), @@ -2050,11 +2049,12 @@ async fn persist_custom_mcp_tool_approval( server: &str, tool_name: &str, ) -> anyhow::Result<()> { - let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? else { + let Some(config_edits_builder) = custom_mcp_tool_approval_config_builder(config, server)? + else { anyhow::bail!("MCP server `{server}` is not configured in config.toml"); }; - persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await + persist_custom_mcp_tool_approval_with(config_edits_builder, server, tool_name).await } async fn persist_non_app_mcp_tool_approval( @@ -2063,8 +2063,9 @@ async fn persist_non_app_mcp_tool_approval( server: &str, tool_name: &str, ) -> anyhow::Result<()> { - if let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? { - return persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await; + if let Some(config_edits_builder) = custom_mcp_tool_approval_config_builder(config, server)? { + return persist_custom_mcp_tool_approval_with(config_edits_builder, server, tool_name) + .await; } let plugin_config_name = sess @@ -2079,7 +2080,7 @@ async fn persist_non_app_mcp_tool_approval( .map(|plugin| plugin.config_name.clone()); if let Some(plugin_config_name) = plugin_config_name { - return ConfigEditsBuilder::new(&config.codex_home) + return ConfigEditsBuilder::for_config(config) .with_edits([ConfigEdit::SetPath { segments: vec![ "plugins".to_string(), @@ -2099,26 +2100,24 @@ async fn persist_non_app_mcp_tool_approval( anyhow::bail!("MCP server `{server}` is not configured in config.toml or an enabled plugin") } -async fn custom_mcp_tool_approval_config_folder( +fn custom_mcp_tool_approval_config_builder( config: &Config, server: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { if let Some(project_config_folder) = project_mcp_tool_approval_config_folder(config, server) { - return Ok(Some(project_config_folder)); + return Ok(Some(ConfigEditsBuilder::new(&project_config_folder))); } - let servers = load_global_mcp_servers(&config.codex_home).await?; - Ok(servers - .contains_key(server) - .then(|| config.codex_home.clone())) + Ok(user_mcp_server_is_configured(config, server)? + .then(|| ConfigEditsBuilder::for_config(config))) } -async fn persist_custom_mcp_tool_approval_at( - config_folder: &AbsolutePathBuf, +async fn persist_custom_mcp_tool_approval_with( + config_edits_builder: ConfigEditsBuilder, server: &str, tool_name: &str, ) -> anyhow::Result<()> { - ConfigEditsBuilder::new(config_folder) + config_edits_builder .with_edits([ConfigEdit::SetPath { segments: vec![ "mcp_servers".to_string(), @@ -2133,6 +2132,21 @@ async fn persist_custom_mcp_tool_approval_at( .await } +fn user_mcp_server_is_configured(config: &Config, server: &str) -> anyhow::Result { + let Some(mcp_servers_toml) = config + .config_layer_stack + .effective_user_config() + .as_ref() + .and_then(|user_config| user_config.get("mcp_servers")) + .cloned() + else { + return Ok(false); + }; + let servers = + HashMap::::deserialize(mcp_servers_toml)?; + Ok(servers.contains_key(server)) +} + fn project_mcp_tool_approval_config_folder( config: &Config, server: &str, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 65f8977283..d8b6326cd0 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -30,7 +30,6 @@ use codex_rollout_trace::ToolDispatchInvocation; use codex_rollout_trace::ToolDispatchPayload; use codex_rollout_trace::ToolDispatchRequester; use codex_rollout_trace::replay_bundle; -use core_test_support::PathExt; use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -1221,7 +1220,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: turn_context.sub_id.clone(), session.get_tx_event(), turn_context.permission_profile(), - codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()), + codex_mcp::McpRuntimeEnvironment::new(environment, { + #[allow(deprecated)] + turn_context.cwd.to_path_buf() + }), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), /*host_owned_codex_apps_enabled*/ true, @@ -1871,8 +1873,13 @@ fn accepted_elicitation_without_content_defaults_to_accept() { #[tokio::test] async fn persist_codex_app_tool_approval_writes_tool_override() { let tmp = tempdir().expect("tempdir"); + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("load config"); - persist_codex_app_tool_approval(&tmp.path().abs(), "calendar", "calendar/list_events") + persist_codex_app_tool_approval(&config, "calendar", "calendar/list_events") .await .expect("persist approval"); @@ -2113,7 +2120,7 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config() { #[tokio::test] async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_server() { - let (session, turn_context) = make_session_and_context().await; + let (session, mut turn_context) = make_session_and_context().await; let codex_home = session.codex_home().await; std::fs::create_dir_all(&codex_home).expect("create codex home"); std::fs::write( @@ -2121,6 +2128,12 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve "[mcp_servers.docs]\ncommand = \"docs-server\"\n", ) .expect("seed config"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.clone().to_path_buf()) + .build() + .await + .expect("load config"); + turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), connector_id: None, @@ -2230,7 +2243,6 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve .build() .await .expect("load project config"); - turn_context.cwd = config.cwd.clone(); turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), @@ -2431,12 +2443,14 @@ async fn permission_request_hook_allows_mcp_tool_call() { .lines() .map(|line| serde_json::from_str::(line).expect("parse hook input")) .collect::>(); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); assert_eq!( inputs, vec![serde_json::json!({ "session_id": session.session_id(), "turn_id": "turn_id", - "cwd": turn_context.cwd, + "cwd": turn_cwd, "transcript_path": null, "model": turn_context.model_info.slug, "permission_mode": "default", @@ -2491,12 +2505,14 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { .lines() .map(|line| serde_json::from_str::(line).expect("parse hook input")) .collect::>(); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); assert_eq!( inputs, vec![serde_json::json!({ "session_id": session.session_id(), "turn_id": "turn_id", - "cwd": turn_context.cwd, + "cwd": turn_cwd, "transcript_path": null, "model": turn_context.model_info.slug, "permission_mode": "default", diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index fd54044f21..baa64459a2 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -5,7 +5,6 @@ use crate::tools::handlers::unified_exec::ExecCommandArgs; use codex_memories_read::usage::MEMORIES_USAGE_METRIC; use codex_memories_read::usage::memories_usage_kinds_from_command; use codex_protocol::models::ShellCommandToolCallParams; -use codex_protocol::models::ShellToolCallParams; use std::path::PathBuf; pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, success: bool) { @@ -41,22 +40,13 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) - .ok() - .map(|params| { - ( - params.command, - invocation.turn.resolve_path(params.workdir).to_path_buf(), - ) - }), (None, "shell_command") => serde_json::from_str::(arguments) .ok() .map(|params| { if !invocation.turn.tools_config.allow_login_shell && params.login == Some(true) { - return ( - Vec::new(), - invocation.turn.resolve_path(params.workdir).to_path_buf(), - ); + #[allow(deprecated)] + let cwd = invocation.turn.resolve_path(params.workdir).to_path_buf(); + return (Vec::new(), cwd); } let use_login_shell = params .login @@ -65,10 +55,9 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec serde_json::from_str::(arguments) .ok() @@ -80,10 +69,9 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec None, } diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 41ef46e3fd..1c49be2daf 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -90,7 +90,7 @@ fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec { .filter_map(|layer| { let path = match &layer.name { ConfigLayerSource::System { file } => Some(file.clone()), - ConfigLayerSource::User { file } => Some(file.clone()), + ConfigLayerSource::User { file, .. } => Some(file.clone()), ConfigLayerSource::Project { dot_codex_folder } => { Some(dot_codex_folder.join(CONFIG_TOML_FILE)) } diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 18748b33f1..1e632ba0cf 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -194,7 +194,6 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { lock_config.default_permissions = None; lock_config.permissions = None; lock_config.experimental_use_unified_exec_tool = None; - lock_config.experimental_use_freeform_apply_patch = None; } fn resolved_config_to_toml( @@ -264,6 +263,8 @@ mod tests { enabled: Some(false), max_concurrent_threads_per_session: Some(_), min_wait_timeout_ms: Some(_), + max_wait_timeout_ms: Some(_), + default_wait_timeout_ms: Some(_), usage_hint_enabled: Some(_), hide_spawn_agent_metadata: Some(_), .. diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index ab40cb8eed..5cb37f9828 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -699,6 +699,7 @@ pub async fn review( .await; sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) .await; + #[allow(deprecated)] match resolve_review_request(review_request, &turn_context.cwd) { Ok(resolved) => { spawn_review_thread( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d32ecf2b19..fcaaa17c57 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -296,6 +296,7 @@ impl Session { .environment_manager .default_environment() .unwrap_or_else(|| self.services.environment_manager.local_environment()), + #[allow(deprecated)] turn_context.cwd.to_path_buf(), ), }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 62222261c5..3b6c38249f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -179,6 +179,8 @@ use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStackOrdering; use codex_config::types::McpServerConfig; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::config_types::ShellEnvironmentPolicy; @@ -1482,37 +1484,62 @@ impl Session { // // Prefer `refresh_runtime_config()` when the host can already provide a materialized // config snapshot. This file-based path exists for legacy local reload flows. - let config_toml_path = { + let config_toml_paths = { let state = self.state.lock().await; - state - .session_configuration - .codex_home - .join(CONFIG_TOML_FILE) + let config = &state.session_configuration.original_config_do_not_use; + let user_config_paths = config + .config_layer_stack + .get_user_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter_map(|layer| match &layer.name { + ConfigLayerSource::User { file, .. } => Some(file.clone()), + _ => None, + }) + .collect::>(); + if user_config_paths.is_empty() { + vec![ + state + .session_configuration + .codex_home + .join(CONFIG_TOML_FILE), + ] + } else { + user_config_paths + } }; - let user_config = match std::fs::read_to_string(&config_toml_path) { - Ok(contents) => match toml::from_str::(&contents) { - Ok(config) => config, + let mut reloaded_user_configs = Vec::with_capacity(config_toml_paths.len()); + for config_toml_path in config_toml_paths { + let user_config = match std::fs::read_to_string(&config_toml_path) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(config) => config, + Err(err) => { + warn!("failed to parse user config while reloading layer: {err}"); + return; + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + toml::Value::Table(Default::default()) + } Err(err) => { - warn!("failed to parse user config while reloading layer: {err}"); + warn!("failed to read user config while reloading layer: {err}"); return; } - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - toml::Value::Table(Default::default()) - } - Err(err) => { - warn!("failed to read user config while reloading layer: {err}"); - return; - } - }; + }; + reloaded_user_configs.push((config_toml_path, user_config)); + } let next_config = { let state = self.state.lock().await; let mut config = (*state.session_configuration.original_config_do_not_use).clone(); - config.config_layer_stack = config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); + for (config_toml_path, user_config) in reloaded_user_configs { + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&config_toml_path, user_config); + } config.tool_suggest = resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack); config @@ -2054,6 +2081,7 @@ impl Session { turn_context, call_id, args, + #[allow(deprecated)] turn_context.cwd.clone(), cancellation_token, ) @@ -2633,6 +2661,7 @@ impl Session { turn_context.approval_policy.value(), turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), + #[allow(deprecated)] &turn_context.cwd, turn_context .features @@ -2766,6 +2795,7 @@ impl Session { contextual_user_sections.push( UserInstructions { text: user_instructions.to_string(), + #[allow(deprecated)] directory: turn_context.cwd.to_string_lossy().into_owned(), } .render(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index f884a95a27..acb4adb945 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) .with_wait_agent_min_timeout_ms( @@ -63,6 +64,16 @@ pub(super) async fn spawn_review_thread( .enabled(Feature::MultiAgentV2) .then_some(config.multi_agent_v2.min_wait_timeout_ms), ) + .with_wait_agent_max_timeout_ms( + review_features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.max_wait_timeout_ms), + ) + .with_wait_agent_default_timeout_ms( + review_features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.default_wait_timeout_ms), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -106,6 +117,7 @@ pub(super) async fn spawn_review_thread( sess.thread_id().to_string(), parent_turn_context.thread_source, review_turn_id.clone(), + #[allow(deprecated)] parent_turn_context.cwd.clone(), &parent_turn_context.permission_profile, parent_turn_context.windows_sandbox_level, @@ -142,6 +154,7 @@ pub(super) async fn spawn_review_thread( network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), + #[allow(deprecated)] cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, codex_self_exe: parent_turn_context.codex_self_exe.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 5cfcc38053..143b23d3a3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -60,6 +60,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -101,6 +102,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -911,6 +913,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -989,6 +992,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1020,6 +1024,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1135,6 +1140,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1249,6 +1255,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1401,6 +1408,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b4ba561873..38988329e9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4,7 +4,6 @@ use crate::config::ConfigBuilder; use crate::config::test_config; use crate::context::ContextualUserFragment; use crate::context::TurnAborted; -use crate::exec::ExecCapturePolicy; use crate::function_tool::FunctionCallError; use crate::shell::default_user_shell; use crate::skills::SkillRenderSideEffects; @@ -13,6 +12,7 @@ use crate::test_support::models_manager_with_provider; use crate::tools::format_exec_output_str; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; +use codex_config::LoaderOverrides; use codex_config::NetworkConstraints; use codex_config::NetworkDomainPermissionToml; use codex_config::NetworkDomainPermissionsToml; @@ -69,7 +69,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; -use crate::tools::handlers::ShellHandler; +use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; @@ -1211,6 +1211,70 @@ async fn reload_user_config_layer_updates_effective_apps_config() { assert_eq!(app.destructive_enabled, Some(false)); } +#[tokio::test] +async fn reload_user_config_layer_updates_base_and_selected_profile_layers() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let base_config_path = codex_home.join(CONFIG_TOML_FILE); + let profile_config_path = codex_home.join("work.config.toml"); + std::fs::write( + &base_config_path, + "model = \"base\"\napproval_policy = \"on-failure\"\n", + ) + .expect("write base user config"); + std::fs::write(&profile_config_path, "model = \"profile-old\"\n") + .expect("write profile user config"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.to_path_buf()) + .loader_overrides(LoaderOverrides { + user_config_path: Some(profile_config_path.abs()), + user_config_profile: Some("work".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await + .expect("load profile config"); + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config); + } + std::fs::write( + &base_config_path, + "model = \"base\"\napproval_policy = \"never\"\n", + ) + .expect("update base user config"); + std::fs::write(&profile_config_path, "model = \"profile-new\"\n") + .expect("update profile user config"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + assert_eq!( + config + .config_layer_stack + .get_user_config_file() + .map(codex_utils_absolute_path::AbsolutePathBuf::as_path), + Some(profile_config_path.as_path()) + ); + let effective_user_config = config + .config_layer_stack + .effective_user_config() + .expect("merged user config"); + assert_eq!( + effective_user_config + .get("model") + .and_then(toml::Value::as_str), + Some("profile-new") + ); + assert_eq!( + effective_user_config + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("never") + ); +} + #[tokio::test] async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { let session = make_session_with_config(|config| { @@ -2217,6 +2281,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -3763,13 +3828,14 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up !updated .file_system_sandbox_policy() .can_write_path_with_cwd(next_cwd.as_path(), updated.cwd.as_path()), - "cwd-only update must not reinterpret an absolute old-cwd grant as :project_roots" + "cwd-only update must not reinterpret an absolute old-cwd grant as :workspace_roots" ); } #[tokio::test] async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let (session, turn_context) = make_session_and_context().await; + #[allow(deprecated)] let updated_cwd = turn_context.cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); @@ -3789,8 +3855,12 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let next_turn = session.new_default_turn().await; assert_eq!(session_cwd, updated_cwd); - assert_eq!(config.cwd, turn_context.cwd); - assert_eq!(next_turn.cwd, updated_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + #[allow(deprecated)] + let next_turn_cwd = next_turn.cwd.clone(); + assert_eq!(config.cwd, turn_cwd); + assert_eq!(next_turn_cwd, updated_cwd); assert_eq!(next_turn.config.cwd, updated_cwd); } @@ -3874,7 +3944,9 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .await .expect("absolute cwd with explicit environments should succeed"); - assert_eq!(turn_context.cwd, absolute_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, absolute_cwd); assert_eq!(turn_context.config.cwd, absolute_cwd); assert_eq!(turn_context.environments.turn_environments.len(), 1); } @@ -4675,7 +4747,9 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); - assert_eq!(request.cwd, Some(turn_context.cwd.clone())); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(request.cwd, Some(turn_cwd)); session .notify_request_permissions_response(&request.call_id, expected_response.clone()) @@ -5104,7 +5178,9 @@ async fn turn_environments_set_primary_environment() { &turn_environments.turn_environments[0].environment )); assert!(!turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd.as_path(), selected_cwd.as_path()); assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); } @@ -5135,7 +5211,9 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { &turn_environment.environment, &turn_environments.turn_environments[0].environment )); - assert_eq!(turn_context.cwd, session_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); } @@ -5153,7 +5231,9 @@ async fn default_turn_honors_empty_stored_thread_environments() { assert!(turn_context.environments.primary().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd, session_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); assert_eq!(turn_context.environments.turn_environments.len(), 0); } @@ -5162,6 +5242,7 @@ async fn default_turn_honors_empty_stored_thread_environments() { async fn primary_environment_uses_first_turn_environment() { let (_session, mut turn_context) = make_session_and_context().await; let first_environment = turn_context.environments.turn_environments[0].clone(); + #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); turn_context .environments @@ -5215,7 +5296,9 @@ async fn empty_turn_environments_clear_primary_environment() { assert!(turn_context.environments.primary().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd, session.get_config().await.cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session.get_config().await.cwd); assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); } @@ -6475,10 +6558,10 @@ async fn make_multi_agent_v2_usage_hint_test_session( (session, turn_context) } -struct GitAttributionTestContributor; -struct GitAttributionTestState; +struct PromptExtensionTestContributor; +struct PromptExtensionTestState; -impl codex_extension_api::ContextContributor for GitAttributionTestContributor { +impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { fn contribute<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, @@ -6488,11 +6571,11 @@ impl codex_extension_api::ContextContributor for GitAttributionTestContributor { > { Box::pin(async move { thread_store - .get::() + .get::() .is_some() .then(|| { codex_extension_api::PromptFragment::developer_policy( - "git attribution extension enabled", + "prompt extension enabled", ) }) .into_iter() @@ -6501,21 +6584,21 @@ impl codex_extension_api::ContextContributor for GitAttributionTestContributor { } } -fn git_attribution_test_registry() +fn prompt_extension_test_registry() -> Arc> { let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); - builder.prompt_contributor(Arc::new(GitAttributionTestContributor)); + builder.prompt_contributor(Arc::new(PromptExtensionTestContributor)); Arc::new(builder.build()) } #[tokio::test] -async fn build_initial_context_includes_git_attribution_from_extensions() { +async fn build_initial_context_includes_prompt_fragments_from_extensions() { let (mut session, turn_context) = make_session_and_context().await; - session.services.extensions = git_attribution_test_registry(); + session.services.extensions = prompt_extension_test_registry(); session .services .thread_extension_data - .insert(GitAttributionTestState); + .insert(PromptExtensionTestState); let initial_context = session.build_initial_context(&turn_context).await; let developer_messages = developer_message_texts(&initial_context); @@ -6524,15 +6607,15 @@ async fn build_initial_context_includes_git_attribution_from_extensions() { developer_messages .iter() .flatten() - .any(|text| *text == "git attribution extension enabled"), - "expected git attribution developer text, got {developer_messages:?}" + .any(|text| *text == "prompt extension enabled"), + "expected prompt extension developer text, got {developer_messages:?}" ); } #[tokio::test] -async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() { +async fn build_initial_context_omits_prompt_fragments_without_extension_state() { let (mut session, turn_context) = make_session_and_context().await; - session.services.extensions = git_attribution_test_registry(); + session.services.extensions = prompt_extension_test_registry(); let initial_context = session.build_initial_context(&turn_context).await; let developer_messages = developer_message_texts(&initial_context); @@ -6541,8 +6624,8 @@ async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() !developer_messages .iter() .flatten() - .any(|text| *text == "git attribution extension enabled"), - "did not expect git attribution developer text, got {developer_messages:?}" + .any(|text| *text == "prompt extension enabled"), + "did not expect prompt extension developer text, got {developer_messages:?}" ); } @@ -6926,6 +7009,9 @@ async fn handle_output_item_done_records_image_save_history_message() { let mut ctx = HandleOutputCtx { sess: Arc::clone(&session), turn_context: Arc::clone(&turn_context), + turn_store: Arc::new(codex_extension_api::ExtensionData::new( + turn_context.sub_id.clone(), + )), tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; @@ -6978,6 +7064,9 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { let mut ctx = HandleOutputCtx { sess: Arc::clone(&session), turn_context: Arc::clone(&turn_context), + turn_store: Arc::new(codex_extension_api::ExtensionData::new( + turn_context.sub_id.clone(), + )), tool_runtime: test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)), cancellation_token: CancellationToken::new(), }; @@ -7034,13 +7123,16 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is } fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy { + #[allow(deprecated)] let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &turn_context.sandbox_policy(), &turn_context.cwd, ); + #[allow(deprecated)] + let cwd_display = turn_context.cwd.as_path().display().to_string(); policy.entries.push(FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: format!("{}/**/*.env", turn_context.cwd.as_path().display()), + pattern: format!("{cwd_display}/**/*.env"), }, access: FileSystemAccessMode::None, }); @@ -8317,7 +8409,7 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { turn_context: tc.as_ref(), - tool_name: "shell", + tool_name: "shell_command", }) .await?; @@ -8553,7 +8645,7 @@ async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow: .await; sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { turn_context: tc.as_ref(), - tool_name: "shell", + tool_name: "shell_command", }) .await?; @@ -8862,6 +8954,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { let mut ctx = HandleOutputCtx { sess: Arc::clone(&sess), turn_context: Arc::clone(&tc), + turn_store: Arc::new(codex_extension_api::ExtensionData::new(tc.sub_id.clone())), tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)), cancellation_token: CancellationToken::new(), }; @@ -8984,7 +9077,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { id: None, status: None, call_id: "call-1".to_string(), - name: "shell".to_string(), + name: "shell_command".to_string(), input: "{}".to_string(), }; @@ -9007,7 +9100,10 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { match err { FunctionCallError::Fatal(message) => { - assert_eq!(message, "tool shell invoked with incompatible payload"); + assert_eq!( + message, + "tool shell_command invoked with incompatible payload" + ); } other => panic!("expected FunctionCallError::Fatal, got {other:?}"), } @@ -9353,13 +9449,12 @@ async fn update_goal_tool_marks_goal_complete() { #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { - use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::protocol::AskForApproval; - use std::collections::HashMap; + use codex_tools::ShellCommandBackendConfig; let (session, mut turn_context_raw) = make_session_and_context().await; // Ensure policy is NOT OnRequest so the early rejection path triggers @@ -9370,43 +9465,18 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let session = Arc::new(session); let mut turn_context = Arc::new(turn_context_raw); + let command_script = "echo hi"; let timeout_ms = 1000; let sandbox_permissions = SandboxPermissions::RequireEscalated; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - sandbox_permissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: Some("test".to_string()), - arg0: None, - }; let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let tool_name = "shell"; + let tool_name = "shell_command"; let call_id = "test-call".to_string(); - let handler = ShellHandler::default(); + let handler = ShellCommandHandler::from(ShellCommandBackendConfig::Classic); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -9418,11 +9488,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": params.command.clone(), - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, - "justification": params.justification.clone(), + "command": command_script, + "workdir": workdir, + "timeout_ms": timeout_ms, + "sandbox_permissions": sandbox_permissions, + "justification": Some("test"), }) .to_string(), }, @@ -9448,14 +9518,18 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); + let command = session + .user_shell() + .derive_exec_args(command_script, turn_context.tools_config.allow_login_shell); let exec_approval_requirement = session .services .exec_policy .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: ¶ms.command, + command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), file_system_sandbox_policy: &file_system_sandbox_policy, + #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 730b9f5e87..718b9bf05f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -1,8 +1,6 @@ use super::*; use crate::compact::InitialContextInjection; use crate::environment_selection::ResolvedTurnEnvironments; -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; use crate::sandboxing::SandboxPermissions; @@ -43,8 +41,6 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; -use serde::Deserialize; -use std::collections::HashMap; use std::fs; use std::sync::Arc; use std::time::Duration; @@ -238,7 +234,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { } #[tokio::test] -async fn guardian_allows_shell_additional_permissions_requests_past_policy_validation() { +async fn guardian_allows_shell_command_additional_permissions_requests_past_policy_validation() { let server = start_mock_server().await; let _request_log = mount_sse_once( &server, @@ -292,38 +288,11 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid let turn_context = Arc::new(turn_context_raw); let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 }; - let params = ExecParams { - command: if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/Q".to_string(), - "/D".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }, - cwd: turn_context.cwd.clone(), - expiration: expiration_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: HashMap::new(), - network: None, - sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: Some("test".to_string()), - arg0: None, - }; - - let handler = ShellHandler::default(); + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -331,21 +300,22 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "test-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("shell_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": params.command.clone(), - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.expiration.timeout_ms(), - "sandbox_permissions": params.sandbox_permissions, + "command": "echo hi", + "login": false, + "workdir": workdir, + "timeout_ms": expiration_ms, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": PermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), file_system: None, }, - "justification": params.justification.clone(), + "justification": Some("test"), }) .to_string(), }, @@ -353,27 +323,11 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid .await; let output = expect_text_output(&resp.expect("expected Ok result")); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(output.contains("hi")); } #[tokio::test] -async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { +async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_skip() { let server = start_mock_server().await; let guardian_request_log = mount_sse_once( &server, @@ -437,35 +391,25 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = ShellHandler::default(); - let command = if cfg!(windows) { - vec![ - "cmd.exe".to_string(), - "/Q".to_string(), - "/D".to_string(), - "/C".to_string(), - "echo hi".to_string(), - ] - } else { - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - "echo hi".to_string(), - ] - }; + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), turn: Arc::clone(&turn_context), cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), - call_id: "strict-shell-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + call_id: "strict-shell-command-call".to_string(), + tool_name: codex_tools::ToolName::plain("shell_command"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": command, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "command": "echo hi", + "login": false, + "workdir": workdir, "timeout_ms": 1_000_u64, }) .to_string(), @@ -593,7 +537,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message clippy::await_holding_invalid_type, reason = "test mutates active turn state directly to seed granted permissions" )] -async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { +async fn shell_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { let (mut session, turn_context_raw) = make_session_and_context().await; session .features @@ -615,7 +559,11 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = ShellHandler::default(); + let handler = crate::tools::handlers::ShellCommandHandler::from( + codex_tools::ShellCommandBackendConfig::Classic, + ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -623,17 +571,14 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "sticky-turn-grant".to_string(), - tool_name: codex_tools::ToolName::plain("shell"), + tool_name: codex_tools::ToolName::plain("shell_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": [ - "/bin/sh", - "-c", - "echo hi", - ], + "command": "echo hi", + "login": false, "timeout_ms": 1_000_u64, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, }) .to_string(), }, @@ -643,23 +588,7 @@ async fn shell_handler_allows_sticky_turn_permissions_without_inline_request_per match resp { Ok(output) => { let output = expect_text_output(&output); - - #[derive(Deserialize, PartialEq, Eq, Debug)] - struct ResponseExecMetadata { - exit_code: i32, - } - - #[derive(Deserialize)] - struct ResponseExecOutput { - output: String, - metadata: ResponseExecMetadata, - } - - let exec_output: ResponseExecOutput = - serde_json::from_str(&output).expect("valid exec output json"); - - assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); - assert!(exec_output.output.contains("hi")); + assert!(output.contains("hi")); } Err(FunctionCallError::RespondToModel(output)) => { assert!( diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2dd84213ed..86710e4871 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -44,12 +44,14 @@ use crate::session::PreviousTurnSettings; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::HandleOutputCtx; +use crate::stream_events_utils::TurnItemContributorPolicy; +use crate::stream_events_utils::finalize_non_tool_response_item; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; use crate::stream_events_utils::last_assistant_message_from_item; use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context; use crate::stream_events_utils::raw_assistant_output_text_from_item; -use crate::stream_events_utils::record_completed_response_item; +use crate::stream_events_utils::record_completed_response_item_with_finalized_facts; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::parallel::ToolCallRuntime; @@ -139,6 +141,7 @@ use tracing::warn; pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, + turn_extension_data: Arc, input: Vec, prewarmed_client_session: Option, cancellation_token: CancellationToken, @@ -366,6 +369,7 @@ pub(crate) async fn run_turn( let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. + #[allow(deprecated)] let display_root = get_git_repo_root(turn_context.cwd.as_path()) .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()); let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( @@ -453,6 +457,7 @@ pub(crate) async fn run_turn( match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), + Arc::clone(&turn_extension_data), Arc::clone(&turn_diff_tracker), &mut client_session, turn_metadata_header.as_deref(), @@ -524,6 +529,7 @@ pub(crate) async fn run_turn( let stop_request = codex_hooks::StopRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -573,6 +579,7 @@ pub(crate) async fn run_turn( .hooks() .dispatch(HookPayload { session_id: sess.session_id().into(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), @@ -697,6 +704,7 @@ async fn track_turn_resolved_config_analytics( model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), permission_profile: turn_context.permission_profile(), + #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), @@ -993,6 +1001,7 @@ pub(crate) fn build_prompt( } #[allow(clippy::too_many_arguments)] +#[allow(deprecated)] #[instrument(level = "trace", skip_all, fields( @@ -1004,6 +1013,7 @@ pub(crate) fn build_prompt( async fn run_sampling_request( sess: Arc, turn_context: Arc, + turn_store: Arc, turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, @@ -1060,6 +1070,7 @@ async fn run_sampling_request( tool_runtime.clone(), Arc::clone(&sess), Arc::clone(&turn_context), + Arc::clone(&turn_store), client_session, turn_metadata_header, Arc::clone(&turn_diff_tracker), @@ -1742,6 +1753,7 @@ async fn emit_turn_item_in_plan_mode( async fn handle_assistant_item_done_in_plan_mode( sess: &Session, turn_context: &TurnContext, + turn_store: &codex_extension_api::ExtensionData, item: &ResponseItem, state: &mut PlanModeStreamState, previously_active_item: Option<&TurnItem>, @@ -1752,21 +1764,38 @@ async fn handle_assistant_item_done_in_plan_mode( { maybe_complete_plan_item_from_message(sess, turn_context, state, item).await; - if let Some(turn_item) = - handle_non_tool_response_item(sess, turn_context, item, /*plan_mode*/ true).await + let mut finalized_facts = None; + if let Some(finalized_turn_item) = finalize_non_tool_response_item( + sess, + turn_context, + TurnItemContributorPolicy::Run(turn_store), + item, + /*plan_mode*/ true, + ) + .await { + finalized_facts = Some(finalized_turn_item.facts.clone()); emit_turn_item_in_plan_mode( sess, turn_context, - turn_item, + finalized_turn_item.turn_item, previously_active_item, state, ) .await; } + let final_last_agent_message = finalized_facts + .as_ref() + .and_then(|facts| facts.last_agent_message.clone()); - record_completed_response_item(sess, turn_context, item).await; - if let Some(agent_message) = last_assistant_message_from_item(item, /*plan_mode*/ true) { + record_completed_response_item_with_finalized_facts( + sess, + turn_context, + item, + finalized_facts.as_ref(), + ) + .await; + if let Some(agent_message) = final_last_agent_message { *last_agent_message = Some(agent_message); } return true; @@ -1812,6 +1841,7 @@ async fn try_run_sampling_request( tool_runtime: ToolCallRuntime, sess: Arc, turn_context: Arc, + turn_store: Arc, client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, turn_diff_tracker: SharedTurnDiffTracker, @@ -1860,6 +1890,9 @@ async fn try_run_sampling_request( let plan_mode = turn_context.collaboration_mode.mode == ModeKind::Plan; let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); + let defer_streamed_turn_items_for_contributors = + !sess.services.extensions.turn_item_contributors().is_empty(); + let mut active_item_is_streaming_to_client = false; let receiving_span = trace_span!("receiving_stream"); let mut completed_response_id: Option = None; let outcome: CodexResult = loop { @@ -1912,7 +1945,13 @@ async fn try_run_sampling_request( sess.send_event(&turn_context, event).await; } let previously_active_item = active_item.take(); - if let Some(previous) = previously_active_item.as_ref() + let previously_streamed_item = if active_item_is_streaming_to_client { + previously_active_item + } else { + None + }; + active_item_is_streaming_to_client = false; + if let Some(previous) = previously_streamed_item.as_ref() && matches!(previous, TurnItem::AgentMessage(_)) { let item_id = previous.id(); @@ -1929,9 +1968,10 @@ async fn try_run_sampling_request( && handle_assistant_item_done_in_plan_mode( &sess, &turn_context, + turn_store.as_ref(), &item, state, - previously_active_item.as_ref(), + previously_streamed_item.as_ref(), &mut last_agent_message, ) .await @@ -1942,6 +1982,7 @@ async fn try_run_sampling_request( let mut ctx = HandleOutputCtx { sess: sess.clone(), turn_context: turn_context.clone(), + turn_store: Arc::clone(&turn_store), tool_runtime: tool_runtime.clone(), cancellation_token: cancellation_token.child_token(), }; @@ -1966,7 +2007,7 @@ async fn try_run_sampling_request( }; let output_result = - match handle_output_item_done(&mut ctx, item, previously_active_item) + match handle_output_item_done(&mut ctx, item, previously_streamed_item) .instrument(handle_responses) .await { @@ -2000,15 +2041,18 @@ async fn try_run_sampling_request( if let Some(turn_item) = handle_non_tool_response_item( sess.as_ref(), turn_context.as_ref(), + TurnItemContributorPolicy::Skip, &item, plan_mode, ) .await { let mut turn_item = turn_item; + let stream_item_to_client = !defer_streamed_turn_items_for_contributors; let mut seeded_parsed: Option = None; let mut seeded_item_id: Option = None; - if matches!(turn_item, TurnItem::AgentMessage(_)) + if stream_item_to_client + && matches!(turn_item, TurnItem::AgentMessage(_)) && let Some(raw_text) = raw_assistant_output_text_from_item(&item) { let item_id = turn_item.id(); @@ -2027,31 +2071,34 @@ async fn try_run_sampling_request( seeded_parsed = plan_mode.then_some(seeded); seeded_item_id = Some(item_id); } - if let Some(state) = plan_mode_state.as_mut() - && matches!(turn_item, TurnItem::AgentMessage(_)) - { - let item_id = turn_item.id(); - state - .pending_agent_message_items - .insert(item_id, turn_item.clone()); - } else { - sess.emit_turn_item_started(&turn_context, &turn_item).await; - } - if let (Some(state), Some(item_id), Some(parsed)) = ( - plan_mode_state.as_mut(), - seeded_item_id.as_deref(), - seeded_parsed, - ) { - emit_streamed_assistant_text_delta( - &sess, - &turn_context, - Some(state), - item_id, - parsed, - ) - .await; + if stream_item_to_client { + if let Some(state) = plan_mode_state.as_mut() + && matches!(turn_item, TurnItem::AgentMessage(_)) + { + let item_id = turn_item.id(); + state + .pending_agent_message_items + .insert(item_id, turn_item.clone()); + } else { + sess.emit_turn_item_started(&turn_context, &turn_item).await; + } + if let (Some(state), Some(item_id), Some(parsed)) = ( + plan_mode_state.as_mut(), + seeded_item_id.as_deref(), + seeded_parsed, + ) { + emit_streamed_assistant_text_delta( + &sess, + &turn_context, + Some(state), + item_id, + parsed, + ) + .await; + } } active_item = Some(turn_item); + active_item_is_streaming_to_client = stream_item_to_client; } } ResponseEvent::ServerModel(server_model) => { @@ -2118,6 +2165,9 @@ async fn try_run_sampling_request( // In review child threads, suppress assistant text deltas; the // UI will show a selection popup from the final ReviewOutput. if let Some(active) = active_item.as_ref() { + if !active_item_is_streaming_to_client { + continue; + } let item_id = active.id(); if matches!(active, TurnItem::AgentMessage(_)) { let parsed = assistant_message_stream_parsers.parse_delta(&item_id, &delta); @@ -2166,6 +2216,9 @@ async fn try_run_sampling_request( summary_index, } => { if let Some(active) = active_item.as_ref() { + if !active_item_is_streaming_to_client { + continue; + } let event = ReasoningContentDeltaEvent { thread_id: sess.conversation_id.to_string(), turn_id: turn_context.sub_id.clone(), @@ -2181,6 +2234,9 @@ async fn try_run_sampling_request( } ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { if let Some(active) = active_item.as_ref() { + if !active_item_is_streaming_to_client { + continue; + } let event = EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { item_id: active.id(), @@ -2196,6 +2252,9 @@ async fn try_run_sampling_request( content_index, } => { if let Some(active) = active_item.as_ref() { + if !active_item_is_streaming_to_client { + continue; + } let event = ReasoningRawContentDeltaEvent { thread_id: sess.conversation_id.to_string(), turn_id: turn_context.sub_id.clone(), @@ -2265,3 +2324,7 @@ pub(crate) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) - } None } + +#[cfg(test)] +#[path = "turn_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 12a1ccb79d..c646b5833d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -69,6 +69,7 @@ pub struct TurnContext { /// The session's absolute working directory. All relative paths provided /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. + #[deprecated(note = "use the selected turn environment cwd instead")] pub(crate) cwd: AbsolutePathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, @@ -118,6 +119,7 @@ impl TurnContext { &self.permission_profile, &file_system_sandbox_policy, network_sandbox_policy, + #[allow(deprecated)] &self.cwd, ) } @@ -218,6 +220,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session( config @@ -231,6 +234,18 @@ impl TurnContext { .enabled(Feature::MultiAgentV2) .then_some(config.multi_agent_v2.min_wait_timeout_ms), ) + .with_wait_agent_max_timeout_ms( + config + .features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.max_wait_timeout_ms), + ) + .with_wait_agent_default_timeout_ms( + config + .features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.default_wait_timeout_ms), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -252,6 +267,7 @@ impl TurnContext { session_source: self.session_source.clone(), thread_source: self.thread_source, environments: self.environments.clone(), + #[allow(deprecated)] cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -287,7 +303,9 @@ impl TurnContext { } } + #[deprecated(note = "resolve paths from the selected turn environment cwd instead")] pub(crate) fn resolve_path(&self, path: Option) -> AbsolutePathBuf { + #[allow(deprecated)] path.as_ref() .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } @@ -295,6 +313,7 @@ impl TurnContext { pub(crate) fn file_system_sandbox_context( &self, additional_permissions: Option, + cwd: &AbsolutePathBuf, ) -> FileSystemSandboxContext { let (base_file_system_sandbox_policy, base_network_sandbox_policy) = self.permission_profile.to_runtime_permissions(); @@ -313,7 +332,7 @@ impl TurnContext { ); FileSystemSandboxContext { permissions, - cwd: Some(self.cwd.clone()), + cwd: Some(cwd.clone()), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config @@ -331,6 +350,7 @@ impl TurnContext { let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &self.sandbox_policy(), + #[allow(deprecated)] &self.cwd, ); let file_system_sandbox_policy = self.file_system_sandbox_policy(); @@ -348,6 +368,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), + #[allow(deprecated)] cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -512,6 +533,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session( per_turn_config @@ -529,6 +551,18 @@ impl Session { .enabled(Feature::MultiAgentV2) .then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms), ) + .with_wait_agent_max_timeout_ms( + per_turn_config + .features + .enabled(Feature::MultiAgentV2) + .then_some(per_turn_config.multi_agent_v2.max_wait_timeout_ms), + ) + .with_wait_agent_default_timeout_ms( + per_turn_config + .features + .enabled(Feature::MultiAgentV2) + .then_some(per_turn_config.multi_agent_v2.default_wait_timeout_ms), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &per_turn_config.agent_roles, )); @@ -564,6 +598,7 @@ impl Session { session_source, thread_source: session_configuration.thread_source, environments, + #[allow(deprecated)] cwd, current_date: Some(current_date), timezone: Some(timezone), diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs new file mode 100644 index 0000000000..f1cf86b46c --- /dev/null +++ b/codex-rs/core/src/session/turn_tests.rs @@ -0,0 +1,67 @@ +use super::*; +use codex_extension_api::ExtensionData; +use codex_extension_api::TurnItemContributionFuture; +use codex_extension_api::TurnItemContributor; +use codex_protocol::items::AgentMessageContent; +use pretty_assertions::assert_eq; +use std::sync::Arc; + +struct RewriteAgentMessageContributor; + +impl TurnItemContributor for RewriteAgentMessageContributor { + fn contribute<'a>( + &'a self, + _thread_store: &'a ExtensionData, + _turn_store: &'a ExtensionData, + item: &'a mut TurnItem, + ) -> TurnItemContributionFuture<'a> { + Box::pin(async move { + if let TurnItem::AgentMessage(agent_message) = item { + agent_message.content = vec![AgentMessageContent::Text { + text: "plan contributed assistant text".to_string(), + }]; + } + Ok(()) + }) + } +} + +fn assistant_output_text(text: &str) -> ResponseItem { + ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: text.to_string(), + }], + phase: None, + } +} + +#[tokio::test] +async fn plan_mode_uses_contributed_turn_item_for_last_agent_message() { + let (mut session, turn_context) = crate::session::tests::make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RewriteAgentMessageContributor)); + session.services.extensions = Arc::new(builder.build()); + let turn_store = ExtensionData::new(turn_context.sub_id.clone()); + let mut state = PlanModeStreamState::new(&turn_context.sub_id); + let mut last_agent_message = None; + let item = assistant_output_text("original assistant text"); + + let handled = handle_assistant_item_done_in_plan_mode( + &session, + &turn_context, + &turn_store, + &item, + &mut state, + /*previously_active_item*/ None, + &mut last_agent_message, + ) + .await; + + assert!(handled); + assert_eq!( + last_agent_message.as_deref(), + Some("plan contributed assistant text") + ); +} diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 0dd0c09379..70aae74e87 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -9,6 +9,7 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; +use codex_extension_api::ExtensionData; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -75,6 +76,7 @@ pub(crate) struct RunningTask { pub(crate) cancellation_token: CancellationToken, pub(crate) handle: AbortOnDropHandle<()>, pub(crate) turn_context: Arc, + pub(crate) turn_extension_data: Arc, // Timer recorded when the task drops to capture the full turn duration. pub(crate) _timer: Option, } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index b4f00ee864..bc44fe0c3b 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_extension_api::ExtensionData; use codex_protocol::config_types::ModeKind; use codex_protocol::items::TurnItem; use codex_utils_stream_parser::strip_citations; @@ -20,6 +21,7 @@ use codex_memories_read::citations::parse_memory_citation; use codex_memories_read::citations::thread_ids_from_memory_citation; use codex_protocol::error::CodexErr; use codex_protocol::error::Result; +use codex_protocol::memory_citation::MemoryCitation; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::MessagePhase; @@ -31,6 +33,7 @@ use codex_utils_stream_parser::strip_proposed_plan_blocks; use futures::Future; use tracing::debug; use tracing::instrument; +use tracing::warn; const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; @@ -127,22 +130,50 @@ pub(crate) async fn record_completed_response_item( sess: &Session, turn_context: &TurnContext, item: &ResponseItem, +) { + record_completed_response_item_with_finalized_facts( + sess, + turn_context, + item, + /*finalized_facts*/ None, + ) + .await; +} + +pub(crate) async fn record_completed_response_item_with_finalized_facts( + sess: &Session, + turn_context: &TurnContext, + item: &ResponseItem, + finalized_facts: Option<&FinalizedTurnItemFacts>, ) { sess.record_conversation_items(turn_context, std::slice::from_ref(item)) .await; - if completed_item_defers_mailbox_delivery_to_next_turn( - item, - turn_context.collaboration_mode.mode == ModeKind::Plan, - ) { + let defers_mailbox_delivery = finalized_facts.map_or_else( + || { + completed_item_defers_mailbox_delivery_to_next_turn( + item, + turn_context.collaboration_mode.mode == ModeKind::Plan, + ) + }, + |facts| facts.defers_mailbox_delivery_to_next_turn, + ); + if defers_mailbox_delivery { sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id) .await; } mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await; - let has_memory_citation = record_stage1_output_usage_and_detect_memory_citation( - sess.services.state_db.as_ref(), - item, - ) - .await; + let has_memory_citation = if let Some(memory_citation) = + finalized_facts.and_then(|facts| facts.memory_citation.as_ref()) + { + record_stage1_output_usage_for_memory_citation( + sess.services.state_db.as_ref(), + memory_citation, + ) + .await + } else { + record_stage1_output_usage_and_detect_memory_citation(sess.services.state_db.as_ref(), item) + .await + }; if has_memory_citation { sess.record_memory_citation_for_turn(&turn_context.sub_id) .await; @@ -188,7 +219,14 @@ async fn record_stage1_output_usage_and_detect_memory_citation( let Some(memory_citation) = parse_memory_citation(citations) else { return false; }; - let thread_ids = thread_ids_from_memory_citation(&memory_citation); + record_stage1_output_usage_for_memory_citation(state_db_ctx, &memory_citation).await +} + +async fn record_stage1_output_usage_for_memory_citation( + state_db_ctx: Option<&state_db::StateDbHandle>, + memory_citation: &MemoryCitation, +) -> bool { + let thread_ids = thread_ids_from_memory_citation(memory_citation); if thread_ids.is_empty() { return true; } @@ -215,10 +253,91 @@ pub(crate) struct OutputItemResult { pub(crate) struct HandleOutputCtx { pub sess: Arc, pub turn_context: Arc, + pub turn_store: Arc, pub tool_runtime: ToolCallRuntime, pub cancellation_token: CancellationToken, } +async fn apply_turn_item_contributors( + sess: &Session, + turn_store: &ExtensionData, + item: &mut TurnItem, +) { + let contributors = sess.services.extensions.turn_item_contributors().to_vec(); + for contributor in contributors { + if let Err(err) = contributor + .contribute(&sess.services.thread_extension_data, turn_store, item) + .await + { + warn!("turn item contributor failed: {err}"); + } + } +} + +pub(crate) enum TurnItemContributorPolicy<'a> { + Skip, + Run(&'a ExtensionData), +} + +pub(crate) struct FinalizedTurnItem { + pub(crate) turn_item: TurnItem, + pub(crate) facts: FinalizedTurnItemFacts, +} + +#[derive(Clone, Default)] +pub(crate) struct FinalizedTurnItemFacts { + pub(crate) memory_citation: Option, + pub(crate) last_agent_message: Option, + pub(crate) defers_mailbox_delivery_to_next_turn: bool, +} + +pub(crate) async fn finalize_non_tool_response_item( + sess: &Session, + turn_context: &TurnContext, + contributor_policy: TurnItemContributorPolicy<'_>, + item: &ResponseItem, + plan_mode: bool, +) -> Option { + let turn_item = + handle_non_tool_response_item(sess, turn_context, contributor_policy, item, plan_mode) + .await?; + let (memory_citation, last_agent_message, defers_mailbox_delivery_to_next_turn) = + match &turn_item { + TurnItem::AgentMessage(agent_message) => { + let combined = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + let last_agent_message = if combined.trim().is_empty() { + None + } else { + Some(combined) + }; + let defers_mailbox_delivery_to_next_turn = + !matches!(agent_message.phase, Some(MessagePhase::Commentary)) + && last_agent_message.is_some(); + ( + agent_message.memory_citation.clone(), + last_agent_message, + defers_mailbox_delivery_to_next_turn, + ) + } + TurnItem::ImageGeneration(_) => (None, None, true), + _ => (None, None, false), + }; + Some(FinalizedTurnItem { + turn_item, + facts: FinalizedTurnItemFacts { + memory_citation, + last_agent_message, + defers_mailbox_delivery_to_next_turn, + }, + }) +} + #[instrument(level = "trace", skip_all)] pub(crate) async fn handle_output_item_done( ctx: &mut HandleOutputCtx, @@ -258,16 +377,20 @@ pub(crate) async fn handle_output_item_done( } // No tool call: convert messages/reasoning into turn items and mark them as complete. Ok(None) => { - let turn_item = handle_non_tool_response_item( + let finalized_turn_item = finalize_non_tool_response_item( ctx.sess.as_ref(), ctx.turn_context.as_ref(), + TurnItemContributorPolicy::Run(ctx.turn_store.as_ref()), &item, plan_mode, ) .await; - if let Some(turn_item) = turn_item { + let finalized_facts = finalized_turn_item + .as_ref() + .map(|finalized| finalized.facts.clone()); + if let Some(finalized_turn_item) = finalized_turn_item { if previously_active_item.is_none() { - let mut started_item = turn_item.clone(); + let mut started_item = finalized_turn_item.turn_item.clone(); if let TurnItem::ImageGeneration(item) = &mut started_item { item.status = "in_progress".to_string(); item.revised_prompt = None; @@ -280,42 +403,18 @@ pub(crate) async fn handle_output_item_done( } ctx.sess - .emit_turn_item_completed(&ctx.turn_context, turn_item) + .emit_turn_item_completed(&ctx.turn_context, finalized_turn_item.turn_item) .await; } - record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) - .await; - let last_agent_message = last_assistant_message_from_item(&item, plan_mode); + record_completed_response_item_with_finalized_facts( + ctx.sess.as_ref(), + ctx.turn_context.as_ref(), + &item, + finalized_facts.as_ref(), + ) + .await; - output.last_agent_message = last_agent_message; - } - // Guardrail: the model issued a LocalShellCall without an id; surface the error back into history. - Err(FunctionCallError::MissingLocalShellCallId) => { - let msg = "LocalShellCall without call_id or id"; - ctx.turn_context - .session_telemetry - .log_tool_failed("local_shell", msg); - tracing::error!(msg); - - let response = ResponseInputItem::FunctionCallOutput { - call_id: String::new(), - output: FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text(msg.to_string()), - ..Default::default() - }, - }; - record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) - .await; - if let Some(response_item) = response_input_to_response_item(&response) { - ctx.sess - .record_conversation_items( - &ctx.turn_context, - std::slice::from_ref(&response_item), - ) - .await; - } - - output.needs_follow_up = true; + output.last_agent_message = finalized_facts.and_then(|facts| facts.last_agent_message); } // The tool request should be answered directly (or was denied); push that response into the transcript. Err(FunctionCallError::RespondToModel(message)) => { @@ -351,6 +450,7 @@ pub(crate) async fn handle_output_item_done( pub(crate) async fn handle_non_tool_response_item( sess: &Session, turn_context: &TurnContext, + contributor_policy: TurnItemContributorPolicy<'_>, item: &ResponseItem, plan_mode: bool, ) -> Option { @@ -362,6 +462,9 @@ pub(crate) async fn handle_non_tool_response_item( | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } => { let mut turn_item = parse_turn_item(item)?; + if let TurnItemContributorPolicy::Run(turn_store) = contributor_policy { + apply_turn_item_contributors(sess, turn_store, &mut turn_item).await; + } if let TurnItem::AgentMessage(agent_message) = &mut turn_item { let combined = agent_message .content @@ -374,7 +477,9 @@ pub(crate) async fn handle_non_tool_response_item( strip_hidden_assistant_markup_and_parse_memory_citation(&combined, plan_mode); agent_message.content = vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }]; - agent_message.memory_citation = memory_citation; + if agent_message.memory_citation.is_none() { + agent_message.memory_citation = memory_citation; + } } if let TurnItem::ImageGeneration(image_item) = &mut turn_item { let session_id = sess.conversation_id.to_string(); diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 2012e05aa3..2d095906ed 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -1,12 +1,24 @@ +use super::HandleOutputCtx; +use super::TurnItemContributorPolicy; use super::completed_item_defers_mailbox_delivery_to_next_turn; +use super::finalize_non_tool_response_item; use super::handle_non_tool_response_item; +use super::handle_output_item_done; use super::image_generation_artifact_path; use super::last_assistant_message_from_item; use super::response_item_may_include_external_context; use super::save_image_generation_result; use crate::session::tests::make_session_and_context; +use crate::tools::ToolRouter; +use crate::tools::parallel::ToolCallRuntime; +use crate::turn_diff_tracker::TurnDiffTracker; +use codex_extension_api::ExtensionData; +use codex_extension_api::TurnItemContributionFuture; +use codex_extension_api::TurnItemContributor; use codex_protocol::error::CodexErr; +use codex_protocol::items::AgentMessageContent; use codex_protocol::items::TurnItem; +use codex_protocol::memory_citation::MemoryCitation; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::LocalShellAction; @@ -16,6 +28,8 @@ use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; fn assistant_output_text(text: &str) -> ResponseItem { assistant_output_text_with_phase(text, /*phase*/ None) @@ -117,10 +131,15 @@ async fn handle_non_tool_response_item_strips_citations_from_assistant_message() "hello\nMEMORY.md:1-2|note=[x]\n\n\n019cc2ea-1dff-7902-8d40-c8f6e5d83cc4\n world", ); - let turn_item = - handle_non_tool_response_item(&session, &turn_context, &item, /*plan_mode*/ false) - .await - .expect("assistant message should parse"); + let turn_item = handle_non_tool_response_item( + &session, + &turn_context, + TurnItemContributorPolicy::Skip, + &item, + /*plan_mode*/ false, + ) + .await + .expect("assistant message should parse"); let TurnItem::AgentMessage(agent_message) = turn_item else { panic!("expected agent message"); @@ -144,6 +163,199 @@ async fn handle_non_tool_response_item_strips_citations_from_assistant_message() ); } +struct TestTurnItemContributor; + +#[derive(Debug)] +struct TurnItemContributorRan; + +impl TurnItemContributor for TestTurnItemContributor { + fn contribute<'a>( + &'a self, + _thread_store: &'a ExtensionData, + turn_store: &'a ExtensionData, + item: &'a mut TurnItem, + ) -> TurnItemContributionFuture<'a> { + Box::pin(async move { + turn_store.insert(TurnItemContributorRan); + if let TurnItem::AgentMessage(agent_message) = item { + agent_message.memory_citation = Some(MemoryCitation { + entries: Vec::new(), + rollout_ids: Vec::new(), + }); + } + Ok(()) + }) + } +} + +struct RewriteAgentMessageContributor; + +impl TurnItemContributor for RewriteAgentMessageContributor { + fn contribute<'a>( + &'a self, + _thread_store: &'a ExtensionData, + _turn_store: &'a ExtensionData, + item: &'a mut TurnItem, + ) -> TurnItemContributionFuture<'a> { + Box::pin(async move { + if let TurnItem::AgentMessage(agent_message) = item { + agent_message.content = vec![AgentMessageContent::Text { + text: "contributed assistant text".to_string(), + }]; + } + Ok(()) + }) + } +} + +#[tokio::test] +async fn handle_non_tool_response_item_runs_turn_item_contributors_only_when_requested() { + let (mut session, turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(TestTurnItemContributor)); + session.services.extensions = Arc::new(builder.build()); + let turn_store = ExtensionData::new(turn_context.sub_id.clone()); + let item = assistant_output_text( + "helloignored by memory parser world", + ); + + let provisional_turn_item = handle_non_tool_response_item( + &session, + &turn_context, + TurnItemContributorPolicy::Skip, + &item, + /*plan_mode*/ false, + ) + .await + .expect("assistant message should parse"); + + assert!(turn_store.get::().is_none()); + let TurnItem::AgentMessage(provisional_agent_message) = provisional_turn_item else { + panic!("expected agent message"); + }; + assert_eq!(provisional_agent_message.memory_citation, None); + + let turn_item = handle_non_tool_response_item( + &session, + &turn_context, + TurnItemContributorPolicy::Run(&turn_store), + &item, + /*plan_mode*/ false, + ) + .await + .expect("assistant message should parse"); + + assert!(turn_store.get::().is_some()); + let TurnItem::AgentMessage(agent_message) = turn_item else { + panic!("expected agent message"); + }; + assert!(agent_message.memory_citation.is_some()); + let text = agent_message + .content + .iter() + .map(|entry| match entry { + codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(), + }) + .collect::(); + assert_eq!(text, "hello world"); +} + +#[tokio::test] +async fn handle_output_item_done_returns_contributed_last_agent_message() { + let (mut session, turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RewriteAgentMessageContributor)); + session.services.extensions = Arc::new(builder.build()); + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let router = Arc::new(ToolRouter::from_config( + &turn_context.tools_config, + crate::tools::router::ToolRouterParams { + mcp_tools: None, + deferred_mcp_tools: None, + discoverable_tools: None, + extension_tool_executors: Vec::new(), + dynamic_tools: turn_context.dynamic_tools.as_slice(), + }, + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let tool_runtime = ToolCallRuntime::new( + router, + Arc::clone(&session), + Arc::clone(&turn_context), + tracker, + ); + let item = assistant_output_text("original assistant text"); + let mut ctx = HandleOutputCtx { + sess: session, + turn_context: Arc::clone(&turn_context), + turn_store: Arc::new(ExtensionData::new(turn_context.sub_id.clone())), + tool_runtime, + cancellation_token: CancellationToken::new(), + }; + + let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None) + .await + .expect("assistant message should complete"); + + assert_eq!( + output.last_agent_message.as_deref(), + Some("contributed assistant text") + ); +} + +#[tokio::test] +async fn finalized_turn_item_defers_mailbox_for_contributed_visible_text() { + let (mut session, turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RewriteAgentMessageContributor)); + session.services.extensions = Arc::new(builder.build()); + let turn_store = ExtensionData::new(turn_context.sub_id.clone()); + let item = assistant_output_text("hidden only"); + + let finalized = finalize_non_tool_response_item( + &session, + &turn_context, + TurnItemContributorPolicy::Run(&turn_store), + &item, + /*plan_mode*/ false, + ) + .await + .expect("assistant message should parse"); + + assert_eq!( + finalized.facts.last_agent_message.as_deref(), + Some("contributed assistant text") + ); + assert!(finalized.facts.defers_mailbox_delivery_to_next_turn); +} + +#[tokio::test] +async fn finalized_turn_item_keeps_mailbox_open_for_commentary_text() { + let (mut session, turn_context) = make_session_and_context().await; + let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); + builder.turn_item_contributor(Arc::new(RewriteAgentMessageContributor)); + session.services.extensions = Arc::new(builder.build()); + let turn_store = ExtensionData::new(turn_context.sub_id.clone()); + let item = assistant_output_text_with_phase("still working", Some(MessagePhase::Commentary)); + + let finalized = finalize_non_tool_response_item( + &session, + &turn_context, + TurnItemContributorPolicy::Run(&turn_store), + &item, + /*plan_mode*/ false, + ) + .await + .expect("assistant message should parse"); + + assert_eq!( + finalized.facts.last_agent_message.as_deref(), + Some("contributed assistant text") + ); + assert!(!finalized.facts.defers_mailbox_delivery_to_next_turn); +} + #[test] fn last_assistant_message_from_item_strips_citations_and_plan_blocks() { let item = assistant_output_text( diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 6da6d9ad9b..58ead0fd25 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::Duration; use std::time::Instant; +use codex_extension_api::ExtensionData; use futures::future::BoxFuture; use tokio::select; use tokio::sync::Notify; @@ -153,17 +154,25 @@ fn bool_tag(value: bool) -> &'static str { #[derive(Clone)] pub(crate) struct SessionTaskContext { session: Arc, + turn_extension_data: Arc, } impl SessionTaskContext { - pub(crate) fn new(session: Arc) -> Self { - Self { session } + pub(crate) fn new(session: Arc, turn_extension_data: Arc) -> Self { + Self { + session, + turn_extension_data, + } } pub(crate) fn clone_session(&self) -> Arc { Arc::clone(&self.session) } + pub(crate) fn turn_extension_data(&self) -> Arc { + Arc::clone(&self.turn_extension_data) + } + pub(crate) fn auth_manager(&self) -> Arc { Arc::clone(&self.session.services.auth_manager) } @@ -358,11 +367,15 @@ impl Session { } self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()); + let turn_extension_data = Arc::clone(&turn_context.extension_data); let mut active = self.active_turn.lock().await; let turn = active.get_or_insert_with(ActiveTurn::default); debug_assert!(turn.tasks.is_empty()); let done_clone = Arc::clone(&done); - let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); + let session_ctx = Arc::new(SessionTaskContext::new( + Arc::clone(self), + Arc::clone(&turn_extension_data), + )); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); let task_cancellation_token = cancellation_token.child_token(); @@ -427,6 +440,7 @@ impl Session { task, cancellation_token, turn_context: Arc::clone(&turn_context), + turn_extension_data, _timer: timer, }; turn.add_task(running_task); @@ -829,7 +843,10 @@ impl Session { task.handle.abort(); - let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); + let session_ctx = Arc::new(SessionTaskContext::new( + Arc::clone(self), + Arc::clone(&task.turn_extension_data), + )); session_task .abort(session_ctx, Arc::clone(&task.turn_context)) .await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 08c4933488..756a691f11 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -45,6 +45,7 @@ impl SessionTask for RegularTask { cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); + let turn_extension_data = session.turn_extension_data(); let run_turn_span = trace_span!("run_turn"); // Regular turns emit `TurnStarted` inline so first-turn lifecycle does // not wait on startup prewarm resolution. @@ -72,6 +73,7 @@ impl SessionTask for RegularTask { let last_agent_message = run_turn( Arc::clone(&sess), Arc::clone(&ctx), + Arc::clone(&turn_extension_data), next_input, prewarmed_client_session.take(), cancellation_token.child_token(), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 683856b90e..23f3882eda 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -148,6 +148,7 @@ pub(crate) async fn execute_user_shell_command( let exec_command = maybe_wrap_shell_lc_with_snapshot( &display_command, session_shell.as_ref(), + #[allow(deprecated)] &turn_context.cwd, &turn_context.shell_environment_policy.r#set, &exec_env_map, @@ -155,6 +156,7 @@ pub(crate) async fn execute_user_shell_command( let call_id = Uuid::new_v4().to_string(); let raw_command = command; + #[allow(deprecated)] let cwd = turn_context.cwd.clone(); let parsed_cmd = parse_command(&display_command); diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index eaa4150201..26ef021ad2 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -87,6 +87,7 @@ impl CodeModeExecuteHandler { } } +#[async_trait::async_trait] impl ToolExecutor for CodeModeExecuteHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 070b9270d5..4f828c8910 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -41,6 +41,7 @@ where }) } +#[async_trait::async_trait] impl ToolExecutor for CodeModeWaitHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs index 7797c8c61b..e08eb4b5fb 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/report_agent_job_result.rs @@ -12,6 +12,7 @@ use super::*; pub struct ReportAgentJobResultHandler; +#[async_trait::async_trait] impl ToolExecutor for ReportAgentJobResultHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 6dd7ea2b31..a384f2d8cc 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -7,11 +7,13 @@ use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_utils_absolute_path::AbsolutePathBuf; use super::*; pub struct SpawnAgentsOnCsvHandler; +#[async_trait::async_trait] impl ToolExecutor for SpawnAgentsOnCsvHandler { type Output = FunctionToolOutput; @@ -67,8 +69,9 @@ pub async fn handle( )); } + let cwd = single_local_environment_cwd(&turn)?; let db = required_state_db(&session)?; - let input_path = turn.resolve_path(Some(args.csv_path)); + let input_path = cwd.join(args.csv_path); let input_path_display = input_path.display().to_string(); let csv_content = tokio::fs::read_to_string(&input_path) .await @@ -141,7 +144,7 @@ pub async fn handle( let job_id = Uuid::new_v4().to_string(); let output_csv_path = args.output_csv_path.map_or_else( || default_output_csv_path(&input_path, job_id.as_str()), - |path| turn.resolve_path(Some(path)), + |path| cwd.join(path), ); let job_suffix = &job_id[..8]; let job_name = format!("agent-job-{job_suffix}"); @@ -286,3 +289,19 @@ pub async fn handle( })?; Ok(FunctionToolOutput::from_text(content, Some(true))) } + +fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { + let [turn_environment] = turn.environments.turn_environments.as_slice() else { + return Err(FunctionCallError::RespondToModel( + "spawn_agents_on_csv requires exactly one local environment".to_string(), + )); + }; + + if turn_environment.environment.is_remote() { + return Err(FunctionCallError::RespondToModel( + "spawn_agents_on_csv is not supported for remote environments".to_string(), + )); + } + + Ok(&turn_environment.cwd) +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 32edd21da1..c8e2461e03 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -297,6 +297,7 @@ async fn effective_patch_permissions( ) } +#[async_trait::async_trait] impl ToolExecutor for ApplyPatchHandler { type Output = ApplyPatchToolOutput; @@ -345,8 +346,7 @@ impl ToolExecutor for ApplyPatchHandler { }; let cwd = turn_environment.cwd.clone(); let fs = turn_environment.environment.get_filesystem(); - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await { @@ -499,8 +499,7 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 3f3e72e2f3..29dc5199e3 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolHandler; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::turn_timing::now_unix_timestamp_ms; @@ -30,6 +31,7 @@ use tracing::warn; pub struct DynamicToolHandler { tool_name: ToolName, spec: Option, + exposure: ToolExposure, search_text: String, } @@ -48,11 +50,17 @@ impl DynamicToolHandler { Some(Self { tool_name, spec: Some(spec), + exposure: if tool.defer_loading { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }, search_text: build_dynamic_search_text(tool), }) } } +#[async_trait::async_trait] impl ToolExecutor for DynamicToolHandler { type Output = FunctionToolOutput; @@ -64,6 +72,10 @@ impl ToolExecutor for DynamicToolHandler { self.spec.clone() } + fn exposure(&self) -> ToolExposure { + self.exposure + } + async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { session, diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index cd94d88698..c4018fe936 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -35,6 +35,7 @@ impl ExtensionToolHandler { } } +#[async_trait::async_trait] impl ToolExecutor for ExtensionToolHandler { type Output = ExtensionToolOutput; @@ -115,7 +116,10 @@ mod tests { struct StubExtensionExecutor; - impl codex_extension_api::ExtensionToolExecutor for StubExtensionExecutor { + #[async_trait::async_trait] + impl codex_extension_api::ToolExecutor for StubExtensionExecutor { + type Output = codex_tools::JsonToolOutput; + fn tool_name(&self) -> codex_tools::ToolName { codex_tools::ToolName::plain("extension_echo") } @@ -141,11 +145,11 @@ mod tests { )) } - fn handle( + async fn handle( &self, _call: codex_tools::ToolCall, - ) -> codex_extension_api::ExtensionToolFuture<'_> { - Box::pin(async { Ok(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) }) + ) -> Result { + Ok(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) } } diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs index 34a4c9307c..f7575e1e52 100644 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/create_goal.rs @@ -18,6 +18,7 @@ use super::goal_response; pub struct CreateGoalHandler; +#[async_trait::async_trait] impl ToolExecutor for CreateGoalHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs index 3c0813329e..20691b8b5b 100644 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/get_goal.rs @@ -15,6 +15,7 @@ use super::goal_response; pub struct GetGoalHandler; +#[async_trait::async_trait] impl ToolExecutor for GetGoalHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs index 18b59c1b36..bb8ed10efd 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -20,6 +20,7 @@ use super::goal_response; pub struct UpdateGoalHandler; +#[async_trait::async_trait] impl ToolExecutor for UpdateGoalHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 2cd02e8a0a..f82f741346 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -13,6 +13,7 @@ use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolTelemetryTags; use crate::tools::tool_search_entry::ToolSearchInfo; @@ -28,14 +29,23 @@ use serde_json::Value; pub struct McpHandler { tool_info: ToolInfo, + exposure: ToolExposure, } impl McpHandler { pub fn new(tool_info: ToolInfo) -> Self { - Self { tool_info } + Self::with_exposure(tool_info, ToolExposure::Direct) + } + + pub fn with_exposure(tool_info: ToolInfo, exposure: ToolExposure) -> Self { + Self { + tool_info, + exposure, + } } } +#[async_trait::async_trait] impl ToolExecutor for McpHandler { type Output = McpToolOutput; @@ -71,6 +81,10 @@ impl ToolExecutor for McpHandler { })) } + fn exposure(&self) -> ToolExposure { + self.exposure + } + fn supports_parallel_tool_calls(&self) -> bool { self.tool_info.supports_parallel_tool_calls } diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs index 0aea2b335e..71749e9cd1 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs @@ -26,6 +26,7 @@ use super::serialize_function_output; pub struct ListMcpResourceTemplatesHandler; +#[async_trait::async_trait] impl ToolExecutor for ListMcpResourceTemplatesHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs index b412f811bb..08b387376b 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs @@ -26,6 +26,7 @@ use super::serialize_function_output; pub struct ListMcpResourcesHandler; +#[async_trait::async_trait] impl ToolExecutor for ListMcpResourcesHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs index 89d061d692..bd8172ac74 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs @@ -26,6 +26,7 @@ use super::serialize_function_output; pub struct ReadMcpResourceHandler; +#[async_trait::async_trait] impl ToolExecutor for ReadMcpResourceHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 712ed0f1a6..69281acc7d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -62,11 +62,8 @@ pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; -pub use shell::ContainerExecHandler; -pub use shell::LocalShellHandler; pub use shell::ShellCommandHandler; pub(crate) use shell::ShellCommandHandlerOptions; -pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; pub use tool_search::ToolSearchHandler; pub use unified_exec::ExecCommandHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index beac3a96f4..6459a899fe 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -5,6 +5,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = CloseAgentResult; @@ -16,11 +17,8 @@ impl ToolExecutor for Handler { Some(create_close_agent_tool_v1()) } - fn handle( - &self, - invocation: ToolInvocation, - ) -> impl std::future::Future> + Send { - Box::pin(handle_close_agent(invocation)) + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_close_agent(invocation).await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index 1e9f5d9b07..9dab2d999d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -7,6 +7,7 @@ use std::sync::Arc; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = ResumeAgentResult; @@ -18,11 +19,8 @@ impl ToolExecutor for Handler { Some(create_resume_agent_tool()) } - fn handle( - &self, - invocation: ToolInvocation, - ) -> impl std::future::Future> + Send { - Box::pin(handle_resume_agent(invocation)) + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_resume_agent(invocation).await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index ac212f7556..a6067e5b12 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -6,6 +6,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = SendInputResult; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 395e74f137..91f5990210 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -22,6 +22,7 @@ impl Handler { } } +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = SpawnAgentResult; @@ -33,11 +34,8 @@ impl ToolExecutor for Handler { Some(create_spawn_agent_tool_v1(self.options.clone())) } - fn handle( - &self, - invocation: ToolInvocation, - ) -> impl std::future::Future> + Send { - Box::pin(handle_spawn_agent(invocation)) + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_spawn_agent(invocation).await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs index aa9d6f6581..d325edeeb0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -27,6 +27,7 @@ impl Handler { } } +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = WaitAgentResult; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index cc4e9ebfca..968810494d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,7 +1,7 @@ use crate::agent::AgentStatus; use crate::config::Config; use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; -use crate::config::MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS; +use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -30,7 +30,7 @@ use std::collections::HashMap; /// Minimum wait timeout to prevent tight polling loops from burning CPU. pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; pub(crate) fn function_arguments(payload: ToolPayload) -> Result { match payload { @@ -268,7 +268,9 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( })?; config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - config.cwd = turn.cwd.clone(); + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); + config.cwd = turn_cwd; config .permissions .set_permission_profile(turn.permission_profile()) 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 f051fb0f70..12c25aa810 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2088,6 +2088,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let expected_sandbox = turn.config.legacy_sandbox_policy(); + #[allow(deprecated)] let mut expected_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&expected_sandbox, &turn.cwd); expected_file_system_sandbox_policy @@ -2774,7 +2775,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -2807,7 +2808,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { +async fn multi_agent_v2_wait_agent_rejects_timeout_below_configured_min() { let (session, mut turn) = make_session_and_context().await; let mut config = (*turn.config).clone(); config @@ -2815,6 +2816,73 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); config.multi_agent_v2.min_wait_timeout_ms = 50; + config.multi_agent_v2.max_wait_timeout_ms = 1_000; + config.multi_agent_v2.default_wait_timeout_ms = 50; + turn.config = Arc::new(config); + + let Err(err) = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )) + .await + else { + panic!("timeout below configured minimum should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("timeout_ms must be at least 50".to_string()) + ); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_min() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.min_wait_timeout_ms = 1; + config.multi_agent_v2.max_wait_timeout_ms = 1_000; + config.multi_agent_v2.default_wait_timeout_ms = 50; + turn.config = Arc::new(config); + + let output = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )) + .await + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait timed out.".to_string(), + timed_out: true, + } + ); + assert_eq!(success, None); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_uses_configured_default_timeout() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.min_wait_timeout_ms = 1; + config.multi_agent_v2.max_wait_timeout_ms = 1_000; + config.multi_agent_v2.default_wait_timeout_ms = 50; turn.config = Arc::new(config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2825,13 +2893,13 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { session.clone(), turn.clone(), "wait_agent", - function_payload(json!({"timeout_ms": 1})), + function_payload(json!({})), )), ) .await; assert!( early.is_err(), - "wait_agent should not return before the configured minimum timeout" + "wait_agent should not return before the configured default timeout" ); let output = timeout( @@ -2840,11 +2908,11 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1})), + function_payload(json!({})), )), ) .await - .expect("configured minimum should be shorter than the test timeout") + .expect("configured default should be shorter than the test timeout") .expect("wait_agent should succeed"); let (content, success) = expect_text_output(output); let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = @@ -2859,6 +2927,111 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_allows_zero_configured_timeout() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.min_wait_timeout_ms = 0; + config.multi_agent_v2.max_wait_timeout_ms = 0; + config.multi_agent_v2.default_wait_timeout_ms = 0; + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + let output = timeout( + Duration::from_secs(/*secs*/ 1), + WaitAgentHandlerV2::default().handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({})), + )), + ) + .await + .expect("zero timeout should complete immediately") + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait timed out.".to_string(), + timed_out: true, + } + ); + assert_eq!(success, None); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_rejects_timeout_above_configured_max() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.min_wait_timeout_ms = 1; + config.multi_agent_v2.max_wait_timeout_ms = 50; + config.multi_agent_v2.default_wait_timeout_ms = 1; + turn.config = Arc::new(config); + + let Err(err) = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 500})), + )) + .await + else { + panic!("timeout above configured maximum should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("timeout_ms must be at most 50".to_string()) + ); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_max() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.min_wait_timeout_ms = 1; + config.multi_agent_v2.max_wait_timeout_ms = 1; + config.multi_agent_v2.default_wait_timeout_ms = 1; + turn.config = Arc::new(config); + + let output = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )) + .await + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait timed out.".to_string(), + timed_out: true, + } + ); + assert_eq!(success, None); +} + #[tokio::test] async fn wait_agent_returns_not_found_for_missing_agents() { let (mut session, turn) = make_session_and_context().await; @@ -2872,7 +3045,7 @@ async fn wait_agent_returns_not_found_for_missing_agents() { "wait_agent", function_payload(json!({ "targets": [id_a.to_string(), id_b.to_string()], - "timeout_ms": 1000 + "timeout_ms": 10_000 })), ); let output = WaitAgentHandler::default() @@ -3008,7 +3181,7 @@ async fn wait_agent_returns_final_status_without_timeout() { "wait_agent", function_payload(json!({ "targets": [agent_id.to_string()], - "timeout_ms": 1000 + "timeout_ms": 10_000 })), ); let output = WaitAgentHandler::default() @@ -3087,7 +3260,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -3178,7 +3351,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )), ) .await @@ -3253,7 +3426,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -3338,7 +3511,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -3767,15 +3940,20 @@ async fn build_agent_spawn_config_uses_turn_context_values() { ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.abs(); + #[allow(deprecated)] + { + turn.cwd = temp_dir.abs(); + } turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.permission_profile, turn.config.legacy_sandbox_policy(), - turn.cwd.as_path(), + turn_cwd.as_path(), ); let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &turn.cwd); + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &turn_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), @@ -3798,7 +3976,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.compact_prompt = turn.compact_prompt.clone(); expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); + #[allow(deprecated)] + { + expected.cwd = turn.cwd.clone(); + } expected .permissions .approval_policy @@ -3849,7 +4030,10 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.compact_prompt = turn.compact_prompt.clone(); expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); + #[allow(deprecated)] + { + expected.cwd = turn.cwd.clone(); + } expected .permissions .approval_policy diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index c901df9ae0..7b575f8258 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -5,6 +5,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = CloseAgentResult; @@ -16,11 +17,8 @@ impl ToolExecutor for Handler { Some(create_close_agent_tool_v2()) } - fn handle( - &self, - invocation: ToolInvocation, - ) -> impl std::future::Future> + Send { - Box::pin(handle_close_agent(invocation)) + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_close_agent(invocation).await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs index 608d7f2f62..7d111f0418 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs @@ -8,6 +8,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs index 527d5a29e7..8b0ee551e3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -5,6 +5,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = ListAgentsResult; 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 index 71909afb7e..584feec61f 100644 --- 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 @@ -8,6 +8,7 @@ use codex_tools::ToolSpec; pub(crate) struct Handler; +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 1ab59cc3fe..3ad7b87145 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -24,6 +24,7 @@ impl Handler { } } +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = SpawnAgentResult; @@ -35,11 +36,8 @@ impl ToolExecutor for Handler { Some(create_spawn_agent_tool_v2(self.options.clone())) } - fn handle( - &self, - invocation: ToolInvocation, - ) -> impl std::future::Future> + Send { - Box::pin(handle_spawn_agent(invocation)) + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_spawn_agent(invocation).await } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index 48cbebdea4..53c976c9d4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -19,6 +19,7 @@ impl Handler { } } +#[async_trait::async_trait] impl ToolExecutor for Handler { type Output = WaitAgentResult; @@ -40,19 +41,22 @@ impl ToolExecutor for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: WaitArgs = parse_arguments(&arguments)?; - let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); - let min_timeout_ms = turn - .config - .multi_agent_v2 - .min_wait_timeout_ms - .clamp(1, MAX_WAIT_TIMEOUT_MS); - let timeout_ms = match timeout_ms { - ms if ms <= 0 => { - return Err(FunctionCallError::RespondToModel( - "timeout_ms must be greater than zero".to_owned(), - )); + let min_timeout_ms = turn.config.multi_agent_v2.min_wait_timeout_ms; + let max_timeout_ms = turn.config.multi_agent_v2.max_wait_timeout_ms; + let default_timeout_ms = turn.config.multi_agent_v2.default_wait_timeout_ms; + let timeout_ms = match args.timeout_ms { + Some(ms) if ms < min_timeout_ms => { + return Err(FunctionCallError::RespondToModel(format!( + "timeout_ms must be at least {min_timeout_ms}" + ))); } - ms => ms.clamp(min_timeout_ms, MAX_WAIT_TIMEOUT_MS), + Some(ms) if ms > max_timeout_ms => { + return Err(FunctionCallError::RespondToModel(format!( + "timeout_ms must be at most {max_timeout_ms}" + ))); + } + Some(ms) => ms, + None => default_timeout_ms, }; let mut mailbox_seq_rx = session.subscribe_mailbox_seq(); diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 42f03e4e30..9868c77ca7 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -44,6 +44,7 @@ impl ToolOutput for PlanToolOutput { } } +#[async_trait::async_trait] impl ToolExecutor for PlanHandler { type Output = PlanToolOutput; diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 243ed52d15..2f2e71677e 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -15,6 +15,7 @@ use codex_tools::ToolSpec; pub struct RequestPermissionsHandler; +#[async_trait::async_trait] impl ToolExecutor for RequestPermissionsHandler { type Output = FunctionToolOutput; @@ -47,6 +48,7 @@ impl ToolExecutor for RequestPermissionsHandler { } }; + #[allow(deprecated)] let mut args: RequestPermissionsArgs = parse_arguments_with_base_path(&arguments, &turn.cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 9e76352aa7..6073831e02 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -50,6 +50,7 @@ impl RequestPluginInstallHandler { } } +#[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index ae0a2c7694..7597cb6004 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -19,6 +19,7 @@ pub struct RequestUserInputHandler { pub available_modes: Vec, } +#[async_trait::async_trait] impl ToolExecutor for RequestUserInputHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 6f8ba4d632..c8c93bd6c7 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -1,6 +1,5 @@ use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; -use codex_protocol::models::ShellToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; @@ -8,9 +7,8 @@ use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; use crate::session::turn_context::TurnContext; +use crate::shell::ShellType; use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; @@ -19,12 +17,7 @@ use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::implicit_granted_permissions; use crate::tools::handlers::normalize_and_validate_additional_permissions; use crate::tools::handlers::parse_arguments; -use crate::tools::handlers::rewrite_function_arguments; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; use crate::tools::runtimes::shell::ShellRequest; use crate::tools::runtimes::shell::ShellRuntime; use crate::tools::runtimes::shell::ShellRuntimeBackend; @@ -33,36 +26,10 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; -mod container_exec; -mod local_shell; mod shell_command; -mod shell_handler; -pub use container_exec::ContainerExecHandler; -pub use local_shell::LocalShellHandler; pub use shell_command::ShellCommandHandler; pub(crate) use shell_command::ShellCommandHandlerOptions; -pub use shell_handler::ShellHandler; - -fn shell_function_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::Function { arguments } = payload else { - return None; - }; - - parse_arguments::(arguments) - .ok() - .map(|params| codex_shell_command::parse_command::shlex_join(¶ms.command)) -} - -fn local_shell_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::LocalShell { params } = payload else { - return None; - }; - - Some(codex_shell_command::parse_command::shlex_join( - ¶ms.command, - )) -} fn shell_command_payload_command(payload: &ToolPayload) -> Option { let ToolPayload::Function { arguments } = payload else { @@ -78,6 +45,7 @@ struct RunExecLikeArgs { tool_name: ToolName, exec_params: ExecParams, hook_command: String, + shell_type: Option, additional_permissions: Option, prefix_rule: Option>, session: Arc, @@ -88,58 +56,12 @@ struct RunExecLikeArgs { shell_runtime_backend: ShellRuntimeBackend, } -fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option { - shell_function_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) -} - -fn rewrite_shell_function_updated_hook_input( - mut invocation: ToolInvocation, - updated_input: JsonValue, - tool_name: &str, -) -> Result { - let ToolPayload::Function { arguments } = invocation.payload else { - return Err(FunctionCallError::RespondToModel(format!( - "hook input rewrite received unsupported {tool_name} payload" - ))); - }; - let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| { - FunctionCallError::RespondToModel( - "hook returned shell input with an invalid command string".to_string(), - ) - })?; - invocation.payload = ToolPayload::Function { - arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| { - arguments.insert( - "command".to_string(), - JsonValue::Array(command.into_iter().map(JsonValue::String).collect()), - ); - })?, - }; - Ok(invocation) -} - -fn shell_function_post_tool_use_payload( - invocation: &ToolInvocation, - result: &FunctionToolOutput, -) -> Option { - let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_function_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) -} - async fn run_exec_like(args: RunExecLikeArgs) -> Result { let RunExecLikeArgs { tool_name, exec_params, hook_command, + shell_type, additional_permissions, prefix_rule, session, @@ -173,6 +95,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result Result Result ShellRuntime::new(), - backend @ (ShellCommandClassic | ShellCommandZshFork) => { - ShellRuntime::for_shell_command(backend) - } - } - }; + let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend); let tool_ctx = ToolCtx { session: session.clone(), turn: turn.clone(), diff --git a/codex-rs/core/src/tools/handlers/shell/container_exec.rs b/codex-rs/core/src/tools/handlers/shell/container_exec.rs deleted file mode 100644 index ee3dd8221d..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/container_exec.rs +++ /dev/null @@ -1,97 +0,0 @@ -use codex_protocol::models::ShellToolCallParams; -use codex_tools::ToolName; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; - -use super::RunExecLikeArgs; -use super::rewrite_shell_function_updated_hook_input; -use super::run_exec_like; -use super::shell_function_post_tool_use_payload; -use super::shell_function_pre_tool_use_payload; -use super::shell_handler::ShellHandler; - -pub struct ContainerExecHandler; - -impl ToolExecutor for ContainerExecHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("container.exec") - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for container.exec handler".to_string(), - )); - } - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("container.exec"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} - -impl ToolHandler for ContainerExecHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_function_pre_tool_use_payload(invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec") - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - shell_function_post_tool_use_payload(invocation, result) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell/local_shell.rs b/codex-rs/core/src/tools/handlers/shell/local_shell.rs deleted file mode 100644 index 608d748d85..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/local_shell.rs +++ /dev/null @@ -1,131 +0,0 @@ -use codex_tools::ToolName; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::create_local_shell_tool; -use super::RunExecLikeArgs; -use super::local_shell_payload_command; -use super::run_exec_like; -use super::shell_handler::ShellHandler; - -#[derive(Default)] -pub struct LocalShellHandler { - include_spec: bool, -} - -impl LocalShellHandler { - pub(crate) fn new() -> Self { - Self { include_spec: true } - } -} - -impl ToolExecutor for LocalShellHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("local_shell") - } - - fn spec(&self) -> Option { - self.include_spec.then(create_local_shell_tool) - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.include_spec - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let ToolPayload::LocalShell { params } = payload else { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for local_shell handler".to_string(), - )); - }; - - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("local_shell"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: None, - prefix_rule: None, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} - -impl ToolHandler for LocalShellHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::LocalShell { .. }) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - local_shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let command = updated_hook_command(&updated_input)?; - invocation.payload = match invocation.payload { - ToolPayload::LocalShell { mut params } => { - params.command = shlex::split(command).ok_or_else(|| { - FunctionCallError::RespondToModel( - "hook returned shell input with an invalid command string".to_string(), - ) - })?; - ToolPayload::LocalShell { params } - } - payload => payload, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = local_shell_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index 8d9170bbdf..54d74f13b7 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -92,10 +92,12 @@ impl ShellCommandHandler { let shell = session.user_shell(); let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); + #[allow(deprecated)] + let cwd = turn_context.resolve_path(params.workdir.clone()); Ok(ExecParams { command, - cwd: turn_context.resolve_path(params.workdir.clone()), + cwd, expiration: params.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), @@ -125,6 +127,7 @@ impl From for ShellCommandHandler { } } +#[async_trait::async_trait] impl ToolExecutor for ShellCommandHandler { type Output = FunctionToolOutput; @@ -162,8 +165,10 @@ impl ToolExecutor for ShellCommandHandler { ))); }; + #[allow(deprecated)] let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + #[allow(deprecated)] let workdir = turn.resolve_path(params.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), @@ -180,10 +185,12 @@ impl ToolExecutor for ShellCommandHandler { session.conversation_id, turn.tools_config.allow_login_shell, )?; + let shell_type = Some(session.user_shell().shell_type.clone()); run_exec_like(RunExecLikeArgs { tool_name, exec_params, hook_command: params.command, + shell_type, additional_permissions: params.additional_permissions.clone(), prefix_rule, session, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs b/codex-rs/core/src/tools/handlers/shell/shell_handler.rs deleted file mode 100644 index 8875e96024..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/shell_handler.rs +++ /dev/null @@ -1,146 +0,0 @@ -use codex_protocol::ThreadId; -use codex_protocol::models::ShellToolCallParams; -use codex_tools::ToolName; - -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; -use crate::exec_env::create_env; -use crate::function_tool::FunctionCallError; -use crate::session::turn_context::TurnContext; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::ShellToolOptions; -use super::super::shell_spec::create_shell_tool; -use super::RunExecLikeArgs; -use super::rewrite_shell_function_updated_hook_input; -use super::run_exec_like; -use super::shell_function_post_tool_use_payload; -use super::shell_function_pre_tool_use_payload; - -#[derive(Default)] -pub struct ShellHandler { - options: Option, -} - -impl ShellHandler { - pub(crate) fn new(options: ShellToolOptions) -> Self { - Self { - options: Some(options), - } - } - - pub(super) fn to_exec_params( - params: &ShellToolCallParams, - turn_context: &TurnContext, - thread_id: ThreadId, - ) -> ExecParams { - ExecParams { - command: params.command.clone(), - cwd: turn_context.resolve_path(params.workdir.clone()), - expiration: params.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), - network: turn_context.network.clone(), - sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - } - } -} - -impl ToolExecutor for ShellHandler { - type Output = FunctionToolOutput; - - fn tool_name(&self) -> ToolName { - ToolName::plain("shell") - } - - fn spec(&self) -> Option { - self.options.map(create_shell_tool) - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.options.is_some() - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - tracker, - call_id, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "unsupported payload for shell handler".to_string(), - )); - } - }; - - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; - let params: ShellToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = - ShellHandler::to_exec_params(¶ms, turn.as_ref(), session.conversation_id); - run_exec_like(RunExecLikeArgs { - tool_name: ToolName::plain("shell"), - exec_params, - hook_command: codex_shell_command::parse_command::shlex_join(¶ms.command), - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - turn, - tracker, - call_id, - freeform: false, - shell_runtime_backend: ShellRuntimeBackend::Generic, - }) - .await - } -} - -impl ToolHandler for ShellHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_function_pre_tool_use_payload(invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell") - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &Self::Output, - ) -> Option { - shell_function_post_tool_use_payload(invocation, result) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index dc46290bfa..43b78e3afe 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -11,20 +11,11 @@ pub struct CommandToolOptions { pub exec_permission_approvals_enabled: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ShellToolOptions { - pub exec_permission_approvals_enabled: bool, -} - #[cfg(test)] pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec { create_exec_command_tool_with_environment_id(options, /*include_environment_id*/ false) } -pub fn create_local_shell_tool() -> ToolSpec { - ToolSpec::LocalShell {} -} - pub(crate) fn create_exec_command_tool_with_environment_id( options: CommandToolOptions, include_environment_id: bool, @@ -153,69 +144,6 @@ pub fn create_write_stdin_tool() -> ToolSpec { }) } -pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec { - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - Some("The command to execute".to_string()), - ), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - options.exec_permission_approvals_enabled, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"] - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec { let mut properties = BTreeMap::from([ ( diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index a219263e1a..4774815fb2 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -6,91 +6,6 @@ fn windows_shell_guidance_description() -> String { format!("\n\n{}", windows_shell_guidance()) } -#[test] -fn shell_tool_matches_expected_spec() { - let tool = create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled: false, - }); - - let description = if cfg!(windows) { - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"# - .to_string() - + &windows_shell_guidance_description() - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - let properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array(JsonSchema::string(/*description*/ None), Some("The command to execute".to_string())), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some("The working directory to execute the command in".to_string())), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some("The timeout for the command in milliseconds".to_string())), - ), - ( - "sandbox_permissions".to_string(), - JsonSchema::string(Some( - "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." - .to_string(), - )), - ), - ( - "justification".to_string(), - JsonSchema::string(Some( - r#"Only set if sandbox_permissions is \"require_escalated\". - Request approval from the user to run this command outside the sandbox. - Phrased as a simple question that summarizes the purpose of the - command as it relates to the task at hand - e.g. 'Do you want to - fetch and pull the latest version of this git branch?'"# - .to_string(), - )), - ), - ( - "prefix_rule".to_string(), - JsonSchema::array(JsonSchema::string(/*description*/ None), Some( - r#"Only specify when sandbox_permissions is `require_escalated`. - Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future. - Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."# - .to_string(), - )), - ), - ]); - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} - #[test] fn exec_command_tool_matches_expected_spec() { let tool = create_exec_command_tool(CommandToolOptions { @@ -224,77 +139,6 @@ fn write_stdin_tool_matches_expected_spec() { ); } -#[test] -fn shell_tool_with_request_permission_includes_additional_permissions() { - let tool = create_shell_tool(ShellToolOptions { - exec_permission_approvals_enabled: true, - }); - - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::array( - JsonSchema::string(/*description*/ None), - Some("The command to execute".to_string()), - ), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - /*exec_permission_approvals_enabled*/ true, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"]. - -Examples of valid command strings: - -- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"] -- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"] -- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"] -- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"] -- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"] -- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"] - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. -- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} - #[test] fn request_permissions_tool_includes_full_permission_schema() { let tool = diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index ce97b8317e..660bac6789 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -16,7 +16,6 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; @@ -89,6 +88,7 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex let expected_command = session .user_shell() .derive_exec_args(&command, /*use_login_shell*/ true); + #[allow(deprecated)] let expected_cwd = turn_context.resolve_path(workdir.clone()); let expected_env = create_env( &turn_context.shell_environment_policy, @@ -203,44 +203,6 @@ fn shell_command_handler_rejects_login_when_disallowed() { ); } -#[tokio::test] -async fn local_shell_pre_tool_use_payload_uses_joined_command() { - let payload = ToolPayload::LocalShell { - params: codex_protocol::models::ShellToolCallParams { - command: vec![ - "bash".to_string(), - "-lc".to_string(), - "printf hi".to_string(), - ], - workdir: None, - timeout_ms: None, - sandbox_permissions: None, - additional_permissions: None, - prefix_rule: None, - justification: None, - }, - }; - let (session, turn) = make_session_and_context().await; - let handler = LocalShellHandler::default(); - - assert_eq!( - handler.pre_tool_use_payload(&ToolInvocation { - session: session.into(), - turn: turn.into(), - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), - call_id: "call-41".to_string(), - tool_name: codex_tools::ToolName::plain("local_shell"), - source: crate::tools::context::ToolCallSource::Direct, - payload, - }), - Some(crate::tools::registry::PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: json!({ "command": "bash -lc 'printf hi'" }), - }) - ); -} - #[tokio::test] async fn shell_command_pre_tool_use_payload_uses_raw_command() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index 091f0ffce7..feee6470a3 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -56,6 +56,7 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { BARRIERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) } +#[async_trait::async_trait] impl ToolExecutor for TestSyncHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index aaa3138588..579826ac04 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -52,6 +52,7 @@ impl ToolSearchHandler { } } +#[async_trait::async_trait] impl ToolExecutor for ToolSearchHandler { type Output = ToolSearchOutput; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c97f5bb6f2..22e0a16dd8 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,6 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; +use crate::shell::ShellType; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; @@ -79,6 +80,12 @@ fn effective_max_output_tokens( resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) } +#[derive(Debug)] +pub(crate) struct ResolvedCommand { + pub(crate) command: Vec, + pub(crate) shell_type: ShellType, +} + fn post_unified_exec_tool_use_payload( invocation: &ToolInvocation, result: &ExecCommandToolOutput, @@ -107,7 +114,7 @@ pub(crate) fn get_command( session_shell: Arc, shell_mode: &UnifiedExecShellMode, allow_login_shell: bool, -) -> Result, String> { +) -> Result { let use_login_shell = match args.login { Some(true) if !allow_login_shell => { return Err( @@ -126,13 +133,19 @@ pub(crate) fn get_command( shell }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); - Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) + Ok(ResolvedCommand { + command: shell.derive_exec_args(&args.cmd, use_login_shell), + shell_type: shell.shell_type.clone(), + }) } - UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(vec![ - zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(), - if use_login_shell { "-lc" } else { "-c" }.to_string(), - args.cmd.clone(), - ]), + UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(ResolvedCommand { + command: vec![ + zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(), + if use_login_shell { "-lc" } else { "-c" }.to_string(), + args.cmd.clone(), + ], + shell_type: ShellType::Zsh, + }), } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index cef066ad57..2df924b5f4 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -68,6 +68,7 @@ impl ExecCommandHandler { } } +#[async_trait::async_trait] impl ToolExecutor for ExecCommandHandler { type Output = ExecCommandToolOutput; @@ -138,13 +139,15 @@ impl ToolExecutor for ExecCommandHandler { ) .await; let process_id = manager.allocate_process_id().await; - let command = get_command( + let resolved_command = get_command( &args, session.user_shell(), &turn.tools_config.unified_exec_shell_mode, turn.tools_config.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; + let command = resolved_command.command; + let shell_type = resolved_command.shell_type; let command_for_display = codex_shell_command::parse_command::shlex_join(&command); let ExecCommandArgs { @@ -249,6 +252,7 @@ impl ToolExecutor for ExecCommandHandler { .exec_command( ExecCommandRequest { command, + shell_type, hook_command: hook_command.clone(), process_id, yield_time_ms, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index cc79615c14..29ee4b4ea3 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -31,6 +31,7 @@ struct WriteStdinArgs { pub struct WriteStdinHandler; +#[async_trait::async_trait] impl ToolExecutor for WriteStdinHandler { type Output = ExecCommandToolOutput; diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 02123c4b64..6ab2752b94 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shell::ShellType; use crate::shell::default_user_shell; use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; @@ -42,13 +43,14 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> assert!(args.shell.is_none()); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -63,13 +65,14 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -83,21 +86,37 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { #[test] fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#; + let temp_dir = tempfile::tempdir()?; + let powershell_path = temp_dir.path().join(if cfg!(windows) { + "powershell.exe" + } else { + "powershell" + }); + std::fs::write(&powershell_path, "")?; + let json = serde_json::json!({ + "cmd": "echo hello", + "shell": powershell_path, + }) + .to_string(); - let args: ExecCommandArgs = parse_arguments(json)?; + let args: ExecCommandArgs = parse_arguments(&json)?; - assert_eq!(args.shell.as_deref(), Some("powershell")); + assert_eq!( + args.shell.as_deref(), + Some(powershell_path.to_string_lossy().as_ref()) + ); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command[2], "echo hello"); + assert_eq!(resolved.shell_type, ShellType::PowerShell); Ok(()) } @@ -109,13 +128,14 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command[2], "echo hello"); Ok(()) @@ -159,7 +179,7 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< })?, }); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &shell_mode, @@ -168,13 +188,14 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< .map_err(anyhow::Error::msg)?; assert_eq!( - command, + resolved.command, vec![ shell_zsh_path.to_string_lossy().to_string(), "-lc".to_string(), "echo hello".to_string() ] ); + assert_eq!(resolved.shell_type, ShellType::Zsh); Ok(()) } diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 3efbedc0ee..7727fb4370 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -62,6 +62,7 @@ enum ViewImageDetail { Original, } +#[async_trait::async_trait] impl ToolExecutor for ViewImageHandler { type Output = ViewImageOutput; @@ -134,8 +135,7 @@ impl ToolExecutor for ViewImageHandler { }; let cwd = turn_environment.cwd.clone(); let abs_path = cwd.join(path); - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); let fs = turn_environment.environment.get_filesystem(); let metadata = fs @@ -282,7 +282,7 @@ mod tests { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - turn.cwd = image_cwd.clone(); + turn.environments .turn_environments .first_mut() diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 14af2c9c5f..39441d96f5 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -525,6 +525,7 @@ impl NetworkApprovalService { guardian_approval_id, /*approval_id*/ None, prompt_command, + #[allow(deprecated)] turn_context.cwd.clone(), Some(prompt_reason), Some(network_approval_context.clone()), diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 37a28b1255..a98d650688 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -229,7 +229,7 @@ async fn register_call_with_default_shell_trigger( "turn-1".to_string(), GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell".to_string(), + tool_name: "shell_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: test_path_buf("/tmp").abs(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -249,7 +249,7 @@ async fn active_call_preserves_triggering_command_context() { let service = NetworkApprovalService::default(); let expected = GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell".to_string(), + tool_name: "shell_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: test_path_buf("/repo").abs(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 648d3f79e8..deb9ae596e 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -234,6 +234,7 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + #[allow(deprecated)] let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 2c62e6c15d..4c79e4b168 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -180,7 +180,7 @@ impl ToolCallRuntime { if call.tool_name.namespace.is_none() && matches!( call.tool_name.name.as_str(), - "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" + "shell_command" | "unified_exec" ) { format!("Wall time: {secs:.1} seconds\naborted by user") diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b8480600f7..730c3b7ef9 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -18,12 +18,10 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; -use crate::tools::handlers::extension_tools::ExtensionToolHandler; use crate::tools::hook_names::HookToolName; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::tools::tool_search_entry::ToolSearchInfo; use crate::util::error_or_panic; -use codex_extension_api::ExtensionToolExecutor; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; use codex_tools::ToolName; @@ -35,6 +33,7 @@ use tracing::warn; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; pub use codex_tools::ToolExecutor; +pub use codex_tools::ToolExposure; pub trait ToolHandler: ToolExecutor { fn search_info(&self) -> Option { @@ -155,11 +154,17 @@ pub(crate) struct PostToolUsePayload { pub(crate) tool_response: Value, } -pub(crate) trait AnyToolHandler: Send + Sync { +/// Object-safe registry entry for heterogeneous tool handlers. +/// +/// Concrete handlers keep their typed `ToolExecutor::Output`; the registry +/// boxes that output only after typed hooks have run. +pub(crate) trait RegisteredTool: Send + Sync { fn tool_name(&self) -> ToolName; fn spec(&self) -> Option; + fn exposure(&self) -> ToolExposure; + fn search_info(&self) -> Option; fn supports_parallel_tool_calls(&self) -> bool; @@ -186,7 +191,7 @@ pub(crate) trait AnyToolHandler: Send + Sync { ) -> BoxFuture<'a, Result>; } -impl AnyToolHandler for T +impl RegisteredTool for T where T: ToolHandler, { @@ -198,6 +203,10 @@ where ToolExecutor::spec(self) } + fn exposure(&self) -> ToolExposure { + ToolExecutor::exposure(self) + } + fn search_info(&self) -> Option { ToolHandler::search_info(self) } @@ -252,12 +261,85 @@ where } } +pub(crate) fn override_tool_exposure( + handler: Arc, + exposure: ToolExposure, +) -> Arc { + if handler.exposure() == exposure { + return handler; + } + + Arc::new(ExposureOverride { handler, exposure }) +} + +struct ExposureOverride { + handler: Arc, + exposure: ToolExposure, +} + +impl RegisteredTool for ExposureOverride { + fn tool_name(&self) -> ToolName { + self.handler.tool_name() + } + + fn spec(&self) -> Option { + self.handler.spec() + } + + fn exposure(&self) -> ToolExposure { + self.exposure + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + self.handler.pre_tool_use_payload(invocation) + } + + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: Value, + ) -> Result { + self.handler + .with_updated_hook_input(invocation, updated_input) + } + + fn telemetry_tags<'a>( + &'a self, + invocation: &'a ToolInvocation, + ) -> BoxFuture<'a, ToolTelemetryTags> { + self.handler.telemetry_tags(invocation) + } + + fn create_diff_consumer(&self) -> Option> { + self.handler.create_diff_consumer() + } + + fn handle_any<'a>( + &'a self, + invocation: ToolInvocation, + ) -> BoxFuture<'a, Result> { + self.handler.handle_any(invocation) + } +} + pub struct ToolRegistry { - handlers: HashMap>, + handlers: HashMap>, } impl ToolRegistry { - fn new(handlers: HashMap>) -> Self { + fn new(handlers: HashMap>) -> Self { Self { handlers } } @@ -272,13 +354,17 @@ impl ToolRegistry { T: ToolHandler + 'static, { let name = handler.tool_name(); - Self::new(HashMap::from([(name, handler as Arc)])) + Self::new(HashMap::from([(name, handler as Arc)])) } - fn handler(&self, name: &ToolName) -> Option> { + fn handler(&self, name: &ToolName) -> Option> { self.handlers.get(name).map(Arc::clone) } + pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { + self.handlers.get(name).map(|handler| handler.exposure()) + } + #[cfg(test)] pub(crate) fn has_handler(&self, name: &ToolName) -> bool { self.handler(name).is_some() @@ -321,6 +407,7 @@ impl ToolRegistry { "sandbox_policy", permission_profile_policy_tag( &invocation.turn.permission_profile, + #[allow(deprecated)] invocation.turn.cwd.as_path(), ), ), @@ -534,7 +621,7 @@ impl ToolRegistry { } pub struct ToolRegistryBuilder { - handlers: HashMap>, + handlers: HashMap>, specs: Vec, } @@ -550,54 +637,31 @@ impl ToolRegistryBuilder { self.specs.push(spec); } - pub fn register_handler(&mut self, handler: Arc) - where - H: ToolHandler + 'static, - { - self.register_any_handler(handler); + pub(crate) fn register_tool(&mut self, handler: Arc) { + self.register_tool_internal(handler, /*include_spec*/ true); } - pub(crate) fn register_any_handler(&mut self, handler: Arc) { - self.register_any_handler_internal(handler, /*include_spec*/ true); + pub(crate) fn register_tool_without_spec(&mut self, handler: Arc) { + self.register_tool_internal(handler, /*include_spec*/ false); } - pub(crate) fn register_any_handler_without_spec(&mut self, handler: Arc) { - self.register_any_handler_internal(handler, /*include_spec*/ false); - } - - fn register_any_handler_internal( - &mut self, - handler: Arc, - include_spec: bool, - ) { + fn register_tool_internal(&mut self, handler: Arc, include_spec: bool) { let name = handler.tool_name(); if self.handlers.contains_key(&name) { error_or_panic(format!("handler for tool {name} already registered")); return; } - if include_spec && let Some(spec) = handler.spec() { + if include_spec + && handler.exposure().is_direct() + && let Some(spec) = handler.spec() + { self.push_spec(spec); } self.handlers.insert(name, handler); } - pub fn register_extension_tool_executor(&mut self, executor: Arc) { - let tool_name = executor.tool_name(); - if self.handlers.contains_key(&tool_name) { - warn!("Skipping extension tool `{tool_name}`: handler already registered"); - return; - } - - if let Some(spec) = executor.spec() { - self.push_spec(spec); - } - - let handler: Arc = Arc::new(ExtensionToolHandler::new(executor)); - self.handlers.insert(tool_name, handler); - } - pub fn build(self) -> (Vec, ToolRegistry) { let registry = ToolRegistry::new(self.handlers); (self.specs, registry) diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 1cc68be44d..b6c8eadadf 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -8,6 +8,7 @@ struct TestHandler { tool_name: codex_tools::ToolName, } +#[async_trait::async_trait] impl ToolExecutor for TestHandler { type Output = crate::tools::context::FunctionToolOutput; @@ -33,10 +34,10 @@ fn handler_looks_up_namespaced_aliases_explicitly() { let namespaced_name = codex_tools::ToolName::namespaced(namespace, tool_name); let plain_handler = Arc::new(TestHandler { tool_name: plain_name.clone(), - }) as Arc; + }) as Arc; let namespaced_handler = Arc::new(TestHandler { tool_name: namespaced_name.clone(), - }) as Arc; + }) as Arc; let registry = ToolRegistry::new(HashMap::from([ (plain_name.clone(), Arc::clone(&plain_handler)), (namespaced_name.clone(), Arc::clone(&namespaced_handler)), @@ -65,9 +66,9 @@ fn handler_looks_up_namespaced_aliases_explicitly() { } #[test] -fn register_handler_adds_handler_and_spec() { +fn register_tool_adds_executor_and_spec() { let mut builder = ToolRegistryBuilder::new(); - builder.register_handler(Arc::new(GetGoalHandler)); + builder.register_tool(Arc::new(GetGoalHandler)); let (specs, registry) = builder.build(); diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index abb0e18976..0f111922c6 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -1,27 +1,25 @@ use crate::function_tool::FunctionCallError; -use crate::sandboxing::SandboxPermissions; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::registry::AnyToolResult; +use crate::tools::registry::RegisteredTool; use crate::tools::registry::ToolArgumentDiffConsumer; +use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; -use crate::tools::spec::build_specs_with_discoverable_tools; +use crate::tools::spec::collect_tool_router_parts; +use crate::tools::spec_plan::build_tool_registry_builder_from_executors; use codex_extension_api::ExtensionToolExecutor; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; -use codex_protocol::models::ShellToolCallParams; use codex_tools::DiscoverableTool; -use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; -use std::collections::HashSet; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::instrument; @@ -57,7 +55,7 @@ impl ToolRouter { extension_tool_executors, dynamic_tools, } = params; - let builder = build_specs_with_discoverable_tools( + let parts = collect_tool_router_parts( config, mcp_tools, deferred_mcp_tools, @@ -65,23 +63,19 @@ impl ToolRouter { &extension_tool_executors, dynamic_tools, ); - let (specs, registry) = builder.build(); - let deferred_dynamic_tools = dynamic_tools - .iter() - .filter(|tool| tool.defer_loading) - .map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone())) - .collect::>(); - let model_visible_specs = specs - .iter() - .filter_map(|spec| { - if config.code_mode_only_enabled - && codex_code_mode::is_code_mode_nested_tool(spec.name()) - { - return None; - } + Self::from_executors(config, parts.executors, parts.hosted_specs) + } - filter_deferred_dynamic_tool_spec(spec.clone(), &deferred_dynamic_tools) - }) + pub(crate) fn from_executors( + config: &ToolsConfig, + executors: Vec>, + hosted_specs: Vec, + ) -> Self { + let builder = build_tool_registry_builder_from_executors(config, executors, hosted_specs); + let (specs, registry) = builder.build(); + let model_visible_specs = specs + .into_iter() + .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) .collect(); Self { @@ -153,35 +147,6 @@ impl ToolRouter { call_id, payload: ToolPayload::Custom { input }, })), - ResponseItem::LocalShellCall { - id, - call_id, - action, - .. - } => { - let call_id = call_id - .or(id) - .ok_or(FunctionCallError::MissingLocalShellCallId)?; - - match action { - LocalShellAction::Exec(exec) => { - let params = ShellToolCallParams { - command: exec.command, - workdir: exec.working_directory, - timeout_ms: exec.timeout_ms, - sandbox_permissions: Some(SandboxPermissions::UseDefault), - additional_permissions: None, - prefix_rule: None, - justification: None, - }; - Ok(Some(ToolCall { - tool_name: ToolName::plain("local_shell"), - call_id, - payload: ToolPayload::LocalShell { params }, - })) - } - } - } _ => Ok(None), } } @@ -217,6 +182,21 @@ impl ToolRouter { } } +fn is_hidden_by_code_mode_only( + config: &ToolsConfig, + registry: &ToolRegistry, + spec: &ToolSpec, +) -> bool { + if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) { + return false; + } + + let exposure = registry + .tool_exposure(&ToolName::plain(spec.name())) + .unwrap_or(ToolExposure::Direct); + exposure != ToolExposure::DirectModelOnly +} + pub(crate) fn extension_tool_executors(session: &Session) -> Vec> { session .services @@ -232,38 +212,6 @@ pub(crate) fn extension_tool_executors(session: &Session) -> Vec, -) -> Option { - if deferred_dynamic_tools.is_empty() { - return Some(spec); - } - - match spec { - ToolSpec::Function(tool) => { - if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) { - None - } else { - Some(ToolSpec::Function(tool)) - } - } - ToolSpec::Namespace(mut namespace) => { - let namespace_name = namespace.name.clone(); - namespace.tools.retain(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains( - &ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()), - ), - }); - if namespace.tools.is_empty() { - None - } else { - Some(ToolSpec::Namespace(namespace)) - } - } - spec => Some(spec), - } -} #[cfg(test)] #[path = "router_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index b4e935efb1..dd791d9135 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -8,16 +8,18 @@ use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ExtensionToolExecutor; -use codex_extension_api::ExtensionToolOutput; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; +use codex_extension_api::ToolExecutor; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_tools::default_namespace_description; use pretty_assertions::assert_eq; use serde_json::json; use tokio_util::sync::CancellationToken; @@ -42,40 +44,48 @@ impl codex_extension_api::ToolContributor for ExtensionEchoContributor { struct ExtensionEchoExecutor; -impl ExtensionToolExecutor for ExtensionEchoExecutor { +#[async_trait::async_trait] +impl ToolExecutor for ExtensionEchoExecutor { + type Output = codex_tools::JsonToolOutput; + fn tool_name(&self) -> ToolName { - ToolName::plain("extension_echo") + ToolName::namespaced("extension/", "echo") } fn spec(&self) -> Option { - Some(ToolSpec::Function(ResponsesApiTool { - name: "extension_echo".to_string(), - description: "Echoes arguments through an extension tool.".to_string(), - strict: true, - parameters: codex_extension_api::parse_tool_input_schema(&json!({ - "type": "object", - "properties": { - "message": { "type": "string" }, - }, - "required": ["message"], - "additionalProperties": false, - })) - .expect("extension schema should parse"), - output_schema: None, - defer_loading: None, + Some(ToolSpec::Namespace(ResponsesApiNamespace { + name: "extension/".to_string(), + description: default_namespace_description("extension/"), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "echo".to_string(), + description: "Echoes arguments through an extension tool.".to_string(), + strict: true, + parameters: codex_extension_api::parse_tool_input_schema(&json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + }, + "required": ["message"], + "additionalProperties": false, + })) + .expect("extension schema should parse"), + output_schema: None, + defer_loading: None, + })], })) } - fn handle(&self, call: ExtensionToolCall) -> codex_extension_api::ExtensionToolFuture<'_> { - Box::pin(async move { - let arguments: serde_json::Value = serde_json::from_str(call.function_arguments()?) - .expect("test arguments should parse"); - Ok(ExtensionToolOutput::new(json!({ - "arguments": arguments, - "callId": call.call_id.clone(), - "ok": true, - }))) - }) + async fn handle( + &self, + call: ExtensionToolCall, + ) -> Result { + let arguments: serde_json::Value = + serde_json::from_str(call.function_arguments()?).expect("test arguments should parse"); + Ok(codex_tools::JsonToolOutput::new(json!({ + "arguments": arguments, + "callId": call.call_id, + "ok": true, + }))) } } @@ -110,7 +120,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow }, ); - let parallel_tool_name = ["shell", "local_shell", "exec_command", "shell_command"] + let parallel_tool_name = ["exec_command", "shell_command"] .into_iter() .find(|name| { router.tool_supports_parallel(&ToolCall { @@ -333,17 +343,21 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow ); assert!( - router - .model_visible_specs() - .iter() - .any(|spec| spec.name() == "extension_echo"), + router.model_visible_specs().iter().any( + |spec| matches!(spec, ToolSpec::Namespace(namespace) + if namespace.name == "extension/" + && namespace.tools.iter().any(|tool| matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "echo" + ))) + ), "expected extension-provided tool to be visible to the model" ); let call = ToolRouter::build_tool_call(ResponseItem::FunctionCall { id: None, - name: "extension_echo".to_string(), - namespace: None, + name: "echo".to_string(), + namespace: Some("extension/".to_string()), arguments: json!({ "message": "hello" }).to_string(), call_id: "call-extension".to_string(), })? @@ -399,7 +413,6 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec None, diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 073fda8ec4..bba1c572ef 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -8,6 +8,7 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::path_utils; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; +use crate::shell::ShellType; use crate::tools::sandboxing::ToolError; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; @@ -15,8 +16,10 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -67,6 +70,35 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } +pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( + command: &[String], + shell_type: Option<&ShellType>, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, +) -> Vec { + if shell_type != Some(&ShellType::PowerShell) + || sandbox != SandboxType::WindowsRestrictedToken + || windows_sandbox_level != WindowsSandboxLevel::Elevated + || command.is_empty() + { + return command.to_vec(); + } + + if command[1..] + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-NoProfile")) + { + return command.to_vec(); + } + + // The elevated Windows sandbox runs as a dedicated sandbox account while + // HOME/USERPROFILE may still point at the real user profile. Loading + // PowerShell profiles in that mixed context is not a valid login shell. + let mut command = command.to_vec(); + command.insert(1, "-NoProfile".to_string()); + command +} + /// POSIX-only helper: for commands produced by `Shell::derive_exec_args` /// for Bash/Zsh/sh of the form `[shell_path, "-lc", "