diff --git a/.github/dotslash-unsigned-config.json b/.github/dotslash-unsigned-config.json deleted file mode 100644 index 65c44d5e8d..0000000000 --- a/.github/dotslash-unsigned-config.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "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/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 11b4e914fe..f15c190102 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -15,14 +15,8 @@ jobs: permissions: contents: read outputs: - issues_json: ${{ steps.normalize-all.outputs.issues_json }} - reason: ${{ steps.normalize-all.outputs.reason }} - has_matches: ${{ steps.normalize-all.outputs.has_matches }} + codex_output: ${{ steps.codex-all.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Prepare Codex inputs env: GH_TOKEN: ${{ github.token }} @@ -67,6 +61,8 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. @@ -100,10 +96,21 @@ jobs: "additionalProperties": false } + normalize-duplicates-all: + name: Normalize pass 1 output + needs: gather-duplicates-all + if: ${{ needs.gather-duplicates-all.result == 'success' }} + runs-on: ubuntu-latest + permissions: {} + outputs: + issues_json: ${{ steps.normalize-all.outputs.issues_json }} + reason: ${{ steps.normalize-all.outputs.reason }} + has_matches: ${{ steps.normalize-all.outputs.has_matches }} + steps: - id: normalize-all name: Normalize pass 1 output env: - CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }} + CODEX_OUTPUT: ${{ needs.gather-duplicates-all.outputs.codex_output }} CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -eo pipefail @@ -146,21 +153,15 @@ jobs: gather-duplicates-open: name: Identify potential duplicates (open issues fallback) - # Pass 1 may drop sudo on the runner, so run the fallback in a fresh job. - needs: gather-duplicates-all - if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }} + # Pass 1 Codex execution drops sudo on its runner, so run the fallback in a fresh job. + needs: normalize-duplicates-all + if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }} runs-on: ubuntu-latest permissions: contents: read outputs: - issues_json: ${{ steps.normalize-open.outputs.issues_json }} - reason: ${{ steps.normalize-open.outputs.reason }} - has_matches: ${{ steps.normalize-open.outputs.has_matches }} + codex_output: ${{ steps.codex-open.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Prepare Codex inputs env: GH_TOKEN: ${{ github.token }} @@ -203,6 +204,8 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. @@ -236,10 +239,21 @@ jobs: "additionalProperties": false } + normalize-duplicates-open: + name: Normalize pass 2 output + needs: gather-duplicates-open + if: ${{ needs.gather-duplicates-open.result == 'success' }} + runs-on: ubuntu-latest + permissions: {} + outputs: + issues_json: ${{ steps.normalize-open.outputs.issues_json }} + reason: ${{ steps.normalize-open.outputs.reason }} + has_matches: ${{ steps.normalize-open.outputs.has_matches }} + steps: - id: normalize-open name: Normalize pass 2 output env: - CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }} + CODEX_OUTPUT: ${{ needs.gather-duplicates-open.outputs.codex_output }} CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -eo pipefail @@ -283,9 +297,9 @@ jobs: select-final: name: Select final duplicate set needs: - - gather-duplicates-all - - gather-duplicates-open - if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }} + - normalize-duplicates-all + - normalize-duplicates-open + if: ${{ always() && needs.normalize-duplicates-all.result == 'success' && (needs.normalize-duplicates-open.result == 'success' || needs.normalize-duplicates-open.result == 'skipped') }} runs-on: ubuntu-latest permissions: contents: read @@ -295,12 +309,12 @@ jobs: - id: select-final name: Select final duplicate set env: - PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }} - PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }} - PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }} - PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }} - PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }} - PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }} + PASS1_ISSUES: ${{ needs.normalize-duplicates-all.outputs.issues_json }} + PASS1_REASON: ${{ needs.normalize-duplicates-all.outputs.reason }} + PASS2_ISSUES: ${{ needs.normalize-duplicates-open.outputs.issues_json }} + PASS2_REASON: ${{ needs.normalize-duplicates-open.outputs.reason }} + PASS1_HAS_MATCHES: ${{ needs.normalize-duplicates-all.outputs.has_matches }} + PASS2_HAS_MATCHES: ${{ needs.normalize-duplicates-open.outputs.has_matches }} run: | set -eo pipefail diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 0000000000..77fe5d07c8 --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,151 @@ +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: + - id: codex + uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 + with: + openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} + allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only + 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-prepare.yml b/.github/workflows/rust-release-prepare.yml index e998efb54c..67e542efa9 100644 --- a/.github/workflows/rust-release-prepare.yml +++ b/.github/workflows/rust-release-prepare.yml @@ -16,6 +16,9 @@ jobs: prepare: # Prevent scheduled runs on forks (no secrets, wastes Actions minutes) if: github.repository == 'openai/codex' + environment: + name: rust-release-prepare + deployment: false runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ca082812c6..b6c293d6cd 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,6 +4,13 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` +# +# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, +# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff +# archive as a GitHub Release asset, then manually dispatch +# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. +# The signed handoff archive should contain target or artifact directories such +# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: @@ -12,11 +19,31 @@ on: - "rust-v*.*.*" workflow_dispatch: inputs: + release_mode: + description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + required: false + type: choice + default: build_unsigned + options: + - build_unsigned + - promote_signed sign_macos: - description: "Sign and notarize macOS release artifacts." + description: "Deprecated compatibility input; use release_mode instead." required: false type: boolean - default: true + default: false + unsigned_run_id: + description: "For promote_signed: workflow run id from the build_unsigned run." + required: false + type: string + signed_macos_asset: + description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." + required: false + type: string + signed_macos_sha256: + description: "For promote_signed: optional SHA-256 of signed_macos_asset." + required: false + type: string concurrency: group: ${{ github.workflow }} @@ -33,14 +60,57 @@ jobs: - name: Validate tag matches Cargo.toml version shell: bash env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} 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 + case "${RELEASE_MODE}" in + signed) + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" + exit 1 + fi + ;; + build_unsigned) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=build_unsigned is only valid for manual runs" + exit 1 + fi + ;; + promote_signed) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=promote_signed is only valid for manual runs" + exit 1 + fi + if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then + echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" + exit 1 + fi + if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then + echo "❌ release_mode=promote_signed requires signed_macos_asset" + exit 1 + fi + if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then + echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" + exit 1 + fi + if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then + echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" + exit 1 + fi + ;; + *) + echo "❌ Unknown release_mode '${RELEASE_MODE}'" + exit 1 + ;; + esac + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then + echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." fi # 1. Must be a tag and match the regex @@ -62,6 +132,7 @@ jobs: echo "::endgroup::" build: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} @@ -78,7 +149,7 @@ 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 }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} strategy: fail-fast: false @@ -310,7 +381,7 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Stage unsigned macOS artifacts shell: bash run: | @@ -335,7 +406,7 @@ jobs: zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Upload unsigned macOS artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -553,7 +624,233 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* + stage-signed-macos: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} + needs: tag-check + name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: macos-15-xlarge + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download signed macOS handoff + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} + run: | + set -euo pipefail + + download_dir="${RUNNER_TEMP}/signed-macos-download" + handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" + rm -rf "$download_dir" "$handoff_dir" + mkdir -p "$download_dir" "$handoff_dir" + + gh release download "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$SIGNED_MACOS_ASSET" \ + --dir "$download_dir" + + asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" + if [[ "$asset_count" != "1" ]]; then + echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" + find "$download_dir" -maxdepth 1 -type f -print + exit 1 + fi + + asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" + if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then + expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" + actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" + echo "expected: ${expected_sha}" + echo "actual: ${actual_sha}" + exit 1 + fi + fi + + asset_name="$(basename "$asset_path")" + case "$asset_name" in + *.tar.zst) + zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - + ;; + *.tar.gz|*.tgz) + tar -C "$handoff_dir" -xzf "$asset_path" + ;; + *.zip) + ditto -x -k "$asset_path" "$handoff_dir" + ;; + *) + echo "Unsupported signed macOS handoff archive format: ${asset_name}" + exit 1 + ;; + esac + + echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + + - name: Stage signed macOS artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + artifact_name="${{ matrix.artifact_name }}" + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + fi + if [[ ! -d "$source_dir" ]]; then + echo "Signed macOS handoff is missing ${artifact_name}/" + echo "Expected either:" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + exit 1 + fi + + dest="dist/${target}" + mkdir -p "$dest" + + for binary in ${{ matrix.binaries }}; do + source_path="${source_dir}/${binary}" + if [[ ! -f "$source_path" ]]; then + source_path="${source_dir}/${binary}-${target}" + fi + if [[ ! -f "$source_path" ]]; then + echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" + exit 1 + fi + + release_path="${dest}/${binary}-${target}" + ditto "$source_path" "$release_path" + chmod 0755 "$release_path" + codesign --verify --strict --verbose=2 "$release_path" + done + + # DMG staging is disabled for signed promotion because we no longer + # distribute DMGs from this release path. Keep the branch here so the + # handoff can opt back in by flipping matrix.build_dmg if needed. + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + dmg_name="codex-${target}.dmg" + dmg_source="${source_dir}/${dmg_name}" + if [[ ! -f "$dmg_source" ]]; then + echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" + exit 1 + fi + + codesign --verify --strict --verbose=2 "$dmg_source" + xcrun stapler validate "$dmg_source" + cp "$dmg_source" "$dest/$dmg_name" + fi + + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + python3 \ + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + + - name: Compress artifacts + shell: bash + run: | + set -euo pipefail + + dest="dist/${{ matrix.target }}" + for f in "$dest"/*; do + base="$(basename "$f")" + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + continue + fi + + tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" + zstd -T0 -19 --rm "$dest/$base" + done + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }} + path: | + codex-rs/dist/${{ matrix.target }}/* + build-windows: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: @@ -561,6 +858,7 @@ jobs: secrets: inherit argument-comment-lint-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -568,23 +866,53 @@ jobs: publish: true zsh-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml release: needs: + - tag-check - build + - stage-signed-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets + if: >- + ${{ + always() && + needs.tag-check.result == 'success' && + ( + ( + github.event_name == 'workflow_dispatch' && + inputs.release_mode == 'promote_signed' && + needs.stage-signed-macos.result == 'success' && + needs.build.result == 'skipped' && + needs.build-windows.result == 'skipped' && + needs.argument-comment-lint-release-assets.result == 'skipped' && + needs.zsh-release-assets.result == 'skipped' + ) || + ( + (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && + needs.build.result == 'success' && + needs.stage-signed-macos.result == 'skipped' && + needs.build-windows.result == 'success' && + needs.argument-comment-lint-release-assets.result == 'success' && + needs.zsh-release-assets.result == 'success' + ) + ) + }} name: release runs-on: ubuntu-latest permissions: contents: write actions: read env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} @@ -602,6 +930,7 @@ jobs: - name: Define release mode id: release_mode run: | + echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" - name: Generate release notes from tag commit message @@ -628,6 +957,100 @@ jobs: with: path: dist + - name: Validate unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --json conclusion,event,headBranch,headSha,status,workflowName,url \ + --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" + IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" + expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + + if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$event" != "workflow_dispatch" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$head_sha" != "$expected_head_sha" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" + echo "Run URL: ${run_url}" + exit 1 + fi + + - name: Download artifacts from unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh run download "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist + + - name: Remove unsigned macOS staging artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + run: | + set -euo pipefail + find dist -mindepth 1 -maxdepth 1 -type d \ + -name '*-apple-darwin*-unsigned' \ + -exec rm -rf {} + + + - name: Re-upload promoted Linux x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-unknown-linux-musl + path: dist/x86_64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Linux arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-unknown-linux-musl + path: dist/aarch64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Windows x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-pc-windows-msvc + path: dist/x86_64-pc-windows-msvc/* + if-no-files-found: error + + - name: Re-upload promoted Windows arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-pc-windows-msvc + path: dist/aarch64-pc-windows-msvc/* + if-no-files-found: error + - name: List run: ls -R dist/ @@ -742,8 +1165,10 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | + workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ --release-version "$RELEASE_VERSION" \ + --workflow-url "$workflow_url" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk @@ -761,11 +1186,38 @@ jobs: tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** + overwrite_files: true 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, '-') }} + - name: Clean up signed promotion handoff assets + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" + gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ + --jq '.[] | [.id, .name] | @tsv' | + while IFS=$'\t' read -r asset_id asset_name; do + if [[ -z "$asset_id" || -z "$asset_name" ]]; then + continue + fi + + delete_asset=false + if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then + delete_asset=true + fi + + if [[ "$delete_asset" == "true" ]]; then + echo "Deleting release asset ${asset_name}" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" + fi + done + - if: ${{ env.SIGN_MACOS == 'true' }} uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: @@ -774,14 +1226,6 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - 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: @@ -816,7 +1260,15 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_npm == 'true' }} + # promote_signed intentionally skips build jobs that are ancestors of release; + # include the !cancelled() status function so Actions does not apply its implicit + # success() check to the whole dependency chain before evaluating release outputs. + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.should_publish_npm == 'true' + }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -974,7 +1426,12 @@ jobs: # need release follow-up, but should not invalidate the Rust release itself. publish-python-runtime: # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_python_runtime == 'true' }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.should_publish_python_runtime == 'true' + }} name: publish-python-runtime needs: release runs-on: ubuntu-latest @@ -1015,7 +1472,13 @@ 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: ${{ needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + 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 @@ -1035,7 +1498,12 @@ jobs: update-branch: name: Update latest-alpha-cli branch - if: ${{ needs.release.outputs.sign_macos == 'true' }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' + }} permissions: contents: write needs: release diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 20197567ab..eb2439401b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1907,6 +1907,7 @@ dependencies = [ "codex-hooks", "codex-login", "codex-mcp", + "codex-memories-extension", "codex-memories-write", "codex-model-provider", "codex-model-provider-info", @@ -2715,6 +2716,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -3785,6 +3787,7 @@ dependencies = [ "codex-protocol", "codex-realtime-webrtc", "codex-rollout", + "codex-sandboxing", "codex-shell-command", "codex-state", "codex-terminal-detection", @@ -3794,6 +3797,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-elapsed", "codex-utils-fuzzy-match", + "codex-utils-home-dir", "codex-utils-oss", "codex-utils-path", "codex-utils-plugins", @@ -3913,6 +3917,7 @@ version = "0.0.0" dependencies = [ "clap", "codex-protocol", + "codex-shell-command", "pretty_assertions", "serde", "toml 0.9.11+spec-1.1.0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 14b1264a81..7207fd3aba 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -470,7 +470,6 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-memories-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b4a72229b..c744eb3f6e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -138,7 +138,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; @@ -201,11 +200,11 @@ fn sample_thread_start_response( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -257,11 +256,11 @@ fn sample_thread_resume_response_with_source( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -279,6 +278,7 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest }, UserInput::Image { url: "https://example.com/a.png".to_string(), + detail: None, }, ], ..Default::default() @@ -366,9 +366,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo session_source: SessionSource::Exec, model: "gpt-5".to_string(), model_provider: "openai".to_string(), - permission_profile: CorePermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: CorePermissionProfile::read_only(), permission_profile_cwd: PathBuf::from("/tmp"), reasoning_effort: None, reasoning_summary: None, @@ -399,6 +397,7 @@ fn sample_turn_steer_request( }, UserInput::LocalImage { path: "/tmp/a.png".into(), + detail: None, }, ], responsesapi_client_metadata: None, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 71c46d808a..bfb224d899 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -12,7 +12,6 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::SessionSource as AppServerSessionSource; @@ -29,7 +28,6 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use std::collections::HashSet; @@ -142,10 +140,6 @@ fn sample_thread(thread_id: &str) -> Thread { } } -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() -} - fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { thread: sample_thread("thread-1"), @@ -153,11 +147,11 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -170,11 +164,11 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -187,11 +181,11 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6fe99b35e..ad45893bbe 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -719,202 +739,6 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FsCopyParams": { "description": "Copy a file or directory tree on the host filesystem.", "properties": { @@ -1235,8 +1059,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -1732,194 +1554,6 @@ ], "type": "string" }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -2788,6 +2422,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -4293,6 +3943,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -4313,6 +3974,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index e9c1b2a5c2..0fa2cf51c1 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2060,6 +2060,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "ItemCompletedNotification": { "properties": { "completedAtMs": { @@ -3048,12 +3055,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "type": "object" @@ -5116,6 +5127,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -5136,6 +5158,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 9aeea960b5..07e6dab174 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 @@ -5627,14 +5627,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5642,31 +5634,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -7139,6 +7106,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", @@ -7146,9 +7120,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -8717,6 +8695,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible API shape for ChatGPT workspace login restrictions." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -9948,8 +9940,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -11649,194 +11639,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/v2/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/v2/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -13533,12 +13335,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -14152,6 +13958,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -15598,7 +15420,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -17090,7 +16912,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -17398,7 +17220,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -18303,6 +18125,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -18323,6 +18156,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, 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 8e7ede41cd..26be1070fa 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 @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -3508,6 +3475,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", @@ -3515,9 +3489,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -5086,6 +5064,20 @@ ], "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible API shape for ChatGPT workspace login restrictions." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -6428,8 +6420,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -8178,194 +8168,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -10062,12 +9864,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -10681,6 +10487,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -13422,7 +13244,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -14914,7 +14736,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -15222,7 +15044,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -16127,6 +15949,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -16147,6 +15980,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index f29483862c..b6e939386b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -5,6 +5,26 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "CommandExecTerminalSize": { "description": "PTY size in character cells for `command/exec` PTY sessions.", "properties": { @@ -27,202 +47,6 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "NetworkAccess": { "enum": [ "restricted", @@ -230,135 +54,6 @@ ], "type": "string" }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "SandboxPolicy": { "oneOf": [ { 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 cb9176e560..81364a6f40 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -228,6 +228,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", @@ -235,9 +242,13 @@ ] }, "forced_chatgpt_workspace_id": { - "type": [ - "string", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + }, + { + "type": "null" + } ] }, "forced_login_method": { @@ -581,6 +592,20 @@ } ] }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible API shape for ChatGPT workspace login restrictions." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 6909415c2a..7aed9d9722 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -285,6 +285,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1179,6 +1186,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1199,6 +1217,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 758ceba32d..80a3cd2425 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -285,6 +285,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1179,6 +1186,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1199,6 +1217,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 6973d15baa..74420ea57e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -145,8 +145,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -732,6 +730,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json index 85be3316d7..2f305c7d78 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json @@ -22,12 +22,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 9afd1ae514..882fa1f598 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 29d67403cd..102cfa0299 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -64,65 +60,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "SandboxMode": { "enum": [ "read-only", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 6e74ab4ac8..3d66152a60 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -756,6 +527,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -937,135 +715,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2370,6 +2019,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2390,6 +2050,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -2605,7 +2276,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index f78fbaf27e..0693b19028 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 4268ad203a..d2e4ca6728 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index fb0d80a047..431ca759de 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 5f07fe0149..253ebf4572 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -208,8 +204,6 @@ }, "ImageDetail": { "enum": [ - "auto", - "low", "high", "original" ], @@ -298,65 +292,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -862,6 +797,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 727b7a3fb2..f379455b59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -756,6 +527,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -937,135 +715,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2370,6 +2019,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2390,6 +2050,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -2605,7 +2276,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 204828c732..8d807d76cb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9a60049a61..99b25490ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,65 +90,6 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index bf03f0fb55..7d31c52f1c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -756,6 +527,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -937,135 +715,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -2370,6 +2019,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2390,6 +2050,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -2605,7 +2276,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 759b5990be..3ec843e817 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index f64400129a..ea48e34180 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -448,6 +448,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1827,6 +1834,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1847,6 +1865,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index e5e2558e9c..40b81e5a34 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 1ef33d4301..ecea8f1997 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -99,6 +99,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -114,65 +121,6 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -410,6 +358,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -430,6 +389,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index a2eff7fdd8..ffe0a28d15 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 0952db2aca..cbde541e52 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -422,6 +422,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -1452,6 +1459,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1490,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index a064d9e7e3..1b7cfbf400 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -20,6 +20,13 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "high", + "original" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -75,6 +82,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -95,6 +113,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts index a48f07c088..5a62cc32f1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ImageDetail = "auto" | "low" | "high" | "original"; +export type ImageDetail = "high" | "original"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 6fa9beee25..e5e960ff81 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -14,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index cc7e340ea3..ba24663e87 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -10,6 +10,7 @@ import type { JsonValue } from "../serde_json/JsonValue"; import type { AnalyticsConfig } from "./AnalyticsConfig"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; +import type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; @@ -19,4 +20,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts similarity index 51% rename from codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts index 0b25a769a9..d0582c8f4f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ForcedChatgptWorkspaceIds.ts @@ -2,4 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PermissionProfileNetworkPermissions = { enabled: boolean, }; +/** + * Backward-compatible API shape for ChatGPT workspace login restrictions. + */ +export type ForcedChatgptWorkspaceIds = string | Array; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts deleted file mode 100644 index 7642c27650..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -import type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; - -export type PermissionProfile = { "type": "managed", network: PermissionProfileNetworkPermissions, fileSystem: PermissionProfileFileSystemPermissions, } | { "type": "disabled" } | { "type": "external", network: PermissionProfileNetworkPermissions, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts deleted file mode 100644 index 29aeceb433..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; - -export type PermissionProfileFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts deleted file mode 100644 index c619edcea8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts deleted file mode 100644 index a415bd0028..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; - -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts index 8c63ab9029..403b0e6457 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -6,4 +6,4 @@ import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionSta /** * Current remote-control connection status and remote identity exposed to clients. */ -export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, installationId: string, environmentId: string | null, }; +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, serverName: string, installationId: string, environmentId: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c44533ec1a..c5b1201c26 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f91756c7c6..7a4f90377c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 9573bd7dee..38859a3805 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts b/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts index 38abc2338b..2ac37c5228 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts @@ -1,10 +1,11 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageDetail } from "../ImageDetail"; import type { TextElement } from "./TextElement"; export type UserInput = { "type": "text", text: string, /** * UI-defined spans within `text` used to render or persist special elements. */ -text_elements: Array, } | { "type": "image", url: string, } | { "type": "localImage", path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; +text_elements: Array, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 583b720e59..aae7732658 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; @@ -113,6 +112,7 @@ export type { FileSystemPath } from "./FileSystemPath"; export type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; export type { FileSystemSpecialPath } from "./FileSystemSpecialPath"; export type { FileUpdateChange } from "./FileUpdateChange"; +export type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; export type { FsChangedNotification } from "./FsChangedNotification"; export type { FsCopyParams } from "./FsCopyParams"; export type { FsCopyResponse } from "./FsCopyResponse"; @@ -255,11 +255,6 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; -export type { PermissionProfile } from "./PermissionProfile"; -export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; -export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 0f9b33671b..d44b67825d 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2747,7 +2747,7 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k let _guard = TempDirGuard(output_dir.clone()); let path = output_dir.join("CommandExecParams.ts"); let content = r#"import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; -import type { PermissionProfile } from "./PermissionProfile"; +import type { ActivePermissionProfile } from "./ActivePermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; export type CommandExecParams = {/** @@ -2770,12 +2770,12 @@ size?: CommandExecTerminalSize | null, /** */ sandboxPolicy?: SandboxPolicy | null, /** - * Optional full permissions profile for this command. + * Optional active permissions profile for this command. * * Defaults to the user's configured permissions when omitted. Cannot be * combined with `sandboxPolicy`. */ -permissionProfile?: PermissionProfile | null}; +permissionProfile?: ActivePermissionProfile | null}; "#; fs::write(&path, content)?; @@ -2789,11 +2789,13 @@ permissionProfile?: PermissionProfile | null}; let filtered = fs::read_to_string(&path)?; assert_eq!( - filtered.contains("permissionProfile?: PermissionProfile"), + filtered.contains("permissionProfile?: ActivePermissionProfile"), false ); assert_eq!( - filtered.contains(r#"import type { PermissionProfile } from "./PermissionProfile";"#), + filtered.contains( + r#"import type { ActivePermissionProfile } from "./ActivePermissionProfile";"# + ), false ); assert_eq!(filtered.contains("sandboxPolicy?: SandboxPolicy"), true); diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d69c30a3a4..2eaf4806f9 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -223,6 +223,7 @@ macro_rules! client_request_definitions { /// Typed response from the server to the client. #[derive(Serialize, Deserialize, Debug, Clone)] + #[allow(clippy::large_enum_variant)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientResponse { $( @@ -621,12 +622,12 @@ client_request_definitions! { }, PluginList => "plugin/list" { params: v2::PluginListParams, - serialization: global_shared_read("plugin-read"), + serialization: None, response: v2::PluginListResponse, }, PluginRead => "plugin/read" { params: v2::PluginReadParams, - serialization: global_shared_read("plugin-read"), + serialization: None, response: v2::PluginReadResponse, }, PluginSkillRead => "plugin/skill/read" { @@ -818,6 +819,12 @@ client_request_definitions! { serialization: global("remote-control"), response: v2::RemoteControlDisableResponse, }, + #[experimental("remoteControl/status/read")] + RemoteControlStatusRead => "remoteControl/status/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("remote-control"), + response: v2::RemoteControlStatusReadResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1750,12 +1757,7 @@ mod tests { marketplace_kinds: None, }, }; - assert_eq!( - plugin_list.serialization_scope(), - Some(ClientRequestSerializationScope::GlobalSharedRead( - "plugin-read" - )) - ); + assert_eq!(plugin_list.serialization_scope(), None); let plugin_read = ClientRequest::PluginRead { request_id: request_id(), @@ -1765,12 +1767,7 @@ mod tests { plugin_name: "plugin-a".to_string(), }, }; - assert_eq!( - plugin_read.serialization_scope(), - Some(ClientRequestSerializationScope::GlobalSharedRead( - "plugin-read" - )) - ); + assert_eq!(plugin_read.serialization_scope(), None); let plugin_uninstall = ClientRequest::PluginUninstall { request_id: request_id(), @@ -2390,11 +2387,11 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }, @@ -2434,13 +2431,13 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "runtimeWorkspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": null, "activePermissionProfile": null, "reasoningEffort": null } @@ -3082,7 +3079,7 @@ mod tests { env: None, size: None, sandbox_policy: None, - permission_profile: Some(v2::PermissionProfile::Disabled), + permission_profile: Some(v2::ActivePermissionProfile::read_only()), }, }; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 1121d3a35b..3918e4a9bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1077,12 +1077,18 @@ impl ThreadHistoryBuilder { }); } if let Some(images) = &payload.images { - for image in images { - content.push(UserInput::Image { url: image.clone() }); + for (idx, image) in images.iter().enumerate() { + content.push(UserInput::Image { + url: image.clone(), + detail: payload.image_details.get(idx).copied().flatten(), + }); } } - for path in &payload.local_images { - content.push(UserInput::LocalImage { path: path.clone() }); + for (idx, path) in payload.local_images.iter().enumerate() { + content.push(UserInput::LocalImage { + path: path.clone(), + detail: payload.local_image_details.get(idx).copied().flatten(), + }); } content } @@ -1203,6 +1209,7 @@ mod tests { use codex_protocol::items::UserMessageItem as CoreUserMessageItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -1241,6 +1248,7 @@ mod tests { images: Some(vec!["https://example.com/one.png".into()]), text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Hi there".into(), @@ -1258,6 +1266,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Reply two".into(), @@ -1288,6 +1297,7 @@ mod tests { }, UserInput::Image { url: "https://example.com/one.png".into(), + detail: None, } ], } @@ -1335,6 +1345,45 @@ mod tests { ); } + #[test] + fn rebuilds_user_message_image_details_from_legacy_events() { + let local_path = PathBuf::from("/tmp/local.png"); + let events = vec![RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "inspect these".into(), + images: Some(vec!["https://example.com/image.png".into()]), + image_details: vec![Some(ImageDetail::Original)], + local_images: vec![local_path.clone()], + local_image_details: vec![Some(ImageDetail::Original)], + text_elements: Vec::new(), + }, + ))]; + + let turns = build_turns_from_rollout_items(&events); + + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + content: vec![ + UserInput::Text { + text: "inspect these".into(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".into(), + detail: Some(ImageDetail::Original), + }, + UserInput::LocalImage { + path: local_path, + detail: Some(ImageDetail::Original), + }, + ], + } + ); + } + #[test] fn ignores_non_plan_item_lifecycle_events() { let turn_id = "turn-1"; @@ -1351,6 +1400,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ItemStarted(ItemStartedEvent { thread_id, @@ -1428,6 +1478,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { call_id: "ig_123".into(), @@ -1485,6 +1536,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentReasoning(AgentReasoningEvent { text: "first summary".into(), @@ -1537,6 +1589,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Working...".into(), @@ -1554,6 +1607,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Second attempt complete.".into(), @@ -1624,6 +1678,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), @@ -1635,6 +1690,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), @@ -1647,6 +1703,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A3".into(), @@ -1712,6 +1769,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), @@ -1723,6 +1781,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), @@ -1754,12 +1813,14 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::UserMessage(UserMessageEvent { message: "Steer".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -1812,6 +1873,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: "search-1".into(), @@ -1984,6 +2046,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::DynamicToolCallRequest( codex_protocol::dynamic_tools::DynamicToolCallRequest { @@ -2049,6 +2112,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-declined".into(), @@ -2138,6 +2202,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "review-guardian-exec".into(), @@ -2221,6 +2286,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "review-guardian-execve".into(), @@ -2284,6 +2350,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2303,6 +2370,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-late".into(), @@ -2376,6 +2444,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2395,6 +2464,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-unknown-turn".into(), @@ -2463,6 +2533,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id: "patch-call".into(), @@ -2527,6 +2598,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "patch-call".into(), @@ -2591,6 +2663,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2610,6 +2683,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2657,6 +2731,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2676,6 +2751,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), @@ -2748,6 +2824,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { call_id: "resume-1".into(), @@ -2805,6 +2882,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent { call_id: "spawn-1".into(), @@ -2866,6 +2944,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabAgentInteractionBegin( codex_protocol::protocol::CollabAgentInteractionBeginEvent { @@ -2929,6 +3008,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "done".into(), @@ -2965,6 +3045,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -3020,6 +3101,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::Error(ErrorEvent { message: "stream failure".into(), @@ -3077,6 +3159,7 @@ mod tests { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::ResponseItem(hook_prompt), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index f8e9afdd12..3c45c20b8f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -22,6 +22,7 @@ use serde::Serialize; use ts_rs::TS; use crate::protocol::common::AuthMode; +use crate::protocol::v2::ForcedChatgptWorkspaceIds; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -201,7 +202,7 @@ pub struct UserSavedConfig { pub approval_policy: Option, pub sandbox_mode: Option, pub sandbox_settings: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub model: Option, pub model_reasoning_effort: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs index ff0cecf4f9..d476dcb90e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -1,4 +1,4 @@ -use super::PermissionProfile; +use super::ActivePermissionProfile; use super::SandboxPolicy; use codex_experimental_api_macros::ExperimentalApi; use schemars::JsonSchema; @@ -100,13 +100,13 @@ pub struct CommandExecParams { /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Optional full permissions profile for this command. + /// Optional active permissions profile for this command. /// /// Defaults to the user's configured permissions when omitted. Cannot be /// combined with `sandboxPolicy`. #[experimental("command/exec.permissionProfile")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permission_profile: Option, } /// Final buffered result for `command/exec`. 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 108ce9252f..b46515d811 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -225,6 +225,24 @@ pub struct AppsConfig { pub apps: HashMap, } +/// Backward-compatible API shape for ChatGPT workspace login restrictions. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + pub fn into_vec(self) -> Vec { + match self { + Self::Single(value) => vec![value], + Self::Multiple(values) => values, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -242,7 +260,7 @@ pub struct Config { pub approvals_reviewer: Option, pub sandbox_mode: Option, pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, pub web_search: Option, pub tools: Option, @@ -261,6 +279,7 @@ pub struct Config { #[experimental("config/read.apps")] #[serde(default)] pub apps: Option, + pub desktop: Option>, #[serde(default, flatten)] pub additional: HashMap, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb..3299695497 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,24 +5,22 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::Serializer; use std::num::NonZeroUsize; use std::path::PathBuf; use ts_rs::TS; @@ -135,13 +133,6 @@ pub struct AdditionalNetworkPermissions { pub enabled: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionProfileNetworkPermissions { - pub enabled: bool, -} - impl From for AdditionalNetworkPermissions { fn from(value: CoreNetworkPermissions) -> Self { Self { @@ -158,24 +149,6 @@ impl From for CoreNetworkPermissions { } } -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkSandboxPolicy) -> Self { - Self { - enabled: value.is_enabled(), - } - } -} - -impl From for CoreNetworkSandboxPolicy { - fn from(value: PermissionProfileNetworkPermissions) -> Self { - if value.enabled { - Self::Enabled - } else { - Self::Restricted - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -316,116 +289,6 @@ impl From for CoreFileSystemSandboxEntry { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileFileSystemPermissions { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - glob_scan_max_depth: Option, - }, - Unrestricted, -} - -impl From for PermissionProfileFileSystemPermissions { - fn from(value: CoreManagedFileSystemPermissions) -> Self { - match value { - CoreManagedFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -impl From for CoreManagedFileSystemPermissions { - fn from(value: PermissionProfileFileSystemPermissions) -> Self { - match value { - PermissionProfileFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfile { - /// Codex owns sandbox construction for this profile. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Managed { - network: PermissionProfileNetworkPermissions, - file_system: PermissionProfileFileSystemPermissions, - }, - /// Do not apply an outer sandbox. - Disabled, - /// Filesystem isolation is enforced by an external caller. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - External { - network: PermissionProfileNetworkPermissions, - }, -} - -impl From for PermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - match value { - CorePermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - network: network.into(), - file_system: file_system.into(), - }, - CorePermissionProfile::Disabled => Self::Disabled, - CorePermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -impl From for CorePermissionProfile { - fn from(value: PermissionProfile) -> Self { - match value { - PermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - file_system: file_system.into(), - network: network.into(), - }, - PermissionProfile::Disabled => Self::Disabled, - PermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -437,40 +300,18 @@ pub struct ActivePermissionProfile { /// inheritance. This is currently always `null`. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } +impl ActivePermissionProfile { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + extends: None, } } -} -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } + pub fn read_only() -> Self { + CoreActivePermissionProfile::read_only().into() } } @@ -479,11 +320,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,40 +329,104 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileSelectionParams { + id: String, + legacy_additional_writable_roots: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, +impl PermissionProfileSelectionParams { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + legacy_additional_writable_roots: Vec::new(), + } + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn into_id(self) -> String { + self.id + } + + pub fn legacy_additional_writable_roots(&self) -> &[AbsolutePathBuf] { + &self.legacy_additional_writable_roots + } +} + +impl From for PermissionProfileSelectionParams { + fn from(id: String) -> Self { + Self::new(id) + } +} + +impl Serialize for PermissionProfileSelectionParams { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.id) + } +} + +impl<'de> Deserialize<'de> for PermissionProfileSelectionParams { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Id(String), + LegacyProfile { + #[serde(rename = "type")] + _type: LegacyPermissionProfileSelectionType, + id: String, + #[serde(default)] + modifications: Option>, + }, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + enum LegacyPermissionProfileSelectionType { + Profile, + } + + #[derive(Deserialize)] + #[serde(tag = "type", rename_all = "camelCase")] + enum LegacyPermissionProfileModificationParams { + #[serde(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, + } + + match Wire::deserialize(deserializer)? { + Wire::Id(id) => Ok(Self::new(id)), + Wire::LegacyProfile { + id, modifications, .. + } => { + let legacy_additional_writable_roots = modifications + .unwrap_or_default() + .into_iter() + .map(|modification| match modification { + LegacyPermissionProfileModificationParams::AdditionalWritableRoot { + path, + } => path, + }) + .collect(); + Ok(Self { + id, + legacy_additional_writable_roots, + }) + } + } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] 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 e89a9d19b8..c8ad617e7d 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 @@ -9,6 +9,7 @@ use ts_rs::TS; #[ts(export_to = "v2/")] pub struct RemoteControlStatusChangedNotification { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -18,6 +19,7 @@ pub struct RemoteControlStatusChangedNotification { #[ts(export_to = "v2/")] pub struct RemoteControlEnableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -27,6 +29,17 @@ pub struct RemoteControlEnableResponse { #[ts(export_to = "v2/")] pub struct RemoteControlDisableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, + 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 RemoteControlStatusReadResponse { + pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -45,11 +58,13 @@ impl From for RemoteControlEnableRespons fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } @@ -60,11 +75,13 @@ impl From for RemoteControlDisableRespon fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7a8436b71e..8e4311512f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -15,7 +15,7 @@ use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; @@ -505,48 +505,6 @@ fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { .expect_err("zero glob scan depth should fail deserialization"); } -#[test] -fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreManagedFileSystemPermissions::Restricted { - entries: vec![CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - } - ); - assert_eq!( - CoreManagedFileSystemPermissions::from(permissions), - core_permissions - ); -} - -#[test] -fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "type": "restricted", - "entries": [], - "globScanMaxDepth": 0, - })) - .expect_err("zero glob scan depth should fail deserialization"); -} - #[test] fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { let special_path = serde_json::from_value::(json!({ @@ -655,6 +613,61 @@ fn permissions_request_approval_response_accepts_strict_auto_review() { assert_eq!(response.strict_auto_review, Some(true)); } +#[test] +fn permission_profile_selection_accepts_legacy_object_shape() { + let additional_root = absolute_path("additional-root"); + let params = json!({ + "permissions": { + "type": "profile", + "id": ":workspace", + "modifications": [ + { + "type": "additionalWritableRoot", + "path": additional_root, + } + ], + }, + }); + + let start: ThreadStartParams = + serde_json::from_value(params.clone()).expect("thread/start params deserialize"); + assert_legacy_permission_profile_selection(start.permissions, &additional_root); + + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": params["permissions"].clone(), + })) + .expect("thread/resume params deserialize"); + assert_legacy_permission_profile_selection(resume.permissions, &additional_root); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": params["permissions"].clone(), + })) + .expect("thread/fork params deserialize"); + assert_legacy_permission_profile_selection(fork.permissions, &additional_root); + + let turn: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "input": [], + "permissions": params["permissions"].clone(), + })) + .expect("turn/start params deserialize"); + assert_legacy_permission_profile_selection(turn.permissions, &additional_root); +} + +fn assert_legacy_permission_profile_selection( + selection: Option, + additional_root: &AbsolutePathBuf, +) { + let selection = selection.expect("permissions should be present"); + assert_eq!(selection.id(), ":workspace"); + assert_eq!( + selection.legacy_additional_writable_roots(), + std::slice::from_ref(additional_root) + ); +} + #[test] fn fs_get_metadata_response_round_trips_minimal_fields() { let response = FsGetMetadataResponse { @@ -1531,6 +1544,7 @@ fn config_granular_approval_policy_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1564,6 +1578,7 @@ fn config_approvals_reviewer_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1619,6 +1634,7 @@ fn config_nested_profile_granular_approval_policy_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1668,6 +1684,7 @@ fn config_nested_profile_approvals_reviewer_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -2260,9 +2277,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, CoreUserInput::Image { image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), }, CoreUserInput::LocalImage { path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), }, CoreUserInput::Skill { name: "skill-creator".to_string(), @@ -2286,9 +2305,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, UserInput::Image { url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), }, UserInput::LocalImage { path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), }, UserInput::Skill { name: "skill-creator".to_string(), @@ -2503,6 +2524,33 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ); } +#[test] +fn user_input_into_core_preserves_image_detail() { + assert_eq!( + UserInput::Image { + url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), + } + .into_core(), + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), + } + ); + + assert_eq!( + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), + } + .into_core(), + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), + } + ); +} + #[test] fn skills_list_params_serialization_uses_force_reload() { assert_eq!( @@ -3439,9 +3487,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(fork.active_permission_profile, None); @@ -3469,6 +3514,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { responsesapi_client_metadata: None, environments: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index d9a11be185..42807f6711 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,7 +1,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfile; use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; @@ -16,6 +15,7 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -109,6 +109,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -118,10 +123,10 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for this thread. Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -194,6 +199,8 @@ pub struct ThreadTurnContextUpdateParams { /// Select a named permissions profile for subsequent turns. Cannot be /// combined with `sandboxPolicy`. #[experimental("thread/turnContext/update.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, /// Override the model for subsequent turns. @@ -281,6 +288,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -289,14 +301,8 @@ pub struct ThreadStartResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/start.activePermissionProfile")] @@ -350,6 +356,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -359,10 +370,11 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the resumed thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/resume.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -396,6 +408,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -404,14 +421,8 @@ pub struct ThreadResumeResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/resume.activePermissionProfile")] @@ -456,6 +467,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -465,10 +481,11 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the forked thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/fork.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -505,6 +522,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -513,14 +535,8 @@ pub struct ThreadForkResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/fork.activePermissionProfile")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 61a09bfbf5..8781dd2bd5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -7,6 +7,7 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; @@ -64,6 +65,12 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots for this turn and + /// subsequent turns. Relative paths are resolved against the effective + /// cwd for the turn. + #[experimental("turn/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -75,11 +82,11 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Select a named permissions profile id for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, /// Override the model for this turn and subsequent turns. @@ -243,9 +250,15 @@ pub enum UserInput { text_elements: Vec, }, Image { + #[serde(default)] + #[ts(optional)] + detail: Option, url: String, }, LocalImage { + #[serde(default)] + #[ts(optional)] + detail: Option, path: PathBuf, }, Skill { @@ -268,8 +281,11 @@ impl UserInput { text, text_elements: text_elements.into_iter().map(Into::into).collect(), }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Image { url, detail } => CoreUserInput::Image { + image_url: url, + detail, + }, + UserInput::LocalImage { path, detail } => CoreUserInput::LocalImage { path, detail }, UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, } @@ -286,8 +302,11 @@ impl From for UserInput { text, text_elements: text_elements.into_iter().map(Into::into).collect(), }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Image { image_url, detail } => UserInput::Image { + url: image_url, + detail, + }, + CoreUserInput::LocalImage { path, detail } => UserInput::LocalImage { path, detail }, CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, _ => unreachable!("unsupported user input variant"), diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index 60dfc3d845..9ec6e4d004 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -6,7 +6,6 @@ use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; -use gethostname::gethostname; use std::io; use std::io::ErrorKind; use tracing::info; @@ -195,11 +194,11 @@ pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, installation_id: &str, + server_name: &str, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; - let server_name = gethostname().to_string_lossy().trim().to_string(); let request = EnrollRemoteServerRequest { - name: server_name.clone(), + name: server_name.to_string(), os: std::env::consts::OS, arch: std::env::consts::ARCH, app_server_version: env!("CARGO_PKG_VERSION"), @@ -255,7 +254,7 @@ pub(super) async fn enroll_remote_control_server( account_id: auth.account_id.clone(), environment_id: enrollment.environment_id, server_id: enrollment.server_id, - server_name, + server_name: server_name.to_string(), }) } @@ -464,6 +463,7 @@ mod tests { account_id: "account_id".to_string(), }, "11111111-1111-4111-8111-111111111111", + "test-server", ) .await .expect_err("invalid response should fail to parse"); 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 8722f4e9ee..ae6d79462c 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,7 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; @@ -112,15 +113,8 @@ impl RemoteControlHandle { 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() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } @@ -132,6 +126,22 @@ impl RemoteControlHandle { } } +fn remote_control_status_with_connection_status( + status: &RemoteControlStatusChangedNotification, + connection_status: RemoteControlConnectionStatus, +) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status: connection_status, + server_name: status.server_name.clone(), + installation_id: status.installation_id.clone(), + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + } +} + pub async fn start_remote_control( config: RemoteControlStartConfig, state_db: Option>, @@ -154,12 +164,14 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let server_name = gethostname().to_string_lossy().trim().to_string(); let initial_status = RemoteControlStatusChangedNotification { status: if initial_enabled { RemoteControlConnectionStatus::Connecting } else { RemoteControlConnectionStatus::Disabled }, + server_name: server_name.clone(), installation_id: config.installation_id.clone(), environment_id: None, }; @@ -171,6 +183,7 @@ pub async fn start_remote_control( remote_control_url: config.remote_control_url, installation_id: config.installation_id, remote_control_target, + server_name, }, state_db, auth_manager, 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 3dcfb81ce5..fb1512fedb 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 @@ -121,6 +121,10 @@ fn remote_control_url_for_listener(listener: &TcpListener) -> String { format!("http://{addr}/backend-api/") } +fn test_server_name() -> String { + gethostname().to_string_lossy().trim().to_string() +} + async fn expect_remote_control_status( status_rx: &mut watch::Receiver, expected_status: Option, @@ -134,6 +138,7 @@ async fn expect_remote_control_status( if let Some(expected_status) = expected_status { assert_eq!(status.status, expected_status); } + assert_eq!(status.server_name, test_server_name()); assert_eq!(status.installation_id, TEST_INSTALLATION_ID); assert_eq!(status.environment_id.as_deref(), expected_environment_id); } @@ -630,6 +635,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -698,6 +704,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, @@ -708,6 +715,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.disable(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -716,6 +724,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, @@ -732,6 +741,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.enable().expect("enable should succeed"), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -740,6 +750,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 472639bc68..f117aec3b4 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -15,6 +15,7 @@ use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; +use super::remote_control_status_with_connection_status; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -216,6 +217,7 @@ impl WebsocketState { pub(crate) struct RemoteControlWebsocket { remote_control_url: String, installation_id: String, + server_name: String, remote_control_target: Option, state_db: Option>, auth_manager: Arc, @@ -235,6 +237,7 @@ pub(crate) struct RemoteControlWebsocketConfig { pub(crate) remote_control_url: String, pub(crate) installation_id: String, pub(crate) remote_control_target: Option, + pub(crate) server_name: String, } enum ConnectOutcome { @@ -260,15 +263,8 @@ impl RemoteControlStatusPublisher { fn publish_status(&self, connection_status: RemoteControlConnectionStatus) { self.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() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } @@ -285,6 +281,7 @@ impl RemoteControlStatusPublisher { } let next_status = RemoteControlStatusChangedNotification { status: status.status, + server_name: status.server_name.clone(), installation_id: status.installation_id.clone(), environment_id, }; @@ -301,6 +298,7 @@ impl RemoteControlStatusPublisher { #[derive(Clone, Copy)] pub(super) struct RemoteControlConnectOptions<'a> { installation_id: &'a str, + server_name: &'a str, subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, } @@ -327,6 +325,7 @@ impl RemoteControlWebsocket { Self { remote_control_url: config.remote_control_url, installation_id: config.installation_id, + server_name: config.server_name, remote_control_target: config.remote_control_target, state_db, auth_manager, @@ -454,6 +453,7 @@ impl RemoteControlWebsocket { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); let connect_options = RemoteControlConnectOptions { installation_id: &self.installation_id, + server_name: &self.server_name, subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; @@ -1076,7 +1076,10 @@ pub(super) async fn connect_remote_control_websocket( if let Some(loaded_enrollment) = loaded_enrollment.as_ref() { status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone())); } - *enrollment = loaded_enrollment; + *enrollment = loaded_enrollment.map(|mut enrollment| { + enrollment.server_name = connect_options.server_name.to_string(); + enrollment + }); } if enrollment.is_none() { @@ -1088,6 +1091,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, &auth, connect_options.installation_id, + connect_options.server_name, ) .await { @@ -1279,6 +1283,7 @@ mod tests { ) { let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }); @@ -1386,6 +1391,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1403,6 +1409,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1464,6 +1471,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1477,6 +1485,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1546,6 +1555,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1599,6 +1609,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1647,6 +1658,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1665,6 +1677,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1694,6 +1707,7 @@ mod tests { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), remote_control_target: Some(remote_control_target), + server_name: "test-server".to_string(), }, /*state_db*/ None, remote_control_auth_manager(), @@ -1738,6 +1752,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1759,6 +1774,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1773,6 +1789,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1788,6 +1805,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index c75461ecf7..95baac4e9e 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -54,6 +54,7 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-memories-extension = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ce7fdc48a1..6f4a9fa307 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,10 +130,10 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -156,7 +156,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/turnContext/update` — update the stored defaults used by subsequent turns without starting a turn. Omitted fields leave the current value unchanged; fields with explicit clearing support, such as `serviceTier`, accept `null` to clear the value. The response is `{ "turnContext": ... }` with the full effective state, and `thread/turnContext/updated` is emitted only when that state changes. `turn/start` emits the same notification when its turn-context overrides change the stored defaults. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. @@ -205,7 +205,8 @@ Example with notification opt-out: - `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. +- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `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. +- `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`; `serverName` is the local machine name used by this app-server process; `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**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). @@ -217,11 +218,11 @@ Example with notification opt-out: - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `config/read` — fetch the effective config on disk after resolving config layering. +- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). -- `config/value/write` — write a single config key/value to the user's config.toml on disk. -- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. +- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -237,7 +238,9 @@ Start a fresh thread when you need a new Codex conversation. "approvalPolicy": "never", "sandbox": "workspaceWrite", // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) @@ -650,7 +653,9 @@ You can optionally specify config overrides on the new turn. If specified, these "networkAccess": true }, // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", @@ -922,14 +927,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "permissionProfile": { // optional; defaults to user config - "type": "managed", - "fileSystem": { "type": "restricted", "entries": [ - { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, - { "path": { "type": "special", "value": { "kind": "project_roots", "subpath": null } }, "access": "write" } - ] }, - "network": { "enabled": false } - }, + "permissionProfile": { "id": ":workspace", "extends": null }, // optional; defaults to user config "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -948,7 +946,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin Notes: - Empty `command` arrays are rejected. -- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. +- Prefer `permissionProfile` for command permission overrides. It selects an active profile by id (for example `:read-only`, `:workspace`, or a user-defined `[permissions.]` profile) rather than accepting low-level filesystem/network permissions. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index ddd7bf1a38..5b137c1091 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2137,6 +2137,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "after rollback".to_string(), @@ -3210,6 +3211,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }), ); } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index a293daf4f2..689eae2e9d 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -18,6 +18,7 @@ where { let mut builder = ExtensionRegistryBuilder::::new(); codex_guardian::install(&mut builder, guardian_agent_spawner); + codex_memories_extension::install(&mut builder); Arc::new(builder.build()) } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 350acf002f..4482d326f6 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -41,7 +41,6 @@ use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; @@ -589,7 +588,7 @@ pub async fn run_main_with_transport_options( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile()) { config_warnings.push(ConfigWarningNotification { summary: warning, @@ -1003,14 +1002,9 @@ pub async fn run_main_with_transport_options( continue; } remote_control_status = status.clone(); + let notification = ServerNotification::RemoteControlStatusChanged(status); initialize_notification_sender - .send_server_notification(ServerNotification::RemoteControlStatusChanged( - RemoteControlStatusChangedNotification { - status: status.status, - installation_id: status.installation_id, - environment_id: status.environment_id, - }, - )) + .send_server_notification(notification) .await; } created = thread_created_rx.recv(), if listen_for_threads => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 050a07f99d..bd3868464c 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -354,6 +354,7 @@ impl MessageProcessor { arg0_paths.clone(), Arc::clone(&config), outgoing.clone(), + config_manager.clone(), ); let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone()); let feedback_processor = FeedbackRequestProcessor::new( @@ -897,6 +898,10 @@ impl MessageProcessor { .remote_control_processor .disable() .map(|response| Some(response.into())), + ClientRequest::RemoteControlStatusRead { .. } => self + .remote_control_processor + .status_read() + .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 c955d06ba2..a9625d3086 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -659,6 +659,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { }], responsesapi_client_metadata: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, sandbox_policy: None, permissions: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5e6cc42e02..41477b9ba6 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; @@ -361,6 +360,7 @@ use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +#[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index f32f87ed15..17d78fb20b 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -570,11 +570,11 @@ impl AccountRequestProcessor { } } - if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() - && chatgpt_account_id != expected_workspace + if let Some(expected_workspaces) = self.config.forced_chatgpt_workspace_id.as_deref() + && !expected_workspaces.contains(&chatgpt_account_id) { return Err(invalid_request(format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + "External auth must use one of workspace(s) {expected_workspaces:?}, but received {chatgpt_account_id:?}.", ))); } diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..2b07588c8a 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -5,6 +5,7 @@ pub(crate) struct CommandExecRequestProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, outgoing: Arc, + config_manager: ConfigManager, command_exec_manager: CommandExecManager, } @@ -13,11 +14,13 @@ impl CommandExecRequestProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, outgoing: Arc, + config_manager: ConfigManager, ) -> Self { Self { arg0_paths, config, outgoing, + config_manager, command_exec_manager: CommandExecManager::default(), } } @@ -114,6 +117,13 @@ impl CommandExecRequestProcessor { "`permissionProfile` cannot be combined with `sandboxPolicy`", )); } + let permission_profile = if let Some(active_permission_profile) = permission_profile { + Some(PermissionProfileSelectionParams::new( + active_permission_profile.id, + )) + } else { + None + }; if size.is_some() && !tty { return Err(invalid_params("command/exec size requires tty: true")); @@ -159,28 +169,6 @@ impl CommandExecRequestProcessor { }, None => None, }; - let managed_network_requirements_enabled = - self.config.managed_network_requirements_enabled(); - let started_network_proxy = match self.config.permissions.network.as_ref() { - Some(spec) => match spec - .start_proxy( - self.config.permissions.permission_profile.get(), - /*policy_decider*/ None, - /*blocked_request_observer*/ None, - managed_network_requirements_enabled, - NetworkProxyAuditMetadata::default(), - ) - .await - { - Ok(started) => Some(started), - Err(err) => { - return Err(internal_error(format!( - "failed to start managed network proxy: {err}" - ))); - } - }, - None => None, - }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); let output_bytes_cap = if disable_output_cap { None @@ -205,48 +193,42 @@ impl CommandExecRequestProcessor { } else { self.config.cwd.clone() }; - let exec_params = ExecParams { - command, - cwd: cwd.clone(), - expiration, - capture_policy, - env, - network: started_network_proxy - .as_ref() - .map(codex_core::config::StartedNetworkProxy::proxy), - sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level, - windows_sandbox_private_desktop: self - .config - .permissions - .windows_sandbox_private_desktop, - justification: None, - arg0: None, - }; - - let effective_permission_profile = if let Some(permission_profile) = permission_profile { - let permission_profile = - codex_protocol::models::PermissionProfile::from(permission_profile); - let (mut file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - let configured_file_system_sandbox_policy = - self.config.permissions.file_system_sandbox_policy(); - Self::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, + let ( + effective_permission_profile, + network_proxy_spec, + network_proxy_permission_profile, + managed_network_requirements_enabled, + ) = if let Some(permission_profile) = permission_profile { + let mut overrides = ConfigOverrides { + cwd: Some(cwd.to_path_buf()), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permission_profile), ); - let effective_permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - self.config - .permissions - .permission_profile - .can_set(&effective_permission_profile) + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(self.config.cwd.to_path_buf()), + ) + .await .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; - effective_permission_profile + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid permission profile: {warning}" + ))); + } + ( + config.permissions.effective_permission_profile(), + config.permissions.network.clone(), + config.permissions.permission_profile().clone(), + config.managed_network_requirements_enabled(), + ) } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { self.config .permissions @@ -264,12 +246,59 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile - .can_set(&permission_profile) + .can_set_permission_profile(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - permission_profile + ( + permission_profile, + self.config.permissions.network.clone(), + self.config.permissions.permission_profile().clone(), + self.config.managed_network_requirements_enabled(), + ) } else { - self.config.permissions.permission_profile() + ( + self.config.permissions.effective_permission_profile(), + self.config.permissions.network.clone(), + self.config.permissions.permission_profile().clone(), + self.config.managed_network_requirements_enabled(), + ) + }; + let started_network_proxy = match network_proxy_spec.as_ref() { + Some(spec) => match spec + .start_proxy( + &network_proxy_permission_profile, + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + { + Ok(started) => Some(started), + Err(err) => { + return Err(internal_error(format!( + "failed to start managed network proxy: {err}" + ))); + } + }, + None => None, + }; + let exec_params = ExecParams { + command, + cwd: cwd.clone(), + expiration, + capture_policy, + env, + network: started_network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy), + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level, + windows_sandbox_private_desktop: self + .config + .permissions + .windows_sandbox_private_desktop, + justification: None, + arg0: None, }; let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); @@ -306,16 +335,4 @@ impl CommandExecRequestProcessor { }) .await } - - fn preserve_configured_deny_read_restrictions( - file_system_sandbox_policy: &mut FileSystemSandboxPolicy, - configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, - ) { - file_system_sandbox_policy - .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); - } } - -#[cfg(test)] -#[path = "command_exec_processor_tests.rs"] -mod command_exec_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs b/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs deleted file mode 100644 index 3e026a6a82..0000000000 --- a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::*; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_utils_absolute_path::test_support::PathBufExt; -use codex_utils_absolute_path::test_support::test_path_buf; -use pretty_assertions::assert_eq; - -#[test] -fn command_profile_preserves_configured_deny_read_restrictions() { - let readable_entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: test_path_buf("/tmp/project").abs(), - }, - access: FileSystemAccessMode::Read, - }; - let deny_entry = FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "/tmp/project/**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }; - let mut file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); - let mut configured_file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); - configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); - - CommandExecRequestProcessor::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, - ); - - let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); - expected.glob_scan_max_depth = Some(2); - assert_eq!(file_system_sandbox_policy, expected); -} 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 index 41a124e94b..2fceeb5030 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -5,6 +5,7 @@ use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; #[derive(Clone)] pub(crate) struct RemoteControlRequestProcessor { @@ -31,6 +32,16 @@ impl RemoteControlRequestProcessor { Ok(RemoteControlDisableResponse::from(handle.disable())) } + pub(crate) fn status_read(&self) -> Result { + let status = self.handle()?.status(); + Ok(RemoteControlStatusReadResponse { + status: status.status, + server_name: status.server_name, + installation_id: status.installation_id, + environment_id: status.environment_id, + }) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 7dab206d85..4781df8350 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -604,6 +604,7 @@ pub(super) async fn handle_pending_thread_resume_request( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, .. } = pending.config_snapshot; @@ -620,11 +621,11 @@ pub(super) async fn handle_pending_thread_resume_request( model_provider: model_provider_id, service_tier, cwd, + runtime_workspace_roots: workspace_roots, instruction_sources, approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile: Some(permission_profile.into()), active_permission_profile, reasoning_effort, }; 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 92da7cdd8f..21160950ab 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -59,6 +59,25 @@ fn collect_resume_override_mismatches( )); } } + if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { + let base_cwd = request + .cwd + .as_deref() + .map(|cwd| { + AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) + }) + .unwrap_or_else(|| config_snapshot.cwd.clone()); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots + .iter() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) + .collect::>(); + if requested_runtime_workspace_roots != config_snapshot.workspace_roots { + mismatch_details.push(format!( + "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", + config_snapshot.workspace_roots + )); + } + } if let Some(requested_approval) = request.approval_policy.as_ref() { let active_approval: AskForApproval = config_snapshot.approval_policy.into(); if requested_approval != &active_approval { @@ -804,6 +823,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -837,6 +857,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -977,7 +998,7 @@ impl ThreadRequestProcessor { let requested_permissions_trust_project = requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); let effective_permissions_trust_project = permission_profile_trusts_project( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); @@ -1173,11 +1194,11 @@ impl ThreadRequestProcessor { model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, cwd: config_snapshot.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; @@ -1214,6 +1235,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -1227,6 +1249,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd: cwd.map(PathBuf::from), + workspace_roots: runtime_workspace_roots, approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -2351,6 +2374,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2386,6 +2410,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2523,11 +2548,11 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -2987,6 +3012,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3052,6 +3078,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3181,11 +3208,11 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; 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 be5f707a11..aa33fc623f 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 @@ -217,6 +217,7 @@ mod thread_processor_behavior_tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]; let active_turn = Turn { @@ -639,6 +640,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -659,6 +661,8 @@ mod thread_processor_behavior_tests { permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, cwd, + workspace_roots: Vec::new(), + profile_workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, reasoning_summary: None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 875bd3deaf..63ceeb55e2 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -179,19 +179,23 @@ pub(super) fn apply_permission_profile_selection_to_config_overrides( overrides: &mut ConfigOverrides, permissions: Option, ) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + let Some(selection) = permissions else { return; }; - overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); + overrides.default_permissions = Some(selection.id().to_string()); + if selection.legacy_additional_writable_roots().is_empty() { + return; + } + + let legacy_roots = selection + .legacy_additional_writable_roots() + .iter() + .map(AbsolutePathBuf::to_path_buf); + if let Some(workspace_roots) = overrides.workspace_roots.as_mut() { + workspace_roots.extend(legacy_roots); + } else { + overrides.additional_writable_roots.extend(legacy_roots); + } } pub(super) fn thread_response_sandbox_policy( diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index f8902e132d..699db34ed4 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -66,3 +66,43 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { assert_eq!(summary, expected); Ok(()) } + +#[test] +fn legacy_permission_profile_modifications_extend_runtime_roots() -> Result<()> { + let root = if cfg!(windows) { + AbsolutePathBuf::try_from("C:\\workspace-extra")? + } else { + AbsolutePathBuf::try_from("/workspace-extra")? + }; + let selection = serde_json::from_value::(json!({ + "type": "profile", + "id": ":workspace", + "modifications": [ + { + "type": "additionalWritableRoot", + "path": root, + } + ], + }))?; + + let mut overrides = ConfigOverrides::default(); + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection.clone())); + assert_eq!( + overrides.default_permissions, + Some(":workspace".to_string()) + ); + assert_eq!( + overrides.additional_writable_roots, + vec![root.to_path_buf()] + ); + + let mut overrides = ConfigOverrides { + workspace_roots: Some(Vec::new()), + ..ConfigOverrides::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection)); + assert_eq!(overrides.additional_writable_roots, Vec::::new()); + assert_eq!(overrides.workspace_roots, Some(vec![root.to_path_buf()])); + + Ok(()) +} diff --git a/codex-rs/app-server/src/request_processors/token_usage_replay.rs b/codex-rs/app-server/src/request_processors/token_usage_replay.rs index b19c4a61a0..b8c65645fc 100644 --- a/codex-rs/app-server/src/request_processors/token_usage_replay.rs +++ b/codex-rs/app-server/src/request_processors/token_usage_replay.rs @@ -152,6 +152,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "first answer".to_string(), @@ -167,6 +168,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), ] } diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 19873b6c28..9574fb0b32 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -19,6 +19,7 @@ pub(crate) struct TurnRequestProcessor { struct TurnContextOverrideRequest { cwd: Option, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -34,6 +35,7 @@ struct TurnContextOverrideRequest { impl TurnContextOverrideRequest { fn has_any_overrides(&self) -> bool { self.cwd.is_some() + || self.runtime_workspace_roots.is_some() || self.approval_policy.is_some() || self.approvals_reviewer.is_some() || self.sandbox_policy.is_some() @@ -50,6 +52,8 @@ impl TurnContextOverrideRequest { fn op_turn_context_overrides(overrides: CodexThreadTurnContextOverrides) -> TurnContextOverrides { TurnContextOverrides { cwd: overrides.cwd, + workspace_roots: overrides.workspace_roots, + profile_workspace_roots: overrides.profile_workspace_roots, approval_policy: overrides.approval_policy, approvals_reviewer: overrides.approvals_reviewer, sandbox_policy: overrides.sandbox_policy, @@ -67,6 +71,20 @@ fn op_turn_context_overrides(overrides: CodexThreadTurnContextOverrides) -> Turn const TURN_CONTEXT_OVERRIDE_ACK_TIMEOUT: Duration = Duration::from_secs(5); +fn resolve_runtime_workspace_roots( + workspace_roots: Vec, + base_cwd: &AbsolutePathBuf, +) -> Vec { + let mut resolved_roots = Vec::new(); + for path in workspace_roots { + let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -393,15 +411,40 @@ impl TurnRequestProcessor { } let cwd = request.cwd; + let runtime_workspace_roots = + request + .runtime_workspace_roots + .clone() + .map(|workspace_roots| { + let base_cwd = cwd + .as_ref() + .map(|cwd| { + AbsolutePathBuf::resolve_path_against_base( + cwd, + base_snapshot.cwd.as_path(), + ) + }) + .unwrap_or_else(|| base_snapshot.cwd.clone()); + resolve_runtime_workspace_roots(workspace_roots, &base_cwd) + }); let approval_policy = request.approval_policy.map(AskForApproval::to_core); let approvals_reviewer = request .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); let sandbox_policy = request.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile) = + let (permission_profile, active_permission_profile, profile_workspace_roots) = if let Some(permissions) = request.permissions { let mut overrides = ConfigOverrides { cwd: cwd.clone(), + workspace_roots: Some(request.runtime_workspace_roots.clone().unwrap_or_else( + || { + base_snapshot + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + }, + )), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() @@ -430,11 +473,12 @@ impl TurnRequestProcessor { ))); } ( - Some(config.permissions.permission_profile()), + Some(config.permissions.permission_profile().clone()), config.permissions.active_permission_profile(), + Some(config.permissions.profile_workspace_roots().to_vec()), ) } else { - (None, None) + (None, None, None) }; // None means the caller sent no context fields at all. Some means at @@ -442,6 +486,8 @@ impl TurnRequestProcessor { // matches the current thread context. Ok(Some(CodexThreadTurnContextOverrides { cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -526,6 +572,7 @@ impl TurnRequestProcessor { &before_snapshot, TurnContextOverrideRequest { cwd: params.cwd, + runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, @@ -678,6 +725,7 @@ impl TurnRequestProcessor { &before_snapshot, TurnContextOverrideRequest { cwd: params.cwd, + runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, sandbox_policy: params.sandbox_policy, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 1aac0d63e7..b1e8807b4a 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -592,6 +592,12 @@ impl McpProcess { .await } + /// Send a `remoteControl/status/read` JSON-RPC request. + pub async fn send_remote_control_status_read_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/status/read", /*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/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 50c365d633..a15b46abd9 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -44,20 +44,30 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; +use url::Url; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; +const WORKSPACE_ID_EMBEDDED: &str = "123e4567-e89b-42d3-a456-426614174010"; +const WORKSPACE_ID_INITIAL: &str = "123e4567-e89b-42d3-a456-426614174011"; +const WORKSPACE_ID_REFRESHED: &str = "123e4567-e89b-42d3-a456-426614174012"; +const WORKSPACE_ID_DEVICE: &str = "123e4567-e89b-42d3-a456-426614174013"; +const WORKSPACE_ID_STALE: &str = "123e4567-e89b-42d3-a456-426614174014"; // Helper to create a minimal config.toml for the app server #[derive(Default)] struct CreateConfigTomlParams { forced_method: Option, forced_workspace_id: Option, + forced_workspace_ids: Option>, requires_openai_auth: Option, base_url: Option, model_provider_id: Option, @@ -76,6 +86,13 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std: }; let forced_workspace_line = if let Some(ws) = params.forced_workspace_id { format!("forced_chatgpt_workspace_id = \"{ws}\"\n") + } else if let Some(workspaces) = params.forced_workspace_ids { + let workspaces = workspaces + .into_iter() + .map(|workspace_id| format!("\"{workspace_id}\"")) + .collect::>() + .join(", "); + format!("forced_chatgpt_workspace_id = [{workspaces}]\n") } else { String::new() }; @@ -248,7 +265,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { &ChatGptIdTokenClaims::new() .email("embedded@example.com") .plan_type("pro") - .chatgpt_account_id("org-embedded"), + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -257,7 +274,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( access_token, - "org-embedded".to_string(), + WORKSPACE_ID_EMBEDDED.to_string(), Some("pro".to_string()), ) .await?; @@ -322,7 +339,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { &ChatGptIdTokenClaims::new() .email("embedded@example.com") .plan_type("pro") - .chatgpt_account_id("org-embedded"), + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -331,7 +348,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( access_token, - "org-embedded".to_string(), + WORKSPACE_ID_EMBEDDED.to_string(), Some("pro".to_string()), ) .await?; @@ -441,13 +458,13 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-initial"), + .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; let refreshed_access_token = encode_id_token( &ChatGptIdTokenClaims::new() .email("refreshed@example.com") .plan_type("pro") - .chatgpt_account_id("org-refreshed"), + .chatgpt_account_id(WORKSPACE_ID_REFRESHED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -456,7 +473,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token.clone(), - "org-initial".to_string(), + WORKSPACE_ID_INITIAL.to_string(), Some("pro".to_string()), ) .await?; @@ -499,7 +516,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { respond_to_refresh_request( &mut mcp, &refreshed_access_token, - "org-refreshed", + WORKSPACE_ID_REFRESHED, Some("pro"), ) .await?; @@ -553,7 +570,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-initial"), + .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -562,7 +579,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token, - "org-initial".to_string(), + WORKSPACE_ID_INITIAL.to_string(), Some("pro".to_string()), ) .await?; @@ -651,7 +668,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { create_config_toml( codex_home.path(), CreateConfigTomlParams { - forced_workspace_id: Some("org-expected".to_string()), + forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()), requires_openai_auth: Some(true), base_url: Some(format!("{}/v1", mock_server.uri())), ..Default::default() @@ -669,13 +686,13 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-expected"), + .chatgpt_account_id(WORKSPACE_ID_ALLOWED), )?; let refreshed_access_token = encode_id_token( &ChatGptIdTokenClaims::new() .email("refreshed@example.com") .plan_type("pro") - .chatgpt_account_id("org-other"), + .chatgpt_account_id(WORKSPACE_ID_DISALLOWED), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -684,7 +701,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token, - "org-expected".to_string(), + WORKSPACE_ID_ALLOWED.to_string(), Some("pro".to_string()), ) .await?; @@ -738,7 +755,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { request_id, serde_json::to_value(ChatgptAuthTokensRefreshResponse { access_token: refreshed_access_token, - chatgpt_account_id: "org-other".to_string(), + chatgpt_account_id: WORKSPACE_ID_DISALLOWED.to_string(), chatgpt_plan_type: Some("pro".to_string()), })?, ) @@ -790,7 +807,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { &ChatGptIdTokenClaims::new() .email("initial@example.com") .plan_type("pro") - .chatgpt_account_id("org-initial"), + .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -799,7 +816,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { let set_id = mcp .send_chatgpt_auth_tokens_login_request( initial_access_token, - "org-initial".to_string(), + WORKSPACE_ID_INITIAL.to_string(), Some("pro".to_string()), ) .await?; @@ -853,7 +870,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { request_id, serde_json::to_value(ChatgptAuthTokensRefreshResponse { access_token: "not-a-jwt".to_string(), - chatgpt_account_id: "org-initial".to_string(), + chatgpt_account_id: WORKSPACE_ID_INITIAL.to_string(), chatgpt_plan_type: Some("pro".to_string()), })?, ) @@ -1062,7 +1079,7 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> &ChatGptIdTokenClaims::new() .email("device@example.com") .plan_type("pro") - .chatgpt_account_id("org-device"), + .chatgpt_account_id(WORKSPACE_ID_DEVICE), )?; mock_device_code_oauth_token(&mock_server, &id_token).await; @@ -1378,14 +1395,14 @@ async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> { &ChatGptIdTokenClaims::new() .email("embedded@example.com") .plan_type("pro") - .chatgpt_account_id("org-embedded"), + .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; // Set an external auth token instead of completing the ChatGPT login flow. // This should cancel the active login attempt. let set_id = mcp .send_chatgpt_auth_tokens_login_request( access_token, - "org-embedded".to_string(), + WORKSPACE_ID_EMBEDDED.to_string(), Some("pro".to_string()), ) .await?; @@ -1428,7 +1445,7 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result create_config_toml( codex_home.path(), CreateConfigTomlParams { - forced_workspace_id: Some("ws-forced".to_string()), + forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()), ..Default::default() }, )?; @@ -1448,12 +1465,56 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result bail!("unexpected login response: {login:?}"); }; assert!( - auth_url.contains("allowed_workspace_id=ws-forced"), + auth_url.contains(&format!("allowed_workspace_id={WORKSPACE_ID_ALLOWED}")), "auth URL should include forced workspace" ); Ok(()) } +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_includes_forced_workspace_allowlist_query_param() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_ids: Some(vec![ + WORKSPACE_ID_ALLOWED.to_string(), + WORKSPACE_ID_SECOND_ALLOWED.to_string(), + ]), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { auth_url, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + let auth_url = Url::parse(&auth_url)?; + let allowed_workspace_ids = auth_url + .query_pairs() + .filter_map(|(key, value)| (key == "allowed_workspace_id").then(|| value.into_owned())) + .collect::>(); + assert_eq!( + allowed_workspace_ids, + vec![format!( + "{WORKSPACE_ID_ALLOWED},{WORKSPACE_ID_SECOND_ALLOWED}" + )] + ); + Ok(()) +} + #[tokio::test] async fn get_account_no_auth() -> Result<()> { let codex_home = TempDir::new()?; @@ -1662,7 +1723,7 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<( codex_home.path(), ChatGptAuthFixture::new("stale-access-token") .refresh_token("stale-refresh-token") - .account_id("acct_123") + .account_id(WORKSPACE_ID_STALE) .email("user@example.com") .plan_type("pro") .last_refresh(Some(Utc::now() - ChronoDuration::days(9))), 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 2a4b8435ef..e4c573e72f 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -5,6 +5,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::ActivePermissionProfile; use codex_app_server_protocol::CommandExecOutputDeltaNotification; use codex_app_server_protocol::CommandExecOutputStream; use codex_app_server_protocol::CommandExecParams; @@ -13,19 +14,13 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::PermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::path::Path; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; @@ -224,7 +219,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { env: None, size: None, sandbox_policy: None, - permission_profile: Some(root_read_only_permission_profile()), + permission_profile: Some(ActivePermissionProfile::read_only()), }) .await?; @@ -244,6 +239,105 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { Ok(()) } +#[tokio::test] +async fn command_exec_permission_profile_starts_selected_network_proxy() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_networked_permission_profile_config( + codex_home.path(), + /*default_permissions*/ None, + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf '%s' \"${CODEX_NETWORK_PROXY_ACTIVE-unset}\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(ActivePermissionProfile::new("networked")), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "1".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_networked_permission_profile_config(codex_home.path(), Some("networked"))?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf '%s' \"${CODEX_NETWORK_PROXY_ACTIVE-unset}\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(ActivePermissionProfile::read_only()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "unset".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + #[cfg(unix)] #[tokio::test] async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> { @@ -252,23 +346,17 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu let command_dir = codex_home.path().join("command-cwd"); std::fs::create_dir(&command_dir)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_command_exec_config( + codex_home.path(), + r#" +[permissions.command-cwd.filesystem] +":root" = "read" +":workspace_roots" = "write" +"#, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let mut permission_profile = root_read_only_permission_profile(); - let PermissionProfile::Managed { file_system, .. } = &mut permission_profile else { - panic!("root read-only helper should use managed permissions"); - }; - let PermissionProfileFileSystemPermissions::Restricted { entries, .. } = file_system else { - panic!("root read-only helper should use restricted filesystem permissions"); - }; - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { subpath: None }, - }, - access: FileSystemAccessMode::Write, - }); - let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec![ @@ -288,7 +376,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile: Some(ActivePermissionProfile::new("command-cwd")), }) .await?; @@ -335,7 +423,7 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result env: None, size: None, sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permission_profile: Some(root_read_only_permission_profile()), + permission_profile: Some(ActivePermissionProfile::read_only()), }) .await?; @@ -1061,19 +1149,41 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } -fn root_read_only_permission_profile() -> PermissionProfile { - PermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - glob_scan_max_depth: None, - }, - } +fn insert_networked_permission_profile_config( + codex_home: &Path, + default_permissions: Option<&str>, +) -> Result<()> { + let default_permissions = default_permissions + .map(|default_permissions| format!("default_permissions = \"{default_permissions}\"\n\n")) + .unwrap_or_default(); + let inserted_config = format!( + r#"{default_permissions}[features] +network_proxy = true + +[permissions.networked.filesystem] +":root" = "read" + +[permissions.networked.network] +enabled = true +proxy_url = "http://127.0.0.1:0" +enable_socks5 = false + +"# + ); + insert_command_exec_config(codex_home, &inserted_config)?; + Ok(()) +} + +fn insert_command_exec_config(codex_home: &Path, inserted_config: &str) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + let marker = "\n[model_providers.mock_provider]\n"; + let (prefix, suffix) = config + .split_once(marker) + .context("test config should include mock provider table")?; + let config = format!("{prefix}\n{inserted_config}{marker}{suffix}"); + std::fs::write(config_path, config)?; + Ok(()) } async fn read_initialize_response( 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 2cbf476f1a..38ba12f95f 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ForcedChatgptWorkspaceIds; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MergeStrategy; @@ -164,6 +165,86 @@ allowed_domains = ["example.com"] Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_accepts_legacy_forced_chatgpt_workspace_id() -> Result<()> { + const WORKSPACE_ID: &str = "123e4567-e89b-42d3-a456-426614174000"; + + let codex_home = TempDir::new()?; + write_config( + &codex_home, + &format!( + r#" +forced_chatgpt_workspace_id = "{WORKSPACE_ID}" +"# + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!( + config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Single(WORKSPACE_ID.to_string())) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_accepts_forced_chatgpt_workspace_id_list() -> Result<()> { + const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000"; + const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001"; + + let codex_home = TempDir::new()?; + write_config( + &codex_home, + &format!( + r#" +forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"] +"# + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + assert_eq!( + config.forced_chatgpt_workspace_id, + Some(ForcedChatgptWorkspaceIds::Multiple(vec![ + WORKSPACE_ID_A.to_string(), + WORKSPACE_ID_B.to_string(), + ])) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_nested_web_search_tool_config() -> Result<()> { let codex_home = TempDir::new()?; @@ -330,6 +411,52 @@ default_tools_approval_mode = "prompt" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_desktop_settings() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[desktop] +appearanceTheme = "dark" +selected-avatar-id = "codex" + +[desktop.workspace] +collapsed = true +width = 320 +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + let desktop = config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); + assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); + assert_eq!( + desktop.get("workspace"), + Some(&json!({ + "collapsed": true, + "width": 320, + })) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_project_layers_for_cwd() -> Result<()> { let codex_home = TempDir::new()?; @@ -568,6 +695,50 @@ model = "gpt-old" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_updates_desktop_settings() -> Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().canonicalize()?; + write_config(&temp_dir, "")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: None, + key_path: "desktop.appearanceTheme".to_string(), + value: json!("dark"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + assert_eq!(write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let desktop = read.config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> { let temp_dir = TempDir::new()?; @@ -722,6 +893,70 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { + let tmp_dir = TempDir::new()?; + let codex_home = tmp_dir.path().canonicalize()?; + write_config(&tmp_dir, "")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: Some(codex_home.join("config.toml").display().to_string()), + edits: vec![ + ConfigEdit { + key_path: "desktop.selected-avatar-id".to_string(), + value: json!("codex"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "desktop.workspace".to_string(), + value: json!({ + "collapsed": true, + "width": 320, + }), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + reload_user_config: false, + }) + .await?; + let batch_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + ) + .await??; + let batch_write: ConfigWriteResponse = to_response(batch_resp)?; + assert_eq!(batch_write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let desktop = read.config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); + assert_eq!( + desktop.get("workspace"), + Some(&json!({ + "collapsed": true, + "width": 320, + })) + ); + + Ok(()) +} + fn assert_layers_user_then_optional_system( layers: &[codex_app_server_protocol::ConfigLayer], user_file: AbsolutePathBuf, diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index ae6bc9013f..d71f05bf58 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -1,14 +1,27 @@ use std::time::Duration; +use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; 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::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -28,6 +41,28 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { let received: RemoteControlDisableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn remote_control_status_read_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_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) @@ -36,6 +71,7 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { #[tokio::test] async fn remote_control_enable_returns_connecting_status() -> Result<()> { let codex_home = TempDir::new()?; + let _backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -48,7 +84,117 @@ async fn remote_control_enable_returns_connecting_status() -> Result<()> { let received: RemoteControlEnableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) } + +#[tokio::test] +async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; + 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 _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let enroll_request = timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??; + assert_eq!( + enroll_request, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +struct BlockingRemoteControlBackend { + enroll_request_rx: Option>>, + server_task: JoinHandle<()>, +} + +impl BlockingRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home, + &remote_control_url, + &remote_control_url, + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account_id") + .chatgpt_account_id("account_id"), + AuthCredentialsStoreMode::File, + )?; + + let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + match read_enroll_request(listener).await { + Ok((request_line, _reader)) => { + let _ = enroll_request_tx.send(Ok(request_line)); + std::future::pending::<()>().await; + } + Err(err) => { + let _ = enroll_request_tx.send(Err(err)); + } + } + }); + + Ok(Self { + enroll_request_rx: Some(enroll_request_rx), + server_task, + }) + } + + async fn wait_for_enroll_request(&mut self) -> Result { + let rx = self + .enroll_request_rx + .take() + .context("enroll request should only be awaited once")?; + rx.await? + } +} + +impl Drop for BlockingRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + +async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader)> { + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + } + + Ok((request_line.trim_end().to_string(), reader)) +} diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515ad..db982bc5d0 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -614,6 +614,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( model_provider: None, service_tier: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, 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 fa143254a5..c8e3e179fb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1323,6 +1323,7 @@ fn store_history_items() -> Vec { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))] } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 64dfe0beb7..ff2ccec49c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -184,6 +184,79 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let extra_root_tmp = TempDir::new()?; + let extra_root = extra_root_tmp.path().join("extra-root"); + std::fs::create_dir_all(&extra_root)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + runtime_workspace_roots, + .. + } = to_response::(resume_resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![AbsolutePathBuf::from_absolute_path(extra_root)?] + ); + + Ok(()) +} + #[tokio::test] async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 78155d8c9a..75c124e893 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -236,6 +236,89 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let cwd_tmp = TempDir::new()?; + let cwd = cwd_tmp.path().to_path_buf(); + let relative_root = PathBuf::from("extra-root"); + std::fs::create_dir_all(cwd.join(&relative_root))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some(vec![relative_root.clone()]), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + cwd: response_cwd, + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!(response_cwd, cwd.abs()); + assert_eq!( + runtime_workspace_roots, + vec![cwd_tmp.path().join(relative_root).abs()] + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_roots() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let profile_root = TempDir::new()?; + create_config_toml_with_profile_workspace_root( + codex_home.path(), + &server.uri(), + profile_root.path(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.path().to_string_lossy().to_string()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![cwd.path().to_path_buf().abs()] + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -980,6 +1063,42 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_profile_workspace_root( + codex_home: &Path, + server_uri: &str, + profile_root: &Path, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let profile_root_key = profile_root + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +default_permissions = "dev" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[permissions.dev.workspace_roots] +"{profile_root_key}" = true + +[permissions.dev.filesystem.":workspace_roots"] +"." = "write" +"#, + ), + ) +} + fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs b/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs index 58a27983be..bb248c102a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs @@ -178,10 +178,7 @@ async fn thread_turn_context_update_rejects_sandbox_policy_with_permissions() -> .send_thread_turn_context_update_request(ThreadTurnContextUpdateParams { thread_id: thread.id, sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permissions: Some(PermissionProfileSelectionParams::Profile { - id: ":read-only".to_string(), - modifications: None, - }), + permissions: Some(PermissionProfileSelectionParams::new(":read-only")), ..Default::default() }) .await?; 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 62192ebd08..844d9ab1bb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use anyhow::Result; use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; @@ -34,7 +35,6 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -63,11 +63,13 @@ 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::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; @@ -85,6 +87,11 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, + 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, +]; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -92,6 +99,107 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool { .is_some_and(|body| body.contains(text)) } +async fn run_local_image_turn(detail: Option) -> Result> { + // Two Codex turns hit the mock model (session start + turn/start). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + // Use the unchecked variant because the strict matcher does not currently + // cover image-bearing request payloads. + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let image_path = codex_home.path().join("image.png"); + std::fs::write(&image_path, TINY_PNG_BYTES)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::LocalImage { + path: image_path, + detail, + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + received_response_input_images(&server).await +} + +async fn received_response_input_images(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let mut input_images = Vec::new(); + + for request in requests { + if !request.url.path().ends_with("/responses") { + continue; + } + let body = request + .body_json::() + .context("request body should be JSON")?; + let Some(input) = body.get("input").and_then(Value::as_array) else { + continue; + }; + + for item in input { + if item.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + let Some(content) = item.get("content").and_then(Value::as_array) else { + continue; + }; + input_images.extend( + content + .iter() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) + .cloned(), + ); + } + } + + Ok(input_images) +} + #[tokio::test] async fn turn_start_sends_originator_header() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -555,6 +663,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { thread_id: thread.id.clone(), input: vec![V2UserInput::Image { url: "https://example.com/a.png".to_string(), + detail: None, }], ..Default::default() }) @@ -780,10 +889,11 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some(PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - modifications: None, - }), + permissions: Some( + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS + .to_string() + .into(), + ), ..Default::default() }) .await?; @@ -1465,63 +1575,27 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> } #[tokio::test] -async fn turn_start_accepts_local_image_input() -> Result<()> { - // Two Codex turns hit the mock model (session start + turn/start). - let responses = vec![ - create_final_assistant_message_sse_response("Done")?, - create_final_assistant_message_sse_response("Done")?, - ]; - // Use the unchecked variant because the request payload includes a LocalImage - // which the strict matcher does not currently cover. - let server = create_mock_responses_server_sequence_unchecked(responses).await; +async fn turn_start_defaults_local_image_detail_to_high() -> Result<()> { + let input_images = run_local_image_turn(/*detail*/ None).await?; - let codex_home = TempDir::new()?; - create_config_toml( - codex_home.path(), - &server.uri(), - "never", - &BTreeMap::default(), - )?; + assert_eq!(input_images.len(), 1); + assert_eq!( + input_images[0].get("detail").and_then(Value::as_str), + Some("high") + ); - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(()) +} - let thread_req = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; +#[tokio::test] +async fn turn_start_forwards_custom_local_image_detail() -> Result<()> { + let input_images = run_local_image_turn(Some(ImageDetail::Original)).await?; - let image_path = codex_home.path().join("image.png"); - // No need to actually write the file; we just exercise the input path. - - let turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![V2UserInput::LocalImage { path: image_path }], - ..Default::default() - }) - .await?; - let turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), - ) - .await??; - let TurnStartResponse { turn } = to_response::(turn_resp)?; - assert!(!turn.id.is_empty()); - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; + assert_eq!(input_images.len(), 1); + assert_eq!( + input_images[0].get("detail").and_then(Value::as_str), + Some("original") + ); Ok(()) } @@ -1891,6 +1965,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(first_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { @@ -1932,6 +2007,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(second_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), @@ -1991,6 +2067,152 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let old_root = tmp.path().join("old-root"); + let new_root = tmp.path().join("new-root"); + std::fs::create_dir(&old_root)?; + std::fs::create_dir(&new_root)?; + let old_root_text = old_root.to_string_lossy().into_owned(); + let new_root_text = new_root.to_string_lossy().into_owned(); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done first"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "done second"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let server_uri = server.uri(); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +default_permissions = "dev" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[permissions.dev.filesystem.":workspace_roots"] +"." = "write" +"# + ), + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "select dev profile".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![old_root]), + permissions: Some("dev".to_string().into()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "write in new root".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![new_root]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two Responses API requests"); + let latest_permissions_instructions = + |request: &core_test_support::responses::ResponsesRequest| { + request + .message_input_texts("developer") + .into_iter() + .rev() + .find(|text| text.contains("")) + .expect("permissions instructions") + }; + let first_permissions = latest_permissions_instructions(&requests[0]); + assert!(first_permissions.contains(&old_root_text)); + assert!( + !first_permissions.contains(&new_root_text), + "first turn should materialize the initial runtime workspace root" + ); + + let second_permissions = latest_permissions_instructions(&requests[1]); + assert!(second_permissions.contains(&new_root_text)); + assert!( + !second_permissions.contains(&old_root_text), + "second turn should rebind :workspace_roots to the updated runtime workspace root" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { let tmp = TempDir::new()?; @@ -2476,13 +2698,26 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { &server.uri(), "never", &BTreeMap::from([ - (Feature::ApplyPatchFreeform, true), (Feature::ApplyPatchStreamingEvents, true), (Feature::Plugins, false), (Feature::RemoteModels, false), (Feature::ShellSnapshot, false), ]), )?; + write_models_cache(&codex_home)?; + let cache_path = codex_home.join("models_cache.json"); + let mut cache: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?; + let models = cache["models"] + .as_array_mut() + .expect("models_cache.json models should be an array"); + let model = models + .first_mut() + .expect("models_cache.json should contain at least one model"); + model["slug"] = serde_json::Value::from("mock-model"); + model["display_name"] = serde_json::Value::from("mock-model"); + model["apply_patch_tool_type"] = serde_json::Value::from("freeform"); + std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30c60d7b95..bc9d6172f2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -230,7 +230,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile.get(), + config.permissions.permission_profile(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -285,7 +285,7 @@ async fn run_command_under_sandbox( let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), @@ -962,10 +962,18 @@ mod tests { ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) @@ -996,10 +1004,18 @@ mod tests { ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index a3742baa72..f2b1bca6d7 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -105,6 +105,10 @@ const COLOR_ENV_VARS: &[&str] = &[ 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"]; +#[cfg(windows)] +const NPM_COMMAND: &str = "npm.cmd"; +#[cfg(not(windows))] +const NPM_COMMAND: &str = "npm"; const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[ "SSH_TTY", "SSH_CONNECTION", @@ -884,7 +888,7 @@ fn npm_global_root_check() -> NpmRootCheck { return NpmRootCheck::MissingPackageRoot; }; - let output = match run_command("npm", ["root", "-g"]) { + let output = match run_command(NPM_COMMAND, ["root", "-g"]) { Ok(output) => output, Err(err) => return NpmRootCheck::NpmUnavailable(err), }; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 16add7ac90..6fdc62fdb1 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -115,7 +115,7 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { pub async fn login_with_chatgpt( codex_home: PathBuf, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result<()> { let opts = ServerOptions::new( diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6e1ccac867..7ffdff0011 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -38,6 +38,7 @@ use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; +use codex_utils_cli::resume_command; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -51,6 +52,7 @@ mod doctor; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; +mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; @@ -58,6 +60,7 @@ use crate::mcp_cmd::McpCli; use crate::plugin_cmd::PluginCli; use crate::plugin_cmd::PluginSubcommand; use doctor::DoctorCommand; +use state_db_recovery as local_state_db; use codex_config::LoaderOverrides; use codex_core::build_models_manager; @@ -628,9 +631,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec>(); if let Some(prompt) = cmd.prompt.or(interactive.prompt) { input.push(UserInput::Text { @@ -1981,13 +1982,47 @@ async fn run_interactive_tui( }; *slot = Some(auth_token); } - codex_tui::run_main( - interactive, - arg0_paths, - codex_config::LoaderOverrides::default(), - remote_endpoint, - ) - .await + let start_tui = || { + codex_tui::run_main( + interactive.clone(), + arg0_paths.clone(), + codex_config::LoaderOverrides::default(), + remote_endpoint.clone(), + ) + }; + let mut attempted_repair = false; + loop { + let err = match start_tui().await { + Ok(exit_info) => return Ok(exit_info), + Err(err) => err, + }; + let Some(startup_error) = local_state_db::startup_error(&err) else { + return Err(err); + }; + if local_state_db::is_locked(startup_error.detail()) { + local_state_db::print_locked_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + if attempted_repair { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + if !local_state_db::confirm_repair(startup_error)? { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + + match local_state_db::repair_files(startup_error).await { + Ok(backups) => local_state_db::print_repair_backups(&backups), + Err(repair_err) => { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(format!( + "failed to repair Codex local data automatically: {repair_err}" + ))); + } + } + attempted_repair = true; + } } fn confirm(prompt: &str) -> std::io::Result { diff --git a/codex-rs/cli/src/state_db_recovery.rs b/codex-rs/cli/src/state_db_recovery.rs new file mode 100644 index 0000000000..8db134540a --- /dev/null +++ b/codex-rs/cli/src/state_db_recovery.rs @@ -0,0 +1,183 @@ +//! CLI recovery for local state database startup failures. +//! +//! This keeps user-facing repair and lock-contention handling out of the main +//! CLI dispatch path while preserving the TUI startup error as the boundary type. + +use codex_tui::LocalStateDbStartupError; +use std::path::PathBuf; + +pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartupError> { + err.get_ref() + .and_then(|err| err.downcast_ref::()) +} + +pub(crate) fn is_locked(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + detail.contains("database is locked") || detail.contains("database is busy") +} + +pub(crate) fn confirm_repair(startup_error: &LocalStateDbStartupError) -> std::io::Result { + eprintln!("Codex couldn't start because its local database appears to be damaged."); + eprintln!("Codex can try a safe repair by backing up those files and rebuilding them."); + print_technical_details(startup_error); + crate::confirm("Repair Codex local data now? [y/N]: ") +} + +pub(crate) async fn repair_files( + startup_error: &LocalStateDbStartupError, +) -> std::io::Result> { + let state_db_path = startup_error.state_db_path(); + let sqlite_home = state_db_path.parent().ok_or_else(|| { + std::io::Error::other("state database path does not have a parent directory") + })?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + let repair_suffix = format!("codex-repair-{timestamp}"); + let mut backups = Vec::new(); + + match tokio::fs::metadata(sqlite_home).await { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + backups.push(backup_path(sqlite_home, &repair_suffix).await?); + tokio::fs::create_dir_all(sqlite_home).await?; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::fs::create_dir_all(sqlite_home).await?; + } + Err(err) => return Err(err), + } + + let logs_db_path = codex_state::logs_db_path(sqlite_home); + for path in sqlite_paths(state_db_path) + .into_iter() + .chain(sqlite_paths(logs_db_path.as_path())) + { + if tokio::fs::try_exists(path.as_path()).await? { + backups.push(backup_path(path.as_path(), &repair_suffix).await?); + } + } + + if backups.is_empty() { + return Err(std::io::Error::other( + "no repairable Codex local data files were found", + )); + } + + Ok(backups) +} + +pub(crate) fn print_repair_backups(backups: &[PathBuf]) { + eprintln!("Backed up Codex local data before repair:"); + for backup in backups { + eprintln!(" {}", backup.display()); + } + eprintln!("Retrying startup with rebuilt local data..."); +} + +pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) { + eprintln!("Codex couldn't start because its local database appears to be damaged."); + eprintln!("Run `codex doctor` to check your setup and get next-step guidance."); + eprintln!("If this keeps happening, share the technical details below when asking for help."); + print_technical_details(startup_error); +} + +pub(crate) fn print_locked_guidance(startup_error: &LocalStateDbStartupError) { + eprintln!("Codex couldn't start because another Codex process is using its local data."); + eprintln!("Quit any other copies of Codex that may still be running, then try again."); + print_technical_details(startup_error); +} + +fn sqlite_paths(db_path: &std::path::Path) -> Vec { + let mut wal_path = db_path.as_os_str().to_os_string(); + wal_path.push("-wal"); + let mut shm_path = db_path.as_os_str().to_os_string(); + shm_path.push("-shm"); + vec![ + db_path.to_path_buf(), + PathBuf::from(wal_path), + PathBuf::from(shm_path), + ] +} + +async fn backup_path(path: &std::path::Path, repair_suffix: &str) -> std::io::Result { + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::other(format!( + "cannot create a repair backup name for {}", + path.display() + )) + })?; + let mut sequence = 0; + loop { + let mut backup_name = file_name.to_os_string(); + backup_name.push(format!(".{repair_suffix}.{sequence}.bak")); + let backup_path = path.with_file_name(backup_name); + if !tokio::fs::try_exists(backup_path.as_path()).await? { + tokio::fs::rename(path, backup_path.as_path()).await?; + return Ok(backup_path); + } + sequence += 1; + } +} + +fn print_technical_details(startup_error: &LocalStateDbStartupError) { + eprintln!("Technical details:"); + eprintln!(" Location: {}", startup_error.state_db_path().display()); + eprintln!(" Cause: {}", startup_error.detail()); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn repair_backs_up_owned_database_files() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let state_path = codex_state::state_db_path(temp_dir.path()); + let logs_path = codex_state::logs_db_path(temp_dir.path()); + let state_sidecars = sqlite_paths(state_path.as_path()); + tokio::fs::write(state_path.as_path(), b"state").await?; + tokio::fs::write(state_sidecars[1].as_path(), b"state-wal").await?; + tokio::fs::write(logs_path.as_path(), b"logs").await?; + + let startup_error = + LocalStateDbStartupError::new(state_path.clone(), "corrupt".to_string()); + let backups = repair_files(&startup_error).await?; + + assert_eq!(backups.len(), 3); + assert!(!tokio::fs::try_exists(state_path.as_path()).await?); + assert!(!tokio::fs::try_exists(state_sidecars[1].as_path()).await?); + assert!(!tokio::fs::try_exists(logs_path.as_path()).await?); + for backup in backups { + assert!(tokio::fs::try_exists(backup.as_path()).await?); + } + Ok(()) + } + + #[tokio::test] + async fn repair_replaces_blocking_sqlite_home_file() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let sqlite_home = temp_dir.path().join("sqlite-home"); + tokio::fs::write(sqlite_home.as_path(), b"not-a-directory").await?; + let startup_error = LocalStateDbStartupError::new( + codex_state::state_db_path(sqlite_home.as_path()), + "File exists".to_string(), + ); + + let backups = repair_files(&startup_error).await?; + + assert_eq!(backups.len(), 1); + assert!(tokio::fs::metadata(sqlite_home.as_path()).await?.is_dir()); + assert!(tokio::fs::try_exists(backups[0].as_path()).await?); + Ok(()) + } + + #[test] + fn lock_failures_skip_repair() { + assert!(is_locked("database is locked")); + assert!(is_locked("database is busy")); + assert!(!is_locked("database disk image is malformed")); + } +} diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 0c2813e51a..18d3c4555c 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -24,7 +24,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Global helpers: - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "high" | "original" | null } | ImageContent, detail?: "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs index 0ac3a03770..ae92639cc0 100644 --- a/codex-rs/code-mode/src/response.rs +++ b/codex-rs/code-mode/src/response.rs @@ -4,8 +4,6 @@ use serde::Serialize; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { - Auto, - Low, High, Original, } diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs index 8d76a832d3..865b5a569d 100644 --- a/codex-rs/code-mode/src/runtime/value.rs +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -71,14 +71,10 @@ pub(super) fn normalize_output_image( Some(detail) => { let normalized = detail.to_ascii_lowercase(); Some(match normalized.as_str() { - "auto" => ImageDetail::Auto, - "low" => ImageDetail::Low, "high" => ImageDetail::High, "original" => ImageDetail::Original, _ => { - return Err( - "image detail must be one of: auto, low, high, original".to_string() - ); + return Err("image detail must be one of: high, original".to_string()); } }) } @@ -160,7 +156,7 @@ fn parse_mcp_output_image( .and_then(JsonValue::as_object) .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(JsonValue::as_str) - .filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original")) + .filter(|detail| matches!(*detail, "high" | "original")) .map(str::to_string); Ok((image_url, detail)) } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 44e4be4939..1544f84f36 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1308,7 +1308,7 @@ image({ image( { image_url: "https://example.com/image.jpg", - detail: "low", + detail: "high", }, "original", ); @@ -1348,7 +1348,7 @@ image( mimeType: "image/png", _meta: { "codex/imageDetail": "original" }, }, - "low", + "high", ); "# .to_string(), @@ -1364,7 +1364,7 @@ image( cell_id: "1".to_string(), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(crate::ImageDetail::Low), + detail: Some(crate::ImageDetail::High), }], stored_values: HashMap::new(), error_text: None, @@ -1372,6 +1372,36 @@ image( ); } + #[tokio::test] + async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image({ + image_url: "https://example.com/image.jpg", + detail: "low", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: Vec::new(), + stored_values: HashMap::new(), + error_text: Some("image detail must be one of: high, original".to_string()), + } + ); + } + #[tokio::test] async fn image_helper_rejects_raw_mcp_result_container() { let service = CodeModeService::new(); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index c5593a5598..e8dc36b9b2 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -80,6 +80,13 @@ impl McpConnectionManager { pub fn new_uninitialized( approval_policy: &Constrained, permission_profile: &Constrained, + ) -> Self { + Self::new_uninitialized_with_permission_profile(approval_policy, permission_profile.get()) + } + + pub fn new_uninitialized_with_permission_profile( + approval_policy: &Constrained, + permission_profile: &PermissionProfile, ) -> Self { Self { clients: HashMap::new(), @@ -87,7 +94,7 @@ impl McpConnectionManager { host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), - permission_profile.get().clone(), + permission_profile.clone(), /*reviewer*/ None, ), startup_cancellation_token: CancellationToken::new(), diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 0d24718ba9..2ce03c9317 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -109,6 +109,8 @@ pub struct McpConfig { pub chatgpt_base_url: String, /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, + /// Optional product SKU forwarded to the host-owned apps MCP server. + pub apps_mcp_product_sku: Option, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. @@ -427,12 +429,15 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { let url = codex_apps_mcp_url(config); + let http_headers = config.apps_mcp_product_sku.as_ref().map(|product_sku| { + HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.clone())]) + }); McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(), - http_headers: None, + http_headers, env_http_headers: None, }, experimental_environment: None, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 286c191b29..99622d7f07 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -18,6 +18,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { chatgpt_base_url: "https://chatgpt.com".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, codex_home, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), mcp_oauth_callback_port: None, @@ -251,6 +252,40 @@ fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp"); } +#[test] +fn codex_apps_server_config_forwards_configured_product_sku_header() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.apps_mcp_product_sku = Some("tpp".to_string()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + + match &config.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([( + "X-OpenAI-Product-Sku".to_string(), + "tpp".to_string(), + )])) + ); + assert!(env_http_headers.is_none()); + } + other => panic!("expected streamable http transport, got {other:?}"), + } +} + #[tokio::test] async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9faa846137..4e4336a2b5 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1255,8 +1255,7 @@ mod tests { use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; - use codex_protocol::protocol::NetworkAccess; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use pretty_assertions::assert_eq; @@ -1272,10 +1271,6 @@ mod tests { )?) } - fn profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) - } - fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { let ConfigRequirementsToml { allowed_approval_policies, @@ -1963,9 +1958,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2110,9 +2103,7 @@ allowed_approvals_reviewers = ["user"] assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy() - )) + .can_set(&PermissionProfile::read_only()) .is_ok() ); @@ -2195,29 +2186,25 @@ allowed_approvals_reviewers = ["user"] assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy() - )) + .can_set(&PermissionProfile::read_only()) .is_ok() ); - let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .can_set(&workspace_write_profile) .is_ok() ); assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2228,11 +2215,9 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - } - )), + .can_set(&PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "ExternalSandbox".into(), @@ -2313,24 +2298,22 @@ allowed_approvals_reviewers = ["user"] let requirements = ConfigRequirements::try_from(requirements_with_sources)?; let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; - let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .can_set(&workspace_write_profile) .is_ok() ); assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2361,9 +2344,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2402,9 +2383,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - )), + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 591b259676..76ba338fc0 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -27,6 +27,7 @@ use crate::types::ToolSuggestConfig; use crate::types::Tui; use crate::types::UriBasedFileOpener; use crate::types::WindowsToml; +use codex_app_server_protocol::ForcedChatgptWorkspaceIds as ApiForcedChatgptWorkspaceIds; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_features::FeaturesToml; @@ -56,6 +57,8 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; +use serde::de::Error as SerdeError; +use serde_json::Value as JsonValue; const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ AMAZON_BEDROCK_PROVIDER_ID, @@ -86,6 +89,55 @@ const fn default_hide_agent_reasoning() -> Option { Some(false) } +/// Backward-compatible shape for ChatGPT workspace login restrictions in config.toml. +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(untagged)] +pub enum ForcedChatgptWorkspaceIds { + Single(String), + Multiple(Vec), +} + +impl ForcedChatgptWorkspaceIds { + pub fn into_vec(self) -> Vec { + match self { + Self::Single(value) => vec![value], + Self::Multiple(values) => values, + } + } + + pub fn into_api(self) -> ApiForcedChatgptWorkspaceIds { + match self { + Self::Single(value) => ApiForcedChatgptWorkspaceIds::Single(value), + Self::Multiple(values) => ApiForcedChatgptWorkspaceIds::Multiple(values), + } + } +} + +impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Single(String), + Multiple(Vec), + } + + match Repr::deserialize(deserializer)? { + Repr::Single(value) if value.contains(',') => Err(D::Error::custom( + "forced_chatgpt_workspace_id must be a single workspace ID string or a TOML list \ +of strings; comma-separated strings are not supported. Use \ +`forced_chatgpt_workspace_id = [\"123e4567-e89b-42d3-a456-426614174000\", \ +\"123e4567-e89b-42d3-a456-426614174001\"]` instead.", + )), + Repr::Single(value) => Ok(Self::Single(value)), + Repr::Multiple(values) => Ok(Self::Multiple(values)), + } + } +} + /// Base config deserialized from ~/.codex/config.toml. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -177,9 +229,9 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. + /// When set, restricts ChatGPT login to one or more workspace identifiers. #[serde(default)] - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option, /// When set, restricts the login mechanism users may use. #[serde(default)] @@ -308,6 +360,9 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. + pub apps_mcp_product_sku: Option, + /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, @@ -423,6 +478,10 @@ pub struct ConfigToml { #[serde(default)] pub apps: Option, + /// Opaque desktop settings stored alongside the rest of config.toml. + #[serde(default)] + pub desktop: Option>, + /// OTEL configuration. pub otel: Option, @@ -430,17 +489,10 @@ pub struct ConfigToml { #[serde(default)] pub windows: Option, - /// Tracks whether the Windows onboarding screen has been acknowledged. - pub windows_wsl_setup_acknowledged: Option, - /// Collection of in-product notices (different from notifications) /// See [`crate::types::Notice`] for more details pub notice: Option, - /// Legacy, now use features - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". @@ -507,7 +559,9 @@ impl From for UserSavedConfig { approval_policy: config_toml.approval_policy, sandbox_mode: config_toml.sandbox_mode, sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml.forced_chatgpt_workspace_id, + forced_chatgpt_workspace_id: config_toml + .forced_chatgpt_workspace_id + .map(ForcedChatgptWorkspaceIds::into_api), forced_login_method: config_toml.forced_login_method, model: config_toml.model, model_reasoning_effort: config_toml.model_reasoning_effort, @@ -950,3 +1004,56 @@ pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> { )), } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000"; + const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001"; + + #[test] + fn forced_chatgpt_workspace_id_accepts_single_string() { + let config: ConfigToml = toml::from_str(&format!( + r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A}""# + )) + .expect("single workspace id should deserialize"); + + assert_eq!( + config + .forced_chatgpt_workspace_id + .expect("workspace id should be set") + .into_vec(), + vec![WORKSPACE_ID_A.to_string()] + ); + } + + #[test] + fn forced_chatgpt_workspace_id_accepts_string_list() { + let config: ConfigToml = toml::from_str(&format!( + r#"forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]"# + )) + .expect("workspace id list should deserialize"); + + assert_eq!( + config + .forced_chatgpt_workspace_id + .expect("workspace ids should be set") + .into_vec(), + vec![WORKSPACE_ID_A.to_string(), WORKSPACE_ID_B.to_string()] + ); + } + + #[test] + fn forced_chatgpt_workspace_id_rejects_comma_separated_string() { + let err = toml::from_str::(&format!( + r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A},{WORKSPACE_ID_B}""# + )) + .expect_err("comma-separated string should be rejected"); + + let message = err.to_string(); + assert!(message.contains("TOML list of strings")); + assert!(message.contains("comma-separated strings are not supported")); + } +} diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 37cb41b3e1..49df306abb 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1,6 +1,8 @@ mod layer_io; #[cfg(target_os = "macos")] mod macos; +#[cfg(test)] +mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; @@ -59,6 +61,7 @@ const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ "openai_base_url", "chatgpt_base_url", + "apps_mcp_product_sku", "model_provider", "model_providers", "notify", @@ -211,19 +214,35 @@ pub async fn load_config_layers_state( // 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, - ) - .await?, - ); - let active_user_file = overrides.user_config_path(codex_home)?; + let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); + let base_user_layer = load_user_config_layer( + fs, + &base_user_file, + /*profile*/ None, + ignore_user_config, + strict_config, + ) + .await?; + if let Some(active_user_profile) = active_user_profile.as_ref() + && base_user_layer.config.as_table().is_some_and(|config| { + config + .get("profiles") + .and_then(TomlValue::as_table) + .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())) + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "--profile-v2 `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`", + base_user_file.as_path().display(), + active_user_file.as_path().display() + ), + )); + } + layers.push(base_user_layer); + if active_user_file != base_user_file { layers.push( load_user_config_layer( diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs new file mode 100644 index 0000000000..2d9462bfa7 --- /dev/null +++ b/codex-rs/config/src/loader/tests.rs @@ -0,0 +1,172 @@ +use super::*; +use async_trait::async_trait; +use codex_file_system::CopyOptions; +use codex_file_system::CreateDirectoryOptions; +use codex_file_system::FileMetadata; +use codex_file_system::FileSystemResult; +use codex_file_system::FileSystemSandboxContext; +use codex_file_system::ReadDirectoryEntry; +use codex_file_system::RemoveOptions; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +struct TestFileSystem; + +#[async_trait] +impl ExecutorFileSystem for TestFileSystem { + async fn read_file( + &self, + path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult> { + tokio::fs::read(path.as_path()).await + } + + async fn write_file( + &self, + _path: &AbsolutePathBuf, + _contents: Vec, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn create_directory( + &self, + _path: &AbsolutePathBuf, + _create_directory_options: CreateDirectoryOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn get_metadata( + &self, + _path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + unimplemented!("test filesystem only supports reads") + } + + async fn read_directory( + &self, + _path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult> { + unimplemented!("test filesystem only supports reads") + } + + async fn remove( + &self, + _path: &AbsolutePathBuf, + _remove_options: RemoveOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn copy( + &self, + _source_path: &AbsolutePathBuf, + _destination_path: &AbsolutePathBuf, + _copy_options: CopyOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } +} + +#[tokio::test] +async fn profile_v2_rejects_matching_legacy_profile_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" + +[profiles.work] +model = "gpt-work" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("profile-v2 should reject a matching legacy profile in base user config"); + + assert_eq!( + err.kind(), + io::ErrorKind::InvalidData, + "a matching legacy profile should be a hard config error" + ); + let message = err.to_string(); + assert!( + message.contains("--profile-v2 `work` cannot be used"), + "unexpected error message: {message}" + ); + assert!( + message.contains("config.toml"), + "unexpected error message: {message}" + ); + assert!( + message.contains("[profiles.work]"), + "unexpected error message: {message}" + ); +} + +#[tokio::test] +async fn profile_v2_allows_unrelated_legacy_profiles_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" + +[profiles.dev] +model = "gpt-dev" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect("profile-v2 should allow unrelated legacy profiles in base user config"); +} diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index cee68d7abb..fff8c67706 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -25,10 +25,25 @@ impl PermissionsToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub workspace_roots: Option, pub filesystem: Option, pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct WorkspaceRootsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl WorkspaceRootsToml { + pub fn enabled_roots(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|(path, enabled)| (*enabled).then_some(path)) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct FilesystemPermissionsToml { /// Optional maximum depth for expanding unreadable glob patterns on diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index e8e6320490..6cf35be68e 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -50,9 +50,6 @@ pub struct ConfigProfile { pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_permissions_instructions: Option, pub include_apps_instructions: Option, diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs index 4621b6b2e5..4d5a62df25 100644 --- a/codex-rs/config/src/strict_config_tests.rs +++ b/codex-rs/config/src/strict_config_tests.rs @@ -110,3 +110,18 @@ foo = true"#; ) ); } + +#[test] +fn strict_config_accepts_opaque_desktop_keys() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[desktop] +appearanceTheme = "dark" + +[desktop.workspace] +collapsed = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents); + + assert_eq!(error, None); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9fc95349d2..de3632d629 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -457,9 +457,6 @@ "in_app_browser": { "type": "boolean" }, - "include_apply_patch_tool": { - "type": "boolean" - }, "js_repl": { "type": "boolean" }, @@ -852,6 +849,20 @@ }, "type": "object" }, + "ForcedChatgptWorkspaceIds": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Backward-compatible shape for ChatGPT workspace login restrictions in config.toml." + }, "ForcedLoginMethod": { "enum": [ "chatgpt", @@ -1954,6 +1965,9 @@ }, "network": { "$ref": "#/definitions/NetworkToml" + }, + "workspace_roots": { + "$ref": "#/definitions/WorkspaceRootsToml" } }, "type": "object" @@ -3949,6 +3963,9 @@ "type": "string" } ] + }, + "WorkspaceRootsToml": { + "type": "object" } }, "description": "Base config deserialized from ~/.codex/config.toml.", @@ -3999,6 +4016,10 @@ "default": null, "description": "Settings for app-specific controls." }, + "apps_mcp_product_sku": { + "description": "Optional product SKU forwarded on host-owned Codex Apps MCP requests.", + "type": "string" + }, "audio": { "allOf": [ { @@ -4056,6 +4077,12 @@ "description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.", "type": "string" }, + "desktop": { + "additionalProperties": true, + "default": null, + "description": "Opaque desktop settings stored alongside the rest of config.toml.", + "type": "object" + }, "developer_instructions": { "default": null, "description": "Developer instructions inserted as a `developer` role message.", @@ -4210,9 +4237,6 @@ "in_app_browser": { "type": "boolean" }, - "include_apply_patch_tool": { - "type": "boolean" - }, "js_repl": { "type": "boolean" }, @@ -4383,9 +4407,13 @@ "description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme." }, "forced_chatgpt_workspace_id": { + "allOf": [ + { + "$ref": "#/definitions/ForcedChatgptWorkspaceIds" + } + ], "default": null, - "description": "When set, restricts ChatGPT login to a specific workspace identifier.", - "type": "string" + "description": "When set, restricts ChatGPT login to one or more workspace identifiers." }, "forced_login_method": { "allOf": [ @@ -4781,10 +4809,6 @@ "default": null, "description": "Windows-specific configuration." }, - "windows_wsl_setup_acknowledged": { - "description": "Tracks whether the Windows onboarding screen has been acknowledged.", - "type": "boolean" - }, "zsh_path": { "allOf": [ { diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index d973f40e45..71bc026a13 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -116,6 +116,7 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other, ) => false, @@ -1234,7 +1235,9 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { .map(|item| match item { UserInput::Text { text, .. } => text.clone(), UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::LocalImage { path, .. } => { + format!("[local_image:{}]", path.display()) + } UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), _ => "[input]".to_string(), diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index d1e679a631..a4f7e038e0 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -384,6 +384,7 @@ fn build_arc_monitor_message_item( | ResponseItem::ToolSearchOutput { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => None, } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 25d037abd6..5a33c62033 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -59,6 +59,8 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, + pub profile_workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, pub reasoning_summary: Option, @@ -84,6 +86,8 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadTurnContextOverrides { pub cwd: Option, + pub workspace_roots: Option>, + pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -286,6 +290,8 @@ impl CodexThread { ) -> SessionSettingsUpdate { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -311,6 +317,8 @@ impl CodexThread { SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index cc31d50b13..bc684647eb 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -302,6 +302,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 4dafb95f8e..3f5368a960 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -12,6 +12,10 @@ use crate::compact_remote::build_compact_request_log_data; use crate::compact_remote::log_remote_compact_failure; use crate::compact_remote::process_compacted_history; use crate::compact_remote::trim_function_call_history_to_fit_context_window; +use crate::hook_runtime::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; @@ -97,6 +101,21 @@ async fn run_remote_compact_task_inner( phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track( + sess.as_ref(), + codex_analytics::CompactionStatus::Interrupted, + Some(error), + ) + .await; + return Err(CodexErr::TurnAborted); + } + } let result = run_remote_compact_task_inner_impl( sess, turn_context, @@ -104,13 +123,16 @@ async fn run_remote_compact_task_inner( initial_context_injection, ) .await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), @@ -165,9 +187,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::ContextCompaction { - encrypted_content: None, - }); + input.push(ResponseItem::CompactionTrigger); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -276,38 +296,25 @@ async fn run_remote_compaction_request_v2( Err(err) }) .await?; - collect_context_compaction_output(stream).await + collect_compaction_output(stream).await } -async fn collect_context_compaction_output( +async fn collect_compaction_output( mut stream: ResponseStream, ) -> CodexResult<(ResponseItem, String)> { let mut output_item_count = 0usize; - let mut context_compaction_count = 0usize; - let mut context_compaction_output = None; + let mut compaction_count = 0usize; + let mut compaction_output = None; let mut completed_response_id = None; while let Some(event) = stream.next().await { match event? { ResponseEvent::OutputItemDone(item) => { output_item_count += 1; - match item { - ResponseItem::ContextCompaction { - encrypted_content: Some(_), - } => { - context_compaction_count += 1; - if context_compaction_output.is_none() { - context_compaction_output = Some(item); - } + if let ResponseItem::Compaction { .. } = item { + compaction_count += 1; + if compaction_output.is_none() { + compaction_output = Some(item); } - ResponseItem::ContextCompaction { - encrypted_content: None, - } => { - return Err(CodexErr::Fatal( - "remote compaction v2 returned context_compaction without encrypted_content" - .to_string(), - )); - } - _ => {} } } ResponseEvent::Completed { response_id, .. } => { @@ -324,16 +331,16 @@ async fn collect_context_compaction_output( )); }; - if context_compaction_count != 1 { + if compaction_count != 1 { return Err(CodexErr::Fatal(format!( - "remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items" + "remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items" ))); } - let Some(context_compaction_output) = context_compaction_output else { - unreachable!("context compaction output must exist when count is exactly one"); + let Some(compaction_output) = compaction_output else { + unreachable!("compaction output must exist when count is exactly one"); }; - Ok((context_compaction_output, response_id)) + Ok((compaction_output, response_id)) } fn build_v2_compacted_history( @@ -410,8 +417,8 @@ mod tests { encrypted_content: "old".to_string(), }, ]; - let output = ResponseItem::ContextCompaction { - encrypted_content: Some("new".to_string()), + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), }; let history = build_v2_compacted_history(&input, output.clone()); @@ -428,9 +435,9 @@ mod tests { } #[tokio::test] - async fn collect_context_compaction_output_accepts_additional_output_items() { - let context_compaction = ResponseItem::ContextCompaction { - encrypted_content: Some("encrypted".to_string()), + async fn collect_compaction_output_accepts_additional_output_items() { + let compaction = ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), }; let stream = response_stream(vec![ Ok(ResponseEvent::OutputItemDone(message( @@ -438,7 +445,7 @@ mod tests { "IGNORED_COMPACT_REPLY", Some(MessagePhase::FinalAnswer), ))), - Ok(ResponseEvent::OutputItemDone(context_compaction.clone())), + Ok(ResponseEvent::OutputItemDone(compaction.clone())), Ok(ResponseEvent::Completed { response_id: "resp-compact".to_string(), token_usage: None, @@ -446,11 +453,11 @@ mod tests { }), ]); - let (output, response_id) = collect_context_compaction_output(stream) + let (output, response_id) = collect_compaction_output(stream) .await - .expect("context compaction should be collected"); + .expect("compaction should be collected"); - assert_eq!(output, context_compaction); + assert_eq!(output, compaction); assert_eq!(response_id, "resp-compact"); } } diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index b5c5c2ec18..cd84f06cda 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -29,7 +29,6 @@ use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -821,6 +820,7 @@ flag = false async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()> { use base64::Engine; + use codex_protocol::protocol::SandboxPolicy; let Some(home) = dirs::home_dir() else { return Ok(()); @@ -913,14 +913,7 @@ allowed_sandbox_modes = ["read-only"] state .requirements() .permission_profile - .can_set(&PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - )) + .can_set(&PermissionProfile::workspace_write()) .is_err() ); @@ -1233,11 +1226,9 @@ allowed_sandbox_modes = ["read-only"] let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( - config_requirements.permission_profile.can_set( - &PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy() - ) - ), + config_requirements + .permission_profile + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), @@ -1570,9 +1561,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: layers .requirements() .permission_profile - .can_set(&PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy() - )) + .can_set(&PermissionProfile::workspace_write()) .is_ok() ); @@ -2353,6 +2342,7 @@ model = "project-model" model_instructions_file = "instructions.md" openai_base_url = "https://attacker.example/v1" chatgpt_base_url = "https://attacker.example/backend-api" +apps_mcp_product_sku = "attacker" model_provider = "attacker" notify = ["sh", "-c", "echo attacker"] profile = "attacker" @@ -2404,6 +2394,7 @@ wire_api = "responses" let ignored_project_config_keys = vec![ "openai_base_url", "chatgpt_base_url", + "apps_mcp_product_sku", "model_provider", "model_providers", "notify", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ff28e50436..ce7a36d93f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -29,6 +29,7 @@ use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; @@ -70,7 +71,6 @@ use codex_model_provider_info::WireApi; 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; @@ -105,6 +105,18 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; +fn active_permission_profile_state( + permission_profile: PermissionProfile, + profile_id: impl Into, +) -> PermissionProfileState { + PermissionProfileState::from_constrained_active_profile( + Constrained::allow_any(permission_profile), + Some(ActivePermissionProfile::new(profile_id)), + Vec::new(), + ) + .expect("active permission profile state should be valid") +} + fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -722,6 +734,10 @@ fn config_toml_deserializes_permission_profiles() { let toml = r#" default_permissions = "workspace" +[permissions.workspace.workspace_roots] +"~/code/openai" = true +"~/code/ignored" = false + [permissions.workspace.filesystem] ":minimal" = "read" @@ -748,6 +764,12 @@ allow_upstream_proxy = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("~/code/ignored".to_string(), false), + ("~/code/openai".to_string(), true), + ]), + }), filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -803,6 +825,7 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -956,6 +979,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1106,6 +1130,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1208,6 +1233,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1256,6 +1282,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1302,6 +1329,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -1335,6 +1363,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: ) .await?; + let cwd_root = cwd.path().abs(); let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), @@ -1346,14 +1375,14 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + path: FileSystemPath::Path { + path: cwd_root.clone(), }, access: FileSystemAccessMode::Write, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some("docs".into())), + path: FileSystemPath::Path { + path: cwd_root.join("docs"), }, access: FileSystemAccessMode::Read, }, @@ -1374,6 +1403,12 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: exclude_slash_tmp: true, } ); + assert!( + !config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()) + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1406,7 +1441,10 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), @@ -1415,6 +1453,30 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: Ok(()) } +#[test] +fn permission_snapshot_setter_preserves_permission_constraints() { + let initial_profile = PermissionProfile::read_only(); + let mut permissions = Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::Never), + Constrained::allow_only(initial_profile.clone()), + ) + .expect("initial permissions should satisfy constraints"); + + let err = permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect_err("workspace profile should violate read-only constraint"); + + assert_eq!(permissions.permission_profile(), &initial_profile); + assert_eq!(permissions.active_permission_profile(), None); + assert!( + matches!(err, ConstraintError::InvalidValue { .. }), + "expected invalid value constraint error, got {err:?}" + ); +} + #[tokio::test] async fn permission_profile_override_preserves_managed_unrestricted_filesystem() -> std::io::Result<()> { @@ -1436,7 +1498,10 @@ async fn permission_profile_override_preserves_managed_unrestricted_filesystem() ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ExternalSandbox { @@ -1568,6 +1633,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1605,7 +1671,10 @@ async fn permission_profile_override_preserves_configured_network_policy_without config.permissions.network.is_none(), "profile network.enabled should not start the managed network proxy" ); - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); Ok(()) } @@ -1613,7 +1682,9 @@ async fn permission_profile_override_preserves_configured_network_policy_without async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?; + tokio::fs::write(extra_root.path().join(".git"), "gitdir: nowhere").await?; let config = Config::load_from_base_config_with_overrides( ConfigToml { @@ -1622,6 +1693,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), entries: BTreeMap::from([( @@ -1640,6 +1712,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), + additional_writable_roots: vec![extra_root.path().to_path_buf()], ..Default::default() }, codex_home.abs(), @@ -1653,21 +1726,23 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: .glob_scan_max_depth, Some(2) ); - let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) - .to_string_lossy() - .into_owned(); - assert!( - config - .permissions - .file_system_sandbox_policy() - .entries - .contains(&FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: expected_pattern, - }, - access: FileSystemAccessMode::None, - }) - ); + for root in [cwd.path(), extra_root.path()] { + let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", root) + .to_string_lossy() + .into_owned(); + assert!( + config + .permissions + .file_system_sandbox_policy() + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: expected_pattern, + }, + access: FileSystemAccessMode::None, + }) + ); + } assert!( !config .permissions @@ -1697,6 +1772,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1767,8 +1843,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() --> std::io::Result<()> { +async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; @@ -1790,20 +1865,112 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi let policy = config.permissions.file_system_sandbox_policy(); assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + !policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), + "expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}" ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY).with_modifications( - vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )) + ); + Ok(()) +} + +#[tokio::test] +async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots() +-> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let cwd = temp_dir.path().join("frontend"); + let runtime_root = temp_dir.path().join("backend"); + let profile_root = temp_dir.path().join("shared"); + for root in [&cwd, &runtime_root, &profile_root] { + std::fs::create_dir_all(root.join(".git"))?; + std::fs::create_dir_all(root.join(".codex"))?; + } + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "dev".to_string(), + PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([( + profile_root.to_string_lossy().into_owned(), + true, + )]), + }), + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":workspace_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + (".git".to_string(), FileSystemAccessMode::Read), + (".codex".to_string(), FileSystemAccessMode::Read), + ])), + )]), + }), + network: None, }, - ] - ) - ) + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.clone()), + additional_writable_roots: vec![runtime_root.clone()], + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let cwd_abs = cwd.abs(); + let runtime_root_abs = runtime_root.abs(); + let profile_root_abs = profile_root.abs(); + assert_eq!( + config.workspace_roots, + vec![cwd_abs.clone(), runtime_root_abs.clone()] + ); + assert_eq!( + config.permissions.workspace_roots(), + &[cwd_abs.clone(), runtime_root_abs.clone()] + ); + assert_eq!( + config.effective_workspace_roots(), + vec![ + cwd_abs.clone(), + runtime_root_abs.clone(), + profile_root_abs.clone() + ] + ); + + let policy = config.permissions.file_system_sandbox_policy(); + for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] { + assert!( + policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()), + "expected workspace root to be writable, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".git"), cwd.as_path()), + "expected .git carveout under {root:?}, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".codex"), cwd.as_path()), + "expected .codex carveout under {root:?}, policy: {policy:?}" + ); + } + assert_eq!( + config.permissions.profile_workspace_roots(), + std::slice::from_ref(&profile_root_abs) + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) ); Ok(()) } @@ -2071,7 +2238,7 @@ async fn default_permissions_can_select_builtin_full_access_profile() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); assert_eq!( @@ -2188,6 +2355,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2244,6 +2412,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2304,6 +2473,7 @@ async fn load_workspace_permission_profile( #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2347,6 +2517,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2383,6 +2554,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: None, network: None, }) @@ -2411,6 +2583,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -2446,6 +2619,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2492,6 +2666,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2725,6 +2900,55 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() ); } +#[tokio::test] +async fn forced_chatgpt_workspace_id_empty_values_disable_runtime_restriction() +-> std::io::Result<()> { + let cases: Vec<(&str, &str, Option>)> = vec![ + ("unset", "", None), + ("empty string", r#"forced_chatgpt_workspace_id = """#, None), + ( + "whitespace string", + r#"forced_chatgpt_workspace_id = " ""#, + None, + ), + ("empty list", r#"forced_chatgpt_workspace_id = []"#, None), + ( + "blank list entries", + r#"forced_chatgpt_workspace_id = ["", " "]"#, + None, + ), + ( + "mixed list entries", + r#"forced_chatgpt_workspace_id = ["", " 123e4567-e89b-42d3-a456-426614174000 ", "123e4567-e89b-42d3-a456-426614174001"]"#, + Some(vec![ + "123e4567-e89b-42d3-a456-426614174000", + "123e4567-e89b-42d3-a456-426614174001", + ]), + ), + ]; + + for (name, toml, expected) in cases { + let cfg_toml: ConfigToml = toml::from_str(toml) + .unwrap_or_else(|err| panic!("{name} should parse forced_chatgpt_workspace_id: {err}")); + let config = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await?; + + let expected = expected.map(|values| { + values + .into_iter() + .map(ToString::to_string) + .collect::>() + }); + assert_eq!(config.forced_chatgpt_workspace_id, expected, "{name}"); + } + + Ok(()) +} + #[tokio::test] async fn legacy_remote_thread_store_endpoint_is_rejected() { let cfg: ConfigToml = @@ -3038,13 +3262,15 @@ exclude_slash_tmp = true ); continue; } + assert_eq!( + config.permissions.workspace_roots(), + &[cwd.abs(), extra_root.clone()] + ); assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, + path: FileSystemPath::Path { path: cwd.abs() }, access: FileSystemAccessMode::Write, }) ); @@ -3063,15 +3289,16 @@ exclude_slash_tmp = true file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some( - subpath.into() - )), + path: FileSystemPath::Path { + path: AbsolutePathBuf::resolve_path_against_base( + subpath, + cwd.path() + ), }, access: FileSystemAccessMode::Read, }), - "case `{name}` should preserve `{subpath}` as a symbolic project-root \ - metadata carveout" + "case `{name}` should materialize `{subpath}` for the runtime workspace \ + root" ); } } @@ -4076,8 +4303,7 @@ fn web_search_mode_disabled_overrides_legacy_request() { #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let permission_profile = - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()); + let permission_profile = PermissionProfile::read_only(); let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile); assert_eq!(mode, WebSearchMode::Cached); @@ -4271,7 +4497,6 @@ async fn feature_table_overrides_legacy_flags() -> std::io::Result<()> { .await?; assert!(!config.features.enabled(Feature::ApplyPatchFreeform)); - assert!(!config.include_apply_patch_tool); Ok(()) } @@ -4612,6 +4837,57 @@ approval_mode = "approve" ); } +#[test] +fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> { + let parsed = toml::from_str::( + r#" +[desktop] +appearanceTheme = "dark" +selected-avatar-id = "codex" +recentViews = ["threads", "settings"] + +[desktop.workspace] +collapsed = true +width = 320 +pane = { selected = "console", expanded = false } +"#, + )?; + + let desktop = parsed + .desktop + .as_ref() + .expect("desktop settings should deserialize"); + assert_eq!( + desktop.get("appearanceTheme"), + Some(&serde_json::json!("dark")) + ); + assert_eq!( + desktop.get("selected-avatar-id"), + Some(&serde_json::json!("codex")) + ); + assert_eq!( + desktop.get("recentViews"), + Some(&serde_json::json!(["threads", "settings"])) + ); + assert_eq!( + desktop.get("workspace"), + Some(&serde_json::json!({ + "collapsed": true, + "width": 320, + "pane": { + "selected": "console", + "expanded": false, + }, + })) + ); + + let serialized = toml::to_string(&parsed)?; + let reparsed = toml::from_str::(&serialized)?; + assert_eq!(reparsed.desktop, parsed.desktop); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4624,12 +4900,14 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); config.apps_mcp_path_override = Some("/custom/mcp".to_string()); + config.apps_mcp_product_sku = Some("tpp".to_string()); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); assert_eq!( mcp_config.apps_mcp_path_override.as_deref(), Some("/custom/mcp") ); + assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp")); let _ = config.features.disable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; @@ -7375,10 +7653,11 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7390,6 +7669,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7436,6 +7717,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -7456,7 +7738,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -7467,7 +7748,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -7824,10 +8104,11 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_provider: fixture.openai_custom_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -7839,6 +8120,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7885,6 +8168,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -7905,7 +8189,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -7916,7 +8199,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -7987,10 +8269,11 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8002,6 +8285,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8048,6 +8333,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -8068,7 +8354,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -8079,7 +8364,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -8135,10 +8419,11 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_provider: fixture.openai_provider.clone(), permissions: Permissions { approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( + permission_profile_state: active_permission_profile_state( + PermissionProfile::read_only(), BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + ), + workspace_roots: vec![fixture.cwd()], network: None, allow_login_shell: true, shell_environment_policy: ShellEnvironmentPolicy::default(), @@ -8150,6 +8435,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8196,6 +8483,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -8216,7 +8504,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -8227,7 +8514,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { suppress_unstable_features_warning: false, active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Default::default(), check_for_update_on_startup: true, disable_paste_burst: false, @@ -8639,29 +8925,26 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; - let constrained = Constrained::new( - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), - |candidate| { - if matches!( - candidate, - PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, - .. - } if entries - .iter() - .any(|entry| entry.access.can_write()) - ) { - Ok(()) - } else { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{candidate:?}"), - allowed: "[WorkspaceWrite]".to_string(), - requirement_source: RequirementSource::Unknown, - }) - } - }, - )?; + let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| { + if matches!( + candidate, + PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } if entries + .iter() + .any(|entry| entry.access.can_write()) + ) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[WorkspaceWrite]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; let resolution = derive_legacy_sandbox_policy_for_test( &cfg, @@ -8920,6 +9203,27 @@ path = "/custom/mcp" Ok(()) } +#[tokio::test] +async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" +apps_mcp_product_sku = "tpp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp")); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9087,7 +9391,7 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( let expected_sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy); assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); Ok(()) @@ -9115,7 +9419,7 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); assert_eq!(config.permissions.active_permission_profile(), None); @@ -9235,7 +9539,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), ), WebSearchMode::Cached, ); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 80bf4d9bf1..5418d3b268 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -44,8 +44,6 @@ pub enum ConfigEdit { SetNoticeFastDefaultOptOut(bool), /// Toggle the rate limit model nudge acknowledgement flag. SetNoticeHideRateLimitModelNudge(bool), - /// Toggle the Windows onboarding acknowledgement flag. - SetWindowsWslSetupAcknowledged(bool), /// Toggle the model migration prompt acknowledgement flag. SetNoticeHideModelMigrationPrompt(String, bool), /// Toggle the home external config migration prompt acknowledgement flag. @@ -645,11 +643,6 @@ impl ConfigDocument { &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), - ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( - Scope::Global, - &["windows_wsl_setup_acknowledged"], - value(*acknowledged), - )), ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), ConfigEdit::AddToolSuggestDisabledTool(disabled_tool) => { Ok(self.add_tool_suggest_disabled_tool(disabled_tool)) @@ -1240,12 +1233,6 @@ impl ConfigEditsBuilder { self } - pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); - self - } - pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap) -> Self { self.edits .extend(model_availability_nux_count_edits(shown_count)); diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 78b88d2f3a..480225fb99 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -348,12 +348,10 @@ pub(crate) fn validate_feature_requirements_in_config_toml( let configured_features = Features::from_sources( FeatureConfigSource { features: cfg.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { features: profile.features.as_ref(), - 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 cc5203251c..18158671ec 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -117,6 +116,7 @@ use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; +use crate::config::permissions::compile_permission_profile_workspace_roots; use crate::config::permissions::default_builtin_permission_profile_name; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_for_profile_selection; @@ -134,6 +134,7 @@ mod managed_features; mod network_proxy_spec; mod otel; mod permissions; +mod resolved_permission_profile; #[cfg(test)] mod schema; pub use codex_config::ConfigLoadOptions; @@ -148,6 +149,8 @@ pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; pub(crate) use permissions::resolve_permission_profile; +pub use resolved_permission_profile::PermissionProfileSnapshot; +pub(crate) use resolved_permission_profile::PermissionProfileState; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; @@ -247,12 +250,12 @@ pub(crate) async fn test_config() -> Config { pub struct Permissions { /// Approval policy for executing commands. pub approval_policy: Constrained, - /// Canonical effective runtime permissions after config requirements and - /// runtime readable-root additions have been applied. - pub permission_profile: Constrained, - /// Named or implicit built-in profile selected by config, rather than an - /// ad-hoc override. - pub active_permission_profile: Option, + /// Constrained permission profile plus its selected profile identity, if + /// the profile came from a built-in or named config profile. + permission_profile_state: PermissionProfileState, + /// Thread-scoped runtime workspace roots. Symbolic `:workspace_roots` + /// entries in the permission profile are materialized against these roots. + workspace_roots: Vec, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -274,33 +277,131 @@ pub struct Permissions { } impl Permissions { + /// Build permissions from the constrained values required for a minimal + /// in-process configuration. + pub fn from_approval_and_profile( + approval_policy: Constrained, + permission_profile: Constrained, + ) -> ConstraintResult { + Ok(Self { + approval_policy, + permission_profile_state: PermissionProfileState::from_constrained_legacy( + permission_profile, + )?, + workspace_roots: Vec::new(), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + windows_sandbox_private_desktop: true, + }) + } + + pub(crate) fn permission_profile_state(&self) -> &PermissionProfileState { + &self.permission_profile_state + } + + pub(crate) fn set_permission_profile_state( + &mut self, + permission_profile_state: PermissionProfileState, + ) { + self.permission_profile_state = permission_profile_state; + } + + /// Apply a permission profile snapshot emitted by core session state. + /// + /// This is a trusted-state bridge for consumers of `SessionConfigured`. + /// Config loading and app-server selection should resolve named profiles + /// through config instead of constructing a snapshot directly. + pub fn set_permission_profile_from_session_snapshot( + &mut self, + snapshot: PermissionProfileSnapshot, + ) -> ConstraintResult<()> { + self.permission_profile_state + .set_permission_profile_snapshot(snapshot) + } + + /// Replace the current permission constraints with a trusted session + /// snapshot. This is only for clients that must mirror core session state + /// after their local config constraints reject the snapshot. + pub fn replace_permission_profile_from_session_snapshot( + &mut self, + snapshot: PermissionProfileSnapshot, + ) -> ConstraintResult<()> { + let permission_profile = Constrained::allow_only(snapshot.permission_profile().clone()); + self.permission_profile_state = PermissionProfileState::from_constrained_resolved( + permission_profile, + snapshot.into_resolved_permission_profile(), + )?; + Ok(()) + } + + /// Borrow the canonical profile before runtime workspace-root + /// materialization has been applied. + pub fn permission_profile(&self) -> &PermissionProfile { + self.permission_profile_state.permission_profile() + } + + pub fn can_set_permission_profile( + &self, + permission_profile: &PermissionProfile, + ) -> ConstraintResult<()> { + self.permission_profile_state + .can_set_legacy_permission_profile(permission_profile) + } + + pub fn set_workspace_roots(&mut self, workspace_roots: Vec) { + self.workspace_roots = workspace_roots; + } + + pub fn workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + /// Workspace roots that came from user-visible configuration or runtime + /// selection. Internal Codex-only writable roots are intentionally excluded. + pub fn user_visible_workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + } + /// Effective runtime permissions after config requirements and runtime - /// readable-root additions have been applied. - pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + /// workspace-root materialization have been applied. + pub fn effective_permission_profile(&self) -> PermissionProfile { + self.materialized_permission_profile() } /// Named profile selected by config, if the current profile has one. pub fn active_permission_profile(&self) -> Option { - self.active_permission_profile.clone() + self.permission_profile_state.active_permission_profile() } /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } /// Effective network sandbox policy derived from the canonical profile. pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.permission_profile.get().network_sandbox_policy() + self.permission_profile().network_sandbox_policy() } /// Legacy compatibility projection derived from the canonical profile. pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy { - let permission_profile = self.permission_profile.get(); + let permission_profile = self.materialized_permission_profile(); let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); compatibility_sandbox_policy_for_permission_profile( - permission_profile, + &permission_profile, &file_system_sandbox_policy, permission_profile.network_sandbox_policy(), cwd, @@ -322,11 +423,12 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile.can_set(&permission_profile) + self.permission_profile_state + .can_set_legacy_permission_profile(&permission_profile) } - /// Replace permissions from a legacy sandbox policy and keep every - /// permission projection in sync. + /// Set permissions from a legacy sandbox policy and keep every permission + /// projection in sync. pub fn set_legacy_sandbox_policy( &mut self, sandbox_policy: SandboxPolicy, @@ -341,35 +443,39 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); + self.workspace_roots = match &sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + let mut workspace_roots = vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ]; + for root in writable_roots { + if !workspace_roots.iter().any(|existing| existing == root) { + workspace_roots.push(root.clone()); + } + } + workspace_roots + } + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } + | SandboxPolicy::ReadOnly { .. } => vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ], + }; - self.permission_profile.set(permission_profile)?; - self.active_permission_profile = None; + self.permission_profile_state + .set_legacy_permission_profile(permission_profile)?; Ok(()) } - /// Replace permissions from the canonical profile. + /// Set permissions from the canonical profile. pub fn set_permission_profile( &mut self, permission_profile: PermissionProfile, ) -> ConstraintResult<()> { - self.set_permission_profile_with_active_profile( - permission_profile, - /*active_permission_profile*/ None, - ) - } - - /// Replace permissions from the canonical profile and record the named - /// source profile, if one is known. - pub fn set_permission_profile_with_active_profile( - &mut self, - permission_profile: PermissionProfile, - active_permission_profile: Option, - ) -> ConstraintResult<()> { - self.permission_profile.can_set(&permission_profile)?; - - self.permission_profile.set(permission_profile)?; - self.active_permission_profile = active_permission_profile; - Ok(()) + self.permission_profile_state + .set_legacy_permission_profile(permission_profile) } } @@ -577,6 +683,15 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute runtime workspace roots for the session. Symbolic + /// `:workspace_roots` permission entries are materialized against these + /// roots while profile-defined workspace roots remain encoded directly in + /// the permission profile. + pub workspace_roots: Vec, + /// Whether runtime workspace roots were supplied explicitly by the caller + /// or legacy config, rather than defaulting to `cwd`. + pub workspace_roots_explicit: bool, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -726,6 +841,9 @@ pub struct Config { /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, + /// Optional product SKU forwarded to the host-owned apps MCP server. + pub apps_mcp_product_sku: Option, + /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -752,24 +870,18 @@ pub struct Config { /// instructions inserted into developer messages when realtime becomes /// active. pub experimental_realtime_start_instructions: Option, - /// Experimental / do not use. When set, app-server fetches thread-scoped /// config from a remote service at this endpoint. pub experimental_thread_config_endpoint: Option, /// Experimental / do not use. Selects the thread persistence backend. pub experimental_thread_store: ThreadStoreConfig, - /// When set, restricts ChatGPT login to a specific workspace identifier. - pub forced_chatgpt_workspace_id: Option, + /// When set, restricts ChatGPT login to one or more workspace identifiers. + pub forced_chatgpt_workspace_id: Option>, /// When set, restricts the login mechanism users may use. pub forced_login_method: Option, - /// Include the `apply_patch` tool for models that benefit from invoking - /// file edits as a structured tool call. When unset, this falls back to the - /// model info's default preference. - pub include_apply_patch_tool: bool, - /// Explicit or feature-derived web search mode. pub web_search_mode: Constrained, @@ -803,9 +915,6 @@ pub struct Config { /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd pub active_project: ProjectConfig, - /// Tracks whether the Windows onboarding screen has been acknowledged. - pub windows_wsl_setup_acknowledged: bool, - /// Collection of various notices we show the user pub notices: Notice, @@ -891,7 +1000,7 @@ impl AuthManagerConfig for Config { self.cli_auth_credentials_store_mode } - fn forced_chatgpt_workspace_id(&self) -> Option { + fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id.clone() } @@ -1085,8 +1194,21 @@ impl Config { &mut self, sandbox_policy: SandboxPolicy, ) -> ConstraintResult<()> { + self.workspace_roots_explicit = matches!( + &sandbox_policy, + SandboxPolicy::WorkspaceWrite { writable_roots, .. } if !writable_roots.is_empty() + ); self.permissions - .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path()) + .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path())?; + self.workspace_roots = self.permissions.workspace_roots().to_vec(); + Ok(()) + } + + pub fn effective_workspace_roots(&self) -> Vec { + let mut workspace_roots = self.workspace_roots.clone(); + workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned()); + dedupe_absolute_paths(&mut workspace_roots); + workspace_roots } pub fn to_models_manager_config(&self) -> ModelsManagerConfig { @@ -1145,6 +1267,7 @@ impl Config { McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), apps_mcp_path_override: self.apps_mcp_path_override.clone(), + apps_mcp_product_sku: self.apps_mcp_product_sku.clone(), codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, mcp_oauth_callback_port: self.mcp_oauth_callback_port, @@ -1935,6 +2058,14 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit runtime workspace roots for this session. When set, this is + /// the full runtime root list rather than an additive override. + pub workspace_roots: Option>, +} + +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); } /// Resolves the OSS provider from CLI override, profile config, or global config. @@ -2248,6 +2379,7 @@ impl Config { ephemeral, bypass_hook_trust, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); @@ -2302,12 +2434,10 @@ impl Config { let configured_features = Features::from_sources( FeatureConfigSource { features: cfg.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { features: config_profile.features.as_ref(), - include_apply_patch_tool: None, experimental_use_unified_exec_tool: config_profile .experimental_use_unified_exec_tool, }, @@ -2340,11 +2470,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2386,12 +2515,7 @@ impl Config { }; let memories_root = memory_root(&codex_home); std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } + let internal_writable_roots = vec![memories_root]; let profiles_are_active = default_permissions_override.is_some() || matches!( @@ -2401,11 +2525,46 @@ impl Config { || permission_config_syntax.is_none(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); + let should_seed_legacy_workspace_roots = default_permissions.is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let legacy_workspace_roots_explicit = should_seed_legacy_workspace_roots + && cfg + .sandbox_workspace_write + .as_ref() + .is_some_and(|sandbox_workspace_write| { + !sandbox_workspace_write.writable_roots.is_empty() + }); + let workspace_roots_explicit = workspace_roots_override.is_some() + || !requested_additional_writable_roots.is_empty() + || legacy_workspace_roots_explicit; + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( mut configured_network_proxy_config, permission_profile, file_system_sandbox_policy, mut active_permission_profile, + mut profile_workspace_roots, ) = if let Some(mut permission_profile) = permission_profile { let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -2429,18 +2588,24 @@ impl Config { } else { NetworkProxyConfig::default() }; + let materialized_file_system_sandbox_policy = file_system_sandbox_policy + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots); + let materialized_permission_profile = + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &materialized_file_system_sandbox_policy, + network_sandbox_policy, + ); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, + &materialized_permission_profile, + &materialized_file_system_sandbox_policy, network_sandbox_policy, resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2452,6 +2617,7 @@ impl Config { permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) } else if profiles_are_active { let default_permissions = default_permissions.unwrap_or_else(|| { @@ -2474,6 +2640,20 @@ impl Config { resolved_cwd.as_path(), &mut startup_warnings, )?; + let mut configured_workspace_roots = compile_permission_profile_workspace_roots( + cfg.permissions.as_ref(), + default_permissions, + resolved_cwd.as_path(), + )?; + if using_implicit_builtin_profile + && default_permissions == BUILT_IN_WORKSPACE_PROFILE + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + dedupe_absolute_paths(&mut configured_workspace_roots); + file_system_sandbox_policy = file_system_sandbox_policy + .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); let mut permission_profile = if let Some(permission_profile) = builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { @@ -2484,36 +2664,26 @@ impl Config { network_sandbox_policy, ) }; + let materialized_file_system_sandbox_policy = file_system_sandbox_policy + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots); + let materialized_permission_profile = + PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &materialized_file_system_sandbox_policy, + network_sandbox_policy, + ); let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, + &materialized_permission_profile, + &materialized_file_system_sandbox_policy, network_sandbox_policy, resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); - permission_profile = PermissionProfile::from_runtime_permissions( + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); + permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); @@ -2529,28 +2699,14 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + Some(ActivePermissionProfile::new(default_permissions)) }; ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, active_permission_profile, + configured_workspace_roots, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2583,25 +2739,21 @@ impl Config { } let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. + let materialized_file_system_sandbox_policy = permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&workspace_roots) + .file_system_sandbox_policy(); if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( + && materialized_file_system_sandbox_policy.can_write_path_with_cwd( resolved_cwd.as_path(), resolved_cwd.as_path(), ) - && !file_system_sandbox_policy.has_full_disk_write_access() + && !materialized_file_system_sandbox_policy.has_full_disk_write_access() { - // 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 `:workspace_roots` - // entries. + // Keep Codex runtime write access while storing the runtime + // workspace roots separately on the thread. file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2613,6 +2765,7 @@ impl Config { permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) }; if enable_network_proxy && permission_profile.network_sandbox_policy().is_enabled() { @@ -2846,18 +2999,20 @@ impl Config { config }; - let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); - let forced_chatgpt_workspace_id = - cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + let forced_chatgpt_workspace_id = cfg + .forced_chatgpt_workspace_id + .clone() + .map(codex_config::config_toml::ForcedChatgptWorkspaceIds::into_vec) + .map(|values| { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .filter(|values| !values.is_empty()); let forced_login_method = cfg.forced_login_method; @@ -3034,6 +3189,7 @@ impl Config { // The selected profile no longer describes the effective // permissions after requirements forced a fallback. active_permission_profile = None; + profile_workspace_roots.clear(); } apply_requirement_constrained_value( "web_search_mode", @@ -3104,6 +3260,12 @@ impl Config { .value .set(effective_permission_profile) .map_err(std::io::Error::from)?; + let permission_profile_state = PermissionProfileState::from_constrained_active_profile( + constrained_permission_profile.value, + active_permission_profile, + profile_workspace_roots, + ) + .map_err(std::io::Error::from)?; let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings); let config = Self { model, @@ -3114,11 +3276,13 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots: workspace_roots.clone(), + workspace_roots_explicit, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, - permission_profile: constrained_permission_profile.value, - active_permission_profile, + permission_profile_state, + workspace_roots, network, allow_login_shell, shell_environment_policy, @@ -3229,6 +3393,7 @@ impl Config { .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), apps_mcp_path_override, + apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { @@ -3255,7 +3420,6 @@ impl Config { experimental_thread_store: thread_store_config(cfg.experimental_thread_store), forced_chatgpt_workspace_id, forced_login_method, - include_apply_patch_tool: include_apply_patch_tool_flag, web_search_mode: constrained_web_search_mode.value, web_search_config, use_experimental_unified_exec_tool, @@ -3268,7 +3432,6 @@ impl Config { .unwrap_or(false), active_profile: active_profile_name, active_project, - windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), notices, check_for_update_on_startup, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), @@ -3404,7 +3567,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile.get(), + self.permissions.permission_profile(), PermissionProfile::Disabled ) && self .config_layer_stack @@ -3418,13 +3581,6 @@ impl Config { } } -pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { - config_layer_stack - .layers_high_to_low() - .into_iter() - .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) -} - fn guardian_policy_config_from_requirements( requirements_toml: &ConfigRequirementsToml, ) -> Option { @@ -3438,23 +3594,6 @@ fn normalize_guardian_policy_config(value: Option<&str>) -> Option { }) } -fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { - let Some(table) = value.as_table() else { - return false; - }; - if table.contains_key("experimental_instructions_file") { - return true; - } - let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { - return false; - }; - profiles.values().any(|profile| { - profile.as_table().is_some_and(|profile_table| { - profile_table.contains_key("experimental_instructions_file") - }) - }) -} - /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 14b7c1c330..6dfb1e3a25 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -5,13 +5,8 @@ use codex_network_proxy::NetworkDomainPermission; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; -fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - fn domain_permissions( entries: impl IntoIterator, ) -> NetworkDomainPermissionsToml { @@ -62,7 +57,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()), + &PermissionProfile::read_only(), ) .expect("config should stay within the managed allowlist"); @@ -97,7 +92,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed allowlist should not erase a user deny"); @@ -129,7 +124,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still allow user edits"); @@ -207,7 +202,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("yolo mode should pin the effective policy to the managed baseline"); @@ -241,7 +236,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still load"); @@ -270,7 +265,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed-only allowlist should still load"); @@ -300,7 +295,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -324,7 +319,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -351,7 +346,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("deny-only requirements should not constrain the allowlist"); @@ -384,7 +379,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("allow-only requirements should not constrain the denylist"); @@ -417,7 +412,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("default mode should merge managed and user deny entries"); @@ -452,7 +447,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still allow user edits"); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b93b8745d7..9f8fcd9ee3 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -13,6 +13,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; use codex_features::NetworkProxyDomainPermissionToml; @@ -33,6 +34,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::project_roots_glob_pattern; use codex_utils_absolute_path::AbsolutePathBuf; use super::ProjectConfig; @@ -72,12 +74,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { @@ -303,6 +305,41 @@ pub(crate) fn compile_permission_profile_selection( compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) } +pub(crate) fn compile_permission_profile_workspace_roots( + permissions: Option<&PermissionsToml>, + profile_name: &str, + policy_cwd: &Path, +) -> io::Result> { + if is_builtin_permission_profile_name(profile_name) { + return Ok(Vec::new()); + } + reject_unknown_builtin_permission_profile(profile_name)?; + + let permissions = permissions.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let profile = resolve_permission_profile(permissions, profile_name)?; + Ok(compile_workspace_roots( + profile.workspace_roots.as_ref(), + policy_cwd, + )) +} + +fn compile_workspace_roots( + workspace_roots: Option<&WorkspaceRootsToml>, + policy_cwd: &Path, +) -> Vec { + workspace_roots.map_or_else(Vec::new, |workspace_roots| { + workspace_roots + .enabled_roots() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, policy_cwd)) + .collect() + }) +} + fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { if profile_name.starts_with(':') { return Err(io::Error::new( @@ -478,7 +515,7 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - policy_cwd: &Path, + _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the @@ -493,15 +530,10 @@ fn compile_scoped_filesystem_pattern( match parse_special_path(path) { Some(FileSystemSpecialPath::ProjectRoots { .. }) => { - // `: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. - Ok( - AbsolutePathBuf::resolve_path_against_base(&subpath, policy_cwd) - .to_string_lossy() - .to_string(), - ) + // Keep `:workspace_roots` glob patterns symbolic until the active + // workspace roots are known, then materialize them for cwd and any + // runtime/profile-added workspace roots together. + Ok(project_roots_glob_pattern(&subpath)) } Some(_) => Err(io::Error::new( io::ErrorKind::InvalidInput, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 86a3c604dd..51cf13912e 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -11,6 +11,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -66,6 +67,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -275,6 +277,39 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() { ); } +#[test] +fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std::io::Result<()> { + let cwd = TempDir::new()?; + let workspace_roots = compile_permission_profile_workspace_roots( + Some(&PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("backend".to_string(), true), + ("disabled".to_string(), false), + ]), + }), + filesystem: None, + network: None, + }, + )]), + }), + "workspace", + cwd.path(), + )?; + + assert_eq!( + workspace_roots, + vec![AbsolutePathBuf::resolve_path_against_base( + "backend", + cwd.path() + )] + ); + Ok(()) +} + #[test] fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() { let filesystem = FilesystemPermissionsToml { @@ -359,6 +394,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( diff --git a/codex-rs/core/src/config/resolved_permission_profile.rs b/codex-rs/core/src/config/resolved_permission_profile.rs new file mode 100644 index 0000000000..f0fff66bcc --- /dev/null +++ b/codex-rs/core/src/config/resolved_permission_profile.rs @@ -0,0 +1,309 @@ +use codex_config::Constrained; +use codex_config::ConstraintResult; +use codex_protocol::models::ActivePermissionProfile; +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_utils_absolute_path::AbsolutePathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BuiltInPermissionProfileId { + ReadOnly, + Workspace, + DangerFullAccess, +} + +impl BuiltInPermissionProfileId { + fn from_str(id: &str) -> Option { + match id { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY => Some(Self::ReadOnly), + BUILT_IN_PERMISSION_PROFILE_WORKSPACE => Some(Self::Workspace), + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS => Some(Self::DangerFullAccess), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + Self::Workspace => BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + Self::DangerFullAccess => BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResolvedPermissionProfile { + Legacy(LegacyPermissionProfile), + BuiltIn(BuiltInPermissionProfile), + Named(NamedPermissionProfile), +} + +/// Trusted snapshot of a resolved permission profile. +/// +/// This is a bridge for already-resolved session/config state. It keeps the +/// concrete `PermissionProfile`, optional active profile id, and +/// profile-defined workspace roots together so `Permissions` can validate and +/// install them atomically. It is not a resolver: callers that are handling +/// user-selected profile ids should resolve those ids through config instead +/// of constructing this type directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileSnapshot { + resolved_permission_profile: ResolvedPermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LegacyPermissionProfile { + permission_profile: PermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BuiltInPermissionProfile { + id: BuiltInPermissionProfileId, + extends: Option, + permission_profile: PermissionProfile, + profile_workspace_roots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NamedPermissionProfile { + id: String, + extends: Option, + permission_profile: PermissionProfile, + profile_workspace_roots: Vec, +} + +impl ResolvedPermissionProfile { + pub(crate) fn from_active_profile( + permission_profile: PermissionProfile, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> Self { + let Some(active_permission_profile) = active_permission_profile else { + return Self::legacy(permission_profile); + }; + + let ActivePermissionProfile { id, extends } = active_permission_profile; + if let Some(built_in_id) = BuiltInPermissionProfileId::from_str(&id) { + Self::BuiltIn(BuiltInPermissionProfile { + id: built_in_id, + extends, + permission_profile, + profile_workspace_roots, + }) + } else { + Self::Named(NamedPermissionProfile { + id, + extends, + permission_profile, + profile_workspace_roots, + }) + } + } + + pub(crate) fn legacy(permission_profile: PermissionProfile) -> Self { + Self::Legacy(LegacyPermissionProfile { permission_profile }) + } + + pub(crate) fn permission_profile(&self) -> &PermissionProfile { + match self { + Self::Legacy(profile) => &profile.permission_profile, + Self::BuiltIn(profile) => &profile.permission_profile, + Self::Named(profile) => &profile.permission_profile, + } + } + + pub(crate) fn active_permission_profile(&self) -> Option { + match self { + Self::Legacy(_) => None, + Self::BuiltIn(profile) => Some(ActivePermissionProfile { + id: profile.id.as_str().to_string(), + extends: profile.extends.clone(), + }), + Self::Named(profile) => Some(ActivePermissionProfile { + id: profile.id.clone(), + extends: profile.extends.clone(), + }), + } + } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + match self { + Self::Legacy(_) => &[], + Self::BuiltIn(profile) => &profile.profile_workspace_roots, + Self::Named(profile) => &profile.profile_workspace_roots, + } + } +} + +impl PermissionProfileSnapshot { + /// Create a snapshot with no active profile id. + /// + /// Prefer this only for legacy data or local overrides that genuinely do + /// not have a named/built-in profile identity. Using this for a built-in or + /// named profile will intentionally clear the active profile metadata. + pub fn legacy(permission_profile: PermissionProfile) -> Self { + Self { + resolved_permission_profile: ResolvedPermissionProfile::legacy(permission_profile), + } + } + + /// Create a snapshot for a known active profile id. + /// + /// Use this only after a trusted caller has already resolved the active id + /// to the supplied concrete `PermissionProfile`. This constructor does not + /// verify that the id and profile match; `Permissions` will still enforce + /// configured permission constraints when the snapshot is installed. + pub fn active( + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, + ) -> Self { + Self::active_with_profile_workspace_roots( + permission_profile, + active_permission_profile, + Vec::new(), + ) + } + + /// Create a snapshot for a known active profile id with profile roots. + /// + /// As with `active`, the caller is responsible for passing the concrete + /// profile and active id that were resolved together. Use this variant when + /// the selected profile declared workspace roots that should remain + /// distinct from turn-scoped runtime workspace roots. + pub fn active_with_profile_workspace_roots( + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, + profile_workspace_roots: Vec, + ) -> Self { + Self { + resolved_permission_profile: ResolvedPermissionProfile::from_active_profile( + permission_profile, + Some(active_permission_profile), + profile_workspace_roots, + ), + } + } + + /// Reconstruct a trusted snapshot from session state. + /// + /// This is intended for session responses emitted by core, where the + /// concrete profile and active profile id were captured together. Avoid + /// using this as a shortcut for arbitrary user input because mismatched + /// arguments can still misrepresent the active profile identity. + pub fn from_session_snapshot( + permission_profile: PermissionProfile, + active_permission_profile: Option, + ) -> Self { + match active_permission_profile { + Some(active_permission_profile) => { + Self::active(permission_profile, active_permission_profile) + } + None => Self::legacy(permission_profile), + } + } + + /// Borrow the concrete permission profile captured in this snapshot. + pub fn permission_profile(&self) -> &PermissionProfile { + self.resolved_permission_profile.permission_profile() + } + + /// Return the active profile id captured in this snapshot, if any. + pub fn active_permission_profile(&self) -> Option { + self.resolved_permission_profile.active_permission_profile() + } + + /// Borrow profile-declared workspace roots captured in this snapshot. + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile.profile_workspace_roots() + } + + pub(crate) fn into_resolved_permission_profile(self) -> ResolvedPermissionProfile { + self.resolved_permission_profile + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PermissionProfileState { + resolved_permission_profile: Constrained, +} + +impl PermissionProfileState { + pub(crate) fn from_constrained_legacy( + constrained_permission_profile: Constrained, + ) -> ConstraintResult { + let resolved = + ResolvedPermissionProfile::legacy(constrained_permission_profile.get().clone()); + Self::from_constrained_resolved(constrained_permission_profile, resolved) + } + + pub(crate) fn from_constrained_active_profile( + constrained_permission_profile: Constrained, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> ConstraintResult { + let resolved = ResolvedPermissionProfile::from_active_profile( + constrained_permission_profile.get().clone(), + active_permission_profile, + profile_workspace_roots, + ); + Self::from_constrained_resolved(constrained_permission_profile, resolved) + } + + pub(crate) fn from_constrained_resolved( + constrained_permission_profile: Constrained, + resolved_permission_profile: ResolvedPermissionProfile, + ) -> ConstraintResult { + let permission_profile_constraint = constrained_permission_profile; + let resolved_permission_profile = Constrained::new( + resolved_permission_profile, + move |candidate: &ResolvedPermissionProfile| { + permission_profile_constraint.can_set(candidate.permission_profile()) + }, + )?; + Ok(Self { + resolved_permission_profile, + }) + } + + pub(crate) fn permission_profile(&self) -> &PermissionProfile { + self.resolved_permission_profile.get().permission_profile() + } + + pub(crate) fn active_permission_profile(&self) -> Option { + self.resolved_permission_profile + .get() + .active_permission_profile() + } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile + .get() + .profile_workspace_roots() + } + + pub(crate) fn can_set_legacy_permission_profile( + &self, + permission_profile: &PermissionProfile, + ) -> ConstraintResult<()> { + let candidate = ResolvedPermissionProfile::legacy(permission_profile.clone()); + self.resolved_permission_profile.can_set(&candidate) + } + + pub(crate) fn set_legacy_permission_profile( + &mut self, + permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.resolved_permission_profile + .set(ResolvedPermissionProfile::legacy(permission_profile)) + } + + pub(crate) fn set_permission_profile_snapshot( + &mut self, + snapshot: PermissionProfileSnapshot, + ) -> ConstraintResult<()> { + self.resolved_permission_profile + .set(snapshot.into_resolved_permission_profile()) + } +} diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 0ccd6c33a7..e982ca17d2 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -8,7 +8,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::WritableRoot; use codex_utils_template::Template; use std::path::Path; @@ -85,27 +84,6 @@ impl PermissionsInstructions { ) } - /// Builds permissions instructions from a legacy sandbox policy. - pub fn from_policy( - sandbox_policy: &SandboxPolicy, - approval_policy: AskForApproval, - approvals_reviewer: ApprovalsReviewer, - exec_policy: &Policy, - cwd: &Path, - exec_permission_approvals_enabled: bool, - request_permissions_tool_enabled: bool, - ) -> Self { - Self::from_permission_profile( - &PermissionProfile::from_legacy_sandbox_policy(sandbox_policy), - approval_policy, - approvals_reviewer, - exec_policy, - cwd, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, - ) - } - fn from_permissions_with_network( sandbox_mode: SandboxMode, network_access: NetworkAccess, @@ -251,10 +229,11 @@ fn sandbox_text(mode: SandboxMode, network_access: NetworkAccess) -> String { } fn writable_roots_text(writable_roots: Option>) -> Option { - let roots = writable_roots?; + let mut roots = writable_roots?; if roots.is_empty() { return None; } + roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path())); let roots_list: Vec = roots .iter() diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 16d5dc631a..6d1aa5d886 100644 --- a/codex-rs/core/src/context/permissions_instructions_tests.rs +++ b/codex-rs/core/src/context/permissions_instructions_tests.rs @@ -53,29 +53,6 @@ fn builds_permissions_with_network_access_override() { ); } -#[test] -fn builds_permissions_from_policy() { - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - let instructions = PermissionsInstructions::from_policy( - &policy, - AskForApproval::UnlessTrusted, - ApprovalsReviewer::User, - &Policy::empty(), - &PathBuf::from("/tmp"), - /*exec_permission_approvals_enabled*/ false, - /*request_permissions_tool_enabled*/ false, - ); - let text = instructions.body(); - assert!(text.contains("Network access is enabled.")); - assert!(text.contains("`approval_policy` is `unless-trusted`")); -} - #[test] fn builds_permissions_from_profile() { let cwd = PathBuf::from("/tmp"); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 80c057e0eb..cfd9297382 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -400,6 +400,7 @@ impl ContextManager { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => item.clone(), } @@ -490,6 +491,7 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Other => false, } } @@ -688,6 +690,7 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { | ResponseItem::LocalShellCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index e7c79e6dd2..d3cae7feed 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -90,9 +90,10 @@ fn parse_user_message(message: &[ContentItem]) -> Option { text_elements: Vec::new(), }); } - ContentItem::InputImage { image_url, .. } => { + ContentItem::InputImage { image_url, detail } => { content.push(UserInput::Image { image_url: image_url.clone(), + detail: *detail, }); } ContentItem::OutputText { text } => { diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index a70b2a69b0..c8311440eb 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -48,8 +48,14 @@ fn parses_user_message_with_text_and_two_images() { text: "Hello world".to_string(), text_elements: Vec::new(), }, - UserInput::Image { image_url: img1 }, - UserInput::Image { image_url: img2 }, + UserInput::Image { + image_url: img1, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, + UserInput::Image { + image_url: img2, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, ]; assert_eq!(user.content, expected_content); } @@ -87,7 +93,10 @@ fn skips_local_image_label_text() { match turn_item { TurnItem::UserMessage(user) => { let expected_content = vec![ - UserInput::Image { image_url }, + UserInput::Image { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, UserInput::Text { text: user_text, text_elements: Vec::new(), @@ -165,7 +174,10 @@ fn skips_unnamed_image_label_text() { match turn_item { TurnItem::UserMessage(user) => { let expected_content = vec![ - UserInput::Image { image_url }, + UserInput::Image { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }, UserInput::Text { text: user_text, text_elements: Vec::new(), diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 8c424c541c..7b0883db2a 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -23,7 +23,6 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; @@ -130,10 +129,6 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { FileSystemSandboxPolicy::external_sandbox() } -fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -669,7 +664,7 @@ async fn evaluates_bash_lc_inner_commands() { "rm -rf /some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -765,7 +760,7 @@ async fn evaluates_heredoc_script_against_prefix_rules() { policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()), command, approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -789,7 +784,7 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { "python3 <<'PY'\nprint('hello')\nPY".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -813,7 +808,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat "python3 <<'PY'\nprint('hello')\nPY".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ @@ -841,7 +836,7 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( "python3 <<'PY'\nprint('hello')\nPY".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), @@ -866,7 +861,7 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { "PATH=/tmp/evil:$PATH cat <<'EOF'\nhello\nEOF".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -897,7 +892,7 @@ EOF"# .to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -931,7 +926,7 @@ EOF"# .to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -971,7 +966,7 @@ prefix_rule( "/some/important/folder".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -990,7 +985,7 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1018,7 +1013,7 @@ prefix_rule(pattern=["git"], decision="allow") policy_src: Some(policy_src), command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1052,7 +1047,7 @@ prefix_rule(pattern=["git"], decision="prompt") policy_src: Some(policy_src), command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1079,7 +1074,7 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { "cargo-insta".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), @@ -1102,7 +1097,7 @@ async fn exec_approval_requirement_respects_approval_policy() { policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1130,9 +1125,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { request_permissions: true, mcp_elicitations: true, }), - permission_profile: &permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: &PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1175,9 +1168,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &permission_profile_from_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - ), + permission_profile: &PermissionProfile::workspace_write(), file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1258,7 +1249,7 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und "touch requested-dir/requested-but-unused.txt".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, @@ -1281,7 +1272,7 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re policy_src: None, command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1311,7 +1302,7 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1337,7 +1328,7 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra request_permissions: true, mcp_elicitations: true, }), - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, @@ -1373,9 +1364,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() request_permissions: true, mcp_elicitations: true, }), - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1413,9 +1402,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( request_permissions: true, mcp_elicitations: true, }), - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1440,9 +1427,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -1468,9 +1453,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -1500,9 +1483,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::UnlessTrusted, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -1532,9 +1513,7 @@ async fn request_rule_uses_prefix_rule() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1683,7 +1662,7 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol policy_src: None, command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1703,7 +1682,7 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { policy_src: Some(r#"prefix_rule(pattern=["rm"], decision="prompt")"#.to_string()), command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1727,7 +1706,7 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { "cargo build && echo ok".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1757,7 +1736,7 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri policy_src: Some(policy_src.to_string()), command, approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1781,7 +1760,7 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { policy_src: None, command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1805,7 +1784,7 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() "print(1)".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1836,7 +1815,7 @@ prefix_rule(pattern=["cat"], decision="allow") policy_src: Some(policy_src.to_string()), command: command.clone(), approval_policy, - sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: PermissionProfile::workspace_write(), file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -1868,7 +1847,7 @@ prefix_rule(pattern=["bash"], decision="allow") .to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -2034,7 +2013,7 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { policy_src: None, command: command.clone(), approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -2098,9 +2077,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, @@ -2125,9 +2102,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, @@ -2148,9 +2123,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &dangerous_command, approval_policy: AskForApproval::Never, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: permissions, @@ -2170,8 +2143,8 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { policy_src: None, command, approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ExternalSandbox { - network_access: Default::default(), + permission_profile: PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }, file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -2195,8 +2168,8 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { policy_src: Some("prefix_rule(pattern=['rm'], decision='prompt')".to_string()), command, approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ExternalSandbox { - network_access: Default::default(), + permission_profile: PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }, file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -2214,7 +2187,7 @@ struct ExecApprovalRequirementScenario { policy_src: Option, command: Vec, approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + permission_profile: PermissionProfile, file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, @@ -2238,7 +2211,7 @@ async fn exec_approval_requirement_for_command( policy_src, command, approval_policy, - sandbox_policy, + permission_profile, file_system_sandbox_policy, sandbox_permissions, prefix_rule, @@ -2246,7 +2219,6 @@ async fn exec_approval_requirement_for_command( let policy = policy_from_src(policy_src.as_deref()); - let permission_profile = permission_profile_from_sandbox_policy(&sandbox_policy); ExecPolicyManager::new(policy) .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &command, diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index c1552f7e12..1d14d93813 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -14,7 +14,7 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { "echo blocked".to_string(), ], approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -38,7 +38,7 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { "echo blocked".to_string(), ], approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, @@ -80,9 +80,7 @@ fn unmatched_safe_powershell_words_are_allowed() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, - permission_profile: &permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: &PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: Path::new("/tmp"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -111,7 +109,7 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { "Remove-Item test -Force".to_string(), ], approval_policy: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: PermissionProfile::Disabled, file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 1e802121a1..268cbae971 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -347,8 +347,7 @@ async fn process_exec_tool_call_preserves_full_buffer_capture_policy() -> Result ]; let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?; - let sandbox_policy = SandboxPolicy::DangerFullAccess; - let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy); + let permission_profile = PermissionProfile::Disabled; let output = process_exec_tool_call( ExecParams { command, diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index b13db36d1e..af62816f72 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -379,6 +379,50 @@ async fn test_get_has_changes_ignores_repo_fsmonitor_config() { ); } +#[cfg(unix)] +#[tokio::test] +async fn test_get_has_changes_ignores_configured_hooks_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + let hooks_dir = repo_path.join(".git/hooks-path-test"); + let hook_path = hooks_dir.join("post-index-change"); + let marker_path = repo_path.join("hook-ran"); + + fs::create_dir_all(&hooks_dir).expect("create hook dir"); + fs::write( + &hook_path, + format!( + "#!/bin/sh\nprintf ran > \"{}\"\n", + marker_path.to_string_lossy() + ), + ) + .expect("write post-index-change hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("read hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("mark hook executable"); + + Command::new("git") + .args([ + "config", + "core.hooksPath", + hooks_dir.to_string_lossy().as_ref(), + ]) + .current_dir(&repo_path) + .output() + .await + .expect("configure hooks path"); + + fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file"); + + assert_eq!(get_has_changes(&repo_path).await, Some(false)); + assert!( + !marker_path.exists(), + "metadata collection should not invoke configured hook directories" + ); +} + #[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 d3fae0f2e5..afb5882a69 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -151,7 +151,6 @@ struct GuardianReviewSessionReuseKey { main_execve_wrapper_exe: Option, zsh_path: Option, features: ManagedFeatures, - include_apply_patch_tool: bool, use_experimental_unified_exec_tool: bool, } @@ -176,7 +175,6 @@ impl GuardianReviewSessionReuseKey { main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), zsh_path: spawn_config.zsh_path.clone(), features: spawn_config.features.clone(), - include_apply_patch_tool: spawn_config.include_apply_patch_tool, use_experimental_unified_exec_tool: spawn_config.use_experimental_unified_exec_tool, } } @@ -701,6 +699,9 @@ async fn run_review_on_session( .total_token_usage() .await .unwrap_or_default(); + // The legacy SandboxPolicy should match the PermissionProfile. + let guardian_permission_profile = PermissionProfile::read_only(); + let legacy_sandbox_policy = SandboxPolicy::new_read_only_policy(); let submit_result = run_before_review_deadline( deadline, @@ -712,8 +713,8 @@ async fn run_review_on_session( cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - permission_profile: None, + sandbox_policy: legacy_sandbox_policy, + permission_profile: Some(guardian_permission_profile), model: params.model.clone(), effort: params.reasoning_effort, summary: Some(params.reasoning_summary), @@ -895,15 +896,11 @@ pub(crate) fn build_guardian_review_session_config( ); guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - guardian_config.permissions.permission_profile = Constrained::allow_only( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), - ); guardian_config .permissions - .set_legacy_sandbox_policy(sandbox_policy, guardian_config.cwd.as_path()) + .set_permission_profile(PermissionProfile::read_only()) .map_err(|err| { - anyhow::anyhow!("guardian review session could not set sandbox policy: {err}") + anyhow::anyhow!("guardian review session could not set permission profile: {err}") })?; guardian_config.include_apps_instructions = false; guardian_config @@ -924,7 +921,7 @@ pub(crate) fn build_guardian_review_session_config( guardian_config.permissions.network = Some(NetworkProxySpec::from_config_and_constraints( live_network_config, network_constraints, - guardian_config.permissions.permission_profile.get(), + guardian_config.permissions.permission_profile(), )?); } for feature in [ diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 7601ef44c3..0544b27770 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -42,7 +42,6 @@ use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::GuardianUserAuthorization; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnCompleteEvent; use core_test_support::PathBufExt; use core_test_support::TempDirExt; @@ -2163,7 +2162,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { }), ..Default::default() }), - parent_config.permissions.permission_profile.get(), + parent_config.permissions.permission_profile(), ) .expect("network proxy spec"); parent_config.permissions.network = Some(network.clone()); @@ -2190,10 +2189,8 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { Constrained::allow_only(AskForApproval::Never) ); assert_eq!( - guardian_config.permissions.permission_profile, - Constrained::allow_only(PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - )) + guardian_config.permissions.permission_profile(), + &PermissionProfile::read_only() ); } @@ -2230,7 +2227,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { NetworkProxySpec::from_config_and_constraints( parent_network, /*requirements*/ None, - parent_config.permissions.permission_profile.get(), + parent_config.permissions.permission_profile(), ) .expect("parent network proxy spec"), ); @@ -2255,9 +2252,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { NetworkProxySpec::from_config_and_constraints( live_network, /*requirements*/ None, - &PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + &PermissionProfile::read_only(), ) .expect("live network proxy spec") ) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index fc8ce4d8ca..8f6e3afaf5 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -192,12 +192,6 @@ pub(crate) async fn handle_mcp_tool_call( .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())), }; } - let request_meta = build_mcp_tool_call_request_meta( - turn_context.as_ref(), - &server, - &call_id, - metadata.as_ref(), - ); let connector_id = metadata .as_ref() .and_then(|metadata| metadata.connector_id.clone()); @@ -235,7 +229,6 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, invocation, metadata.as_ref(), - request_meta, mcp_app_resource_uri, ) .await; @@ -303,7 +296,6 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, invocation, metadata.as_ref(), - request_meta, mcp_app_resource_uri, ) .await @@ -320,7 +312,6 @@ async fn handle_approved_mcp_tool_call( call_id: &str, invocation: McpInvocation, metadata: Option<&McpToolApprovalMetadata>, - request_meta: Option, mcp_app_resource_uri: Option, ) -> HandledMcpToolCall { let server = invocation.server.clone(); @@ -353,6 +344,8 @@ async fn handle_approved_mcp_tool_call( }; let result = async { let rewritten_arguments = rewrite?; + let request_meta = + build_mcp_tool_call_request_meta(turn_context, &server, call_id, metadata); let result = execute_mcp_tool_call( sess, turn_context, diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 699e06fe67..b84d6dd3eb 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -72,6 +72,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }; diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index d699172498..ce51d8c437 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -1,20 +1,16 @@ use super::*; use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::FileSystemAccessMode; use codex_protocol::protocol::FileSystemPath; use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSpecialPath; use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathExt; use pretty_assertions::assert_eq; use tempfile::TempDir; -fn permission_profile_for_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - #[test] fn test_writable_roots_constraint() { // Use a temporary directory as our workspace to avoid touching @@ -32,36 +28,34 @@ fn test_writable_roots_constraint() { // Policy limited to the workspace only; exclude system temp roots so // only `cwd` is writable by default. - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let workspace_only_file_system_policy = FileSystemSandboxPolicy::workspace_write( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); assert!(is_write_patch_constrained_to_writable_paths( &add_inside, - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &workspace_only_file_system_policy, &cwd, )); assert!(!is_write_patch_constrained_to_writable_paths( &add_outside, - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &workspace_only_file_system_policy, &cwd, )); // With the parent dir explicitly added as a writable root, the // outside write should be permitted. - let policy_with_parent = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![parent], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let file_system_policy_with_parent = FileSystemSandboxPolicy::workspace_write( + std::slice::from_ref(&parent), + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); assert!(is_write_patch_constrained_to_writable_paths( &add_outside, - &FileSystemSandboxPolicy::from(&policy_with_parent), + &file_system_policy_with_parent, &cwd, )); } @@ -73,16 +67,17 @@ fn external_sandbox_auto_approves_in_on_request() { let add_inside_path = cwd.join("inner.txt"); let add_inside = ApplyPatchAction::new_add_for_test(&add_inside_path, "".to_string()); - let policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Enabled, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, }; + let file_system_sandbox_policy = FileSystemSandboxPolicy::external_sandbox(); assert_eq!( assess_patch_safety( &add_inside, AskForApproval::OnRequest, - &permission_profile_for_policy(&policy), - &FileSystemSandboxPolicy::from(&policy), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled ), @@ -100,19 +95,20 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert_eq!( assess_patch_safety( &add_outside, AskForApproval::OnRequest, - &permission_profile_for_policy(&policy_workspace_only), - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, ), @@ -128,8 +124,8 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { request_permissions: true, mcp_elicitations: true, }), - &permission_profile_for_policy(&policy_workspace_only), - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, ), @@ -144,12 +140,13 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { let parent = cwd.parent().unwrap(); let outside_path = parent.join("outside.txt"); let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); - let policy_workspace_only = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert_eq!( assess_patch_safety( @@ -161,8 +158,8 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { request_permissions: true, mcp_elicitations: true, }), - &permission_profile_for_policy(&policy_workspace_only), - &FileSystemSandboxPolicy::from(&policy_workspace_only), + &permission_profile, + &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, ), @@ -178,9 +175,8 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { let cwd = tmp.path().abs(); let inside_path = cwd.join("inside.txt"); let action = ApplyPatchAction::new_add_for_test(&inside_path, "".to_string()); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd); + let permission_profile = PermissionProfile::read_only(); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); assert!(!is_write_patch_constrained_to_writable_paths( &action, @@ -191,7 +187,7 @@ fn read_only_policy_rejects_patch_with_read_only_reason() { assess_patch_safety( &action, AskForApproval::Never, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, @@ -208,8 +204,8 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { let blocked_path = cwd.join("blocked.txt"); let blocked_absolute = blocked_path; let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -235,7 +231,7 @@ fn explicit_unreadable_paths_prevent_auto_approval_for_external_sandbox() { assess_patch_safety( &action, AskForApproval::OnRequest, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, @@ -252,8 +248,8 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { let blocked_absolute = blocked_path; let docs_absolute = AbsolutePathBuf::resolve_path_against_base("docs", &cwd); let action = ApplyPatchAction::new_add_for_test(&blocked_absolute, "".to_string()); - let sandbox_policy = SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Restricted, + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { @@ -279,7 +275,7 @@ fn explicit_read_only_subpaths_prevent_auto_approval_for_external_sandbox() { assess_patch_safety( &action, AskForApproval::OnRequest, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, @@ -294,14 +290,21 @@ fn missing_project_dot_codex_config_requires_approval() { let cwd = tmp.path().abs(); let config_path = cwd.join(".codex").join("config.toml"); let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string()); - let sandbox_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &cwd); + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let mut file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); + file_system_sandbox_policy + .entries + .push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: cwd.join(".codex"), + }, + access: FileSystemAccessMode::Read, + }); assert!(!is_write_patch_constrained_to_writable_paths( &action, @@ -312,7 +315,7 @@ fn missing_project_dot_codex_config_requires_approval() { assess_patch_safety( &action, AskForApproval::OnRequest, - &permission_profile_for_policy(&sandbox_policy), + &permission_profile, &file_system_sandbox_policy, &cwd, WindowsSandboxLevel::Disabled, diff --git a/codex-rs/core/src/sandbox_tags.rs b/codex-rs/core/src/sandbox_tags.rs index f6db4da918..2973a5bf94 100644 --- a/codex-rs/core/src/sandbox_tags.rs +++ b/codex-rs/core/src/sandbox_tags.rs @@ -1,24 +1,10 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; -#[cfg(test)] -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_sandboxing::get_platform_sandbox; use codex_sandboxing::policy_transforms::should_require_platform_sandbox; use std::path::Path; -#[cfg(test)] -pub(crate) fn sandbox_tag( - policy: &SandboxPolicy, - windows_sandbox_level: WindowsSandboxLevel, -) -> &'static str { - permission_profile_sandbox_tag( - &PermissionProfile::from_legacy_sandbox_policy(policy), - windows_sandbox_level, - /*enforce_managed_network*/ false, - ) -} - pub(crate) fn permission_profile_sandbox_tag( profile: &PermissionProfile, windows_sandbox_level: WindowsSandboxLevel, diff --git a/codex-rs/core/src/sandbox_tags_tests.rs b/codex-rs/core/src/sandbox_tags_tests.rs index 8b00de9ccd..64dc50574f 100644 --- a/codex-rs/core/src/sandbox_tags_tests.rs +++ b/codex-rs/core/src/sandbox_tags_tests.rs @@ -1,6 +1,5 @@ use super::permission_profile_policy_tag; use super::permission_profile_sandbox_tag; -use super::sandbox_tag; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -10,8 +9,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_sandboxing::get_platform_sandbox; use codex_utils_absolute_path::AbsolutePathBuf; @@ -20,29 +17,32 @@ use std::path::Path; #[test] fn danger_full_access_is_untagged_even_when_linux_sandbox_defaults_apply() { - let actual = sandbox_tag( - &SandboxPolicy::DangerFullAccess, + let actual = permission_profile_sandbox_tag( + &PermissionProfile::Disabled, WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, ); assert_eq!(actual, "none"); } #[test] fn external_sandbox_keeps_external_tag_when_linux_sandbox_defaults_apply() { - let actual = sandbox_tag( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, + let actual = permission_profile_sandbox_tag( + &PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, }, WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, ); assert_eq!(actual, "external"); } #[test] fn default_linux_sandbox_uses_platform_sandbox_tag() { - let actual = sandbox_tag( - &SandboxPolicy::new_read_only_policy(), + let actual = permission_profile_sandbox_tag( + &PermissionProfile::read_only(), WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, ); let expected = get_platform_sandbox(/*windows_sandbox_enabled*/ false) .map(SandboxType::as_metric_tag) diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 1e632ba0cf..85815f8533 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -186,7 +186,6 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { lock_config.profiles.clear(); clear_config_lock_debug_controls(lock_config); lock_config.model_instructions_file = None; - lock_config.experimental_instructions_file = None; lock_config.experimental_compact_prompt_file = None; lock_config.model_catalog_json = None; lock_config.sandbox_mode = None; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 7dd900e0f6..aee2205a37 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -117,6 +117,8 @@ async fn turn_context_settings_update( ) -> SessionSettingsUpdate { let TurnContextOverrides { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -142,6 +144,8 @@ async fn turn_context_settings_update( }; SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -219,6 +223,8 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), + workspace_roots: None, + profile_workspace_roots: None, permission_profile, active_permission_profile: None, windows_sandbox_level: None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index fcaaa17c57..9f27751f7e 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -154,6 +154,9 @@ impl Session { id, request, }); + turn_context + .turn_metadata_state + .mark_user_input_requested_during_turn(); self.send_event(turn_context, event).await; rx_response.await.ok() } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3b6c38249f..295230af2f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -173,6 +173,8 @@ use crate::compact::collect_user_messages; use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; +use crate::config::PermissionProfileSnapshot; +use crate::config::PermissionProfileState; use crate::config::StartedNetworkProxy; use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; @@ -617,10 +619,10 @@ impl Codex { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: session_permission_profile_state_from_config(&config)?, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: environment_selections.to_selections(), @@ -818,6 +820,12 @@ fn get_service_tier( .then_some(ServiceTier::Fast.request_value().to_string()) } +fn session_permission_profile_state_from_config( + config: &Config, +) -> CodexResult { + Ok(config.permissions.permission_profile_state().clone()) +} + fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { plan_type == AccountPlanType::Enterprise || plan_type.is_business_like() @@ -2278,6 +2286,9 @@ impl Session { turn_id: turn_context.sub_id.clone(), questions: args.questions, }); + turn_context + .turn_metadata_state + .mark_user_input_requested_during_turn(); self.send_event(turn_context, event).await; rx_response.await.ok() } @@ -2683,14 +2694,6 @@ impl Session { { developer_sections.push(developer_instructions.to_string()); } - // Add developer instructions for memories. - if turn_context.features.enabled(Feature::MemoryTool) - && turn_context.config.memories.use_memories - && let Some(memory_prompt) = - build_memory_tool_developer_instructions(&turn_context.config.codex_home).await - { - developer_sections.push(memory_prompt); - } // Add developer instructions from collaboration_mode if they exist and are non-empty if turn_context.config.include_collaboration_mode_instructions && let Some(collab_instructions) = @@ -3431,8 +3434,6 @@ pub(crate) fn emit_subagent_session_started( }); } -use codex_memories_read::build_memory_tool_developer_instructions; - /// Builds the hook engine for one config snapshot, including any enabled plugin hooks. async fn build_hooks_for_config( config: &Config, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index acb4adb945..497cddd912 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -46,7 +46,7 @@ pub(super) async fn spawn_review_thread( .with_image_generation_capability(provider_capabilities.image_generation) .with_web_search_capability(provider_capabilities.web_search) .with_unified_exec_shell_mode_for_session( - crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()), + crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()), sess.services.shell_zsh_path.as_ref(), sess.services.main_execve_wrapper_exe.as_ref(), ) diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 143b23d3a3..aa403316e0 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -143,6 +143,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -209,6 +210,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -237,6 +239,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(rolled_back_context_item), @@ -307,6 +310,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -335,6 +339,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::ResponseItem(turn_two_user), @@ -397,6 +402,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -425,6 +431,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::ResponseItem(turn_two_user), @@ -515,6 +522,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -603,6 +611,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(only_context_item), @@ -655,6 +664,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -727,6 +737,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item.clone()), @@ -753,6 +764,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::Compacted(CompactedItem { @@ -884,6 +896,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(current_context_item), @@ -952,6 +965,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), // Compaction clears baseline until a later TurnContextItem re-establishes it. @@ -1065,6 +1079,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1091,6 +1106,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::EventMsg(EventMsg::TurnAborted( @@ -1176,6 +1192,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1202,6 +1219,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::EventMsg(EventMsg::TurnAborted( @@ -1296,6 +1314,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1322,6 +1341,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::Compacted(CompactedItem { @@ -1372,6 +1392,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(current_context_item.clone()), @@ -1450,6 +1471,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item), @@ -1476,6 +1498,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::Compacted(CompactedItem { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 4e0ca4ed31..ab7020d5aa 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -63,11 +63,10 @@ pub(crate) struct SessionConfiguration { /// When to escalate for approval for execution pub(super) approval_policy: Constrained, pub(super) approvals_reviewer: ApprovalsReviewer, - /// Canonical permission profile for the session. - pub(super) permission_profile: Constrained, - /// Named or implicit built-in permissions profile selected from config, if - /// any. - pub(super) active_permission_profile: Option, + /// Permission profile state for the session. Keep the constrained profile, + /// active profile id, and profile-defined workspace roots in sync by using + /// the methods below instead of mutating the fields independently. + pub(super) permission_profile_state: PermissionProfileState, pub(super) windows_sandbox_level: WindowsSandboxLevel, /// Absolute working directory that should be treated as the *root* of the @@ -75,6 +74,9 @@ pub(crate) struct SessionConfiguration { /// execution sandbox are resolved against this directory **instead** of /// the process-wide current working directory. pub(super) cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots for materializing symbolic + /// workspace permissions at session runtime. + pub(super) workspace_roots: Vec, /// Directory containing all Codex state for this session. pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -103,12 +105,39 @@ impl SessionConfiguration { &self.codex_home } + pub(super) fn permission_profile_state(&self) -> &PermissionProfileState { + &self.permission_profile_state + } + pub(super) fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + self.permission_profile_state + .permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) } pub(super) fn active_permission_profile(&self) -> Option { - self.active_permission_profile.clone() + self.permission_profile_state.active_permission_profile() + } + + pub(super) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + + pub(super) fn apply_permission_profile_to_permissions( + &self, + permissions: &mut crate::config::Permissions, + ) { + permissions.set_permission_profile_state(self.permission_profile_state.clone()); + } + + #[cfg(test)] + pub(super) fn set_permission_profile_for_tests( + &mut self, + permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.permission_profile_state + .set_legacy_permission_profile(permission_profile) } pub(super) fn sandbox_policy(&self) -> SandboxPolicy { @@ -117,7 +146,7 @@ impl SessionConfiguration { .unwrap_or_else(|_| { let file_system_sandbox_policy = self.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - self.permission_profile.get(), + self.permission_profile_state.permission_profile(), &file_system_sandbox_policy, self.network_sandbox_policy(), &self.cwd, @@ -126,11 +155,13 @@ impl SessionConfiguration { } pub(super) fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.permission_profile().file_system_sandbox_policy() } pub(super) fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.permission_profile.get().network_sandbox_policy() + self.permission_profile_state + .permission_profile() + .network_sandbox_policy() } pub(super) fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { @@ -143,6 +174,8 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), + profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), reasoning_summary: self.model_reasoning_summary, @@ -224,21 +257,39 @@ impl SessionConfiguration { let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; + if let Some(workspace_roots) = updates.workspace_roots.clone() { + next_configuration.workspace_roots = workspace_roots; + } else if cwd_changed && self.workspace_roots.contains(&self.cwd) { + let mut retargeted_workspace_roots = + Vec::with_capacity(next_configuration.workspace_roots.len()); + for root in &self.workspace_roots { + let root = if root == &self.cwd { + next_configuration.cwd.clone() + } else { + root.clone() + }; + if !retargeted_workspace_roots.contains(&root) { + retargeted_workspace_roots.push(root); + } + } + next_configuration.workspace_roots = retargeted_workspace_roots; + } if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = updates.active_permission_profile.clone().or_else(|| { if permission_profile == self.permission_profile() { - self.active_permission_profile.clone() + self.active_permission_profile() } else { None } }); next_configuration.set_permission_profile_projection( permission_profile, + active_permission_profile, + updates.profile_workspace_roots.clone().unwrap_or_default(), Some(¤t_file_system_sandbox_policy), )?; - next_configuration.active_permission_profile = active_permission_profile; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -247,14 +298,15 @@ impl SessionConfiguration { ¤t_file_system_sandbox_policy, ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); - next_configuration.permission_profile.set( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ), - )?; - next_configuration.active_permission_profile = None; + next_configuration + .permission_profile_state + .set_legacy_permission_profile( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ), + )?; } else if cwd_changed && file_system_policy_matches_legacy && file_system_policy_has_rebindable_project_root_write @@ -268,13 +320,15 @@ impl SessionConfiguration { &next_configuration.cwd, ¤t_file_system_sandbox_policy, ); - next_configuration.permission_profile.set( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(¤t_sandbox_policy), - &file_system_sandbox_policy, - current_network_sandbox_policy, - ), - )?; + next_configuration + .permission_profile_state + .set_legacy_permission_profile( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(¤t_sandbox_policy), + &file_system_sandbox_policy, + current_network_sandbox_policy, + ), + )?; } if let Some(app_server_client_name) = updates.app_server_client_name.clone() { next_configuration.app_server_client_name = Some(app_server_client_name); @@ -288,6 +342,8 @@ impl SessionConfiguration { fn set_permission_profile_projection( &mut self, permission_profile: PermissionProfile, + active_permission_profile: Option, + profile_workspace_roots: Vec, preserve_deny_reads_from: Option<&FileSystemSandboxPolicy>, ) -> ConstraintResult<()> { let enforcement = permission_profile.enforcement(); @@ -303,14 +359,28 @@ impl SessionConfiguration { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile.set(effective_permission_profile)?; - Ok(()) + + let permission_snapshot = match active_permission_profile { + Some(active_permission_profile) => { + PermissionProfileSnapshot::active_with_profile_workspace_roots( + effective_permission_profile, + active_permission_profile, + profile_workspace_roots, + ) + } + None => PermissionProfileSnapshot::legacy(effective_permission_profile), + }; + + self.permission_profile_state + .set_permission_profile_snapshot(permission_snapshot) } } #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, + pub(crate) workspace_roots: Option>, + pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, @@ -566,19 +636,6 @@ impl Session { }), }); } - if crate::config::uses_deprecated_instructions_file(&config.config_layer_stack) { - post_session_configured_events.push(Event { - id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { - summary: "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." - .to_string(), - details: Some( - "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." - .to_string(), - ), - }), - }); - } for message in &config.startup_warnings { post_session_configured_events.push(Event { id: "".to_owned(), @@ -771,7 +828,7 @@ impl Session { let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( spec, current_exec_policy.as_ref(), - config.permissions.permission_profile.get(), + config.permissions.permission_profile(), network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, @@ -833,10 +890,12 @@ impl Session { // before any MCP-related events. It is reasonable to consider // changing this to use Option or OnceCell, though the current // setup is straightforward enough and performs well. - mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( - &config.permissions.approval_policy, - &config.permissions.permission_profile, - ))), + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_uninitialized_with_permission_profile( + &config.permissions.approval_policy, + config.permissions.permission_profile(), + ), + )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -941,7 +1000,9 @@ impl Session { initial_messages, network_proxy: session_network_proxy.filter(|_| { Self::managed_network_proxy_active_for_permission_profile( - session_configuration.permission_profile.get(), + session_configuration + .permission_profile_state() + .permission_profile(), ) }), rollout_path, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c48024486a..3c072eb43f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -36,6 +36,8 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; @@ -171,10 +173,6 @@ use std::time::Duration as StdDuration; mod guardian_tests; -fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - struct InstructionsTestCase { slug: &'static str, expects_apply_patch_description: bool, @@ -669,10 +667,11 @@ fn validated_network_policy_amendment_host_rejects_mismatch() { #[tokio::test] async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> { + let permission_profile = PermissionProfile::workspace_write(); let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), /*requirements*/ None, - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, )?; let mut exec_policy = Policy::empty(); exec_policy.add_network_rule( @@ -685,7 +684,7 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, /*network_policy_decider*/ None, /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ false, @@ -704,6 +703,7 @@ async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyho #[tokio::test] async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() -> anyhow::Result<()> { + let permission_profile = PermissionProfile::workspace_write(); let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { @@ -716,7 +716,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() managed_allowed_domains_only: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, )?; let mut exec_policy = Policy::empty(); exec_policy.add_network_rule( @@ -729,7 +729,7 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &permission_profile, /*network_policy_decider*/ None, /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ false, @@ -747,13 +747,14 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules() #[tokio::test] async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::Result<()> { + let full_access_permission_profile = PermissionProfile::Disabled; let spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &full_access_permission_profile, )?; let exec_policy = Policy::empty(); let decider_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -768,7 +769,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &exec_policy, - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &full_access_permission_profile, Some(network_policy_decider), /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ true, @@ -776,9 +777,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R ) .await?; - let spec = spec.recompute_for_permission_profile(&permission_profile_for_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - ))?; + let spec = spec.recompute_for_permission_profile(&PermissionProfile::workspace_write())?; spec.apply_to_started_proxy(&started_proxy).await?; let current_cfg = started_proxy.proxy().current_cfg().await?; assert_eq!(current_cfg.network.allowed_domains(), None); @@ -817,7 +816,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R #[tokio::test] async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> { let (mut session, _turn_context) = make_session_and_context().await; - let initial_policy = SandboxPolicy::new_workspace_write_policy(); + let initial_permission_profile = PermissionProfile::workspace_write(); let mut network_config = NetworkProxyConfig::default(); network_config @@ -835,12 +834,12 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow let spec = crate::config::NetworkProxySpec::from_config_and_constraints( network_config, Some(requirements), - &permission_profile_for_sandbox_policy(&initial_policy), + &initial_permission_profile, )?; let (started_proxy, _) = Session::start_managed_network_proxy( &spec, &Policy::empty(), - &permission_profile_for_sandbox_policy(&initial_policy), + &initial_permission_profile, /*network_policy_decider*/ None, /*blocked_request_observer*/ None, /*managed_network_requirements_enabled*/ false, @@ -861,18 +860,14 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow let mut state = session.state.lock().await; let mut config = (*state.session_configuration.original_config_do_not_use).clone(); config.permissions.network = Some(spec); - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(initial_policy.clone(), cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(initial_permission_profile.clone()) + .expect("test setup should allow permission profile"); state.session_configuration.original_config_do_not_use = Arc::new(config); state .session_configuration - .permission_profile - .set(PermissionProfile::from_legacy_sandbox_policy( - &initial_policy, - )) + .set_permission_profile_for_tests(initial_permission_profile) .expect("test setup should allow permission profile"); } session.services.network_proxy = Some(started_proxy); @@ -913,15 +908,14 @@ async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyho enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, )?; let session = make_session_with_config(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(PermissionProfile::Disabled) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); }) .await?; @@ -979,15 +973,14 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, )?; let session = make_session_with_config(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(PermissionProfile::Disabled) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); let layers = config @@ -1047,22 +1040,21 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an #[tokio::test] async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> anyhow::Result<()> { - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let permission_profile = PermissionProfile::workspace_write(); let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&sandbox_policy), + &permission_profile, )?; let session = make_session_with_config(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(sandbox_policy, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(permission_profile) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); }) .await?; @@ -1074,22 +1066,21 @@ async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> any #[tokio::test] async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::Result<()> { - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let permission_profile = PermissionProfile::workspace_write(); let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints( NetworkProxyConfig::default(), Some(NetworkConstraints { enabled: Some(true), ..Default::default() }), - &permission_profile_for_sandbox_policy(&sandbox_policy), + &permission_profile, )?; let (session, rx) = make_session_with_config_and_rx(move |config| { - let cwd = config.cwd.clone(); config .permissions - .set_legacy_sandbox_policy(sandbox_policy, cwd.as_path()) - .expect("test setup should allow sandbox policy"); + .set_permission_profile(permission_profile) + .expect("test setup should allow permission profile"); config.permissions.network = Some(network_spec); }) .await?; @@ -2127,11 +2118,15 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> let sandbox_policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let expected_sandbox_policy = sandbox_policy.clone(); + let permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }; + let expected_permission_profile = permission_profile.clone(); let mut builder = test_codex().with_config(move |config| { - config.permissions.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy), - ); + config + .permissions + .set_permission_profile(permission_profile.clone()) + .expect("set permission profile"); config .set_legacy_sandbox_policy(sandbox_policy) .expect("set sandbox policy"); @@ -2139,10 +2134,6 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> let test = builder.build(&server).await?; - let expected_permission_profile = - codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy( - &expected_sandbox_policy, - ); assert_eq!( test.session_configured.permission_profile, expected_permission_profile, "ExternalSandbox is represented explicitly instead of as a lossy root-write profile" @@ -2150,6 +2141,51 @@ async fn session_configured_reports_permission_profile_for_external_sandbox() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_permission_profile_rebinds_runtime_workspace_roots() -> anyhow::Result<()> { + let codex_home = tempfile::TempDir::new()?; + let cwd = tempfile::TempDir::new()?; + let old_root = test_path_buf("/workspace/old").abs(); + let new_root = test_path_buf("/workspace/new").abs(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(crate::config::ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + additional_writable_roots: vec![old_root.to_path_buf()], + ..Default::default() + }) + .build() + .await?; + + let session_permission_profile_state = session_permission_profile_state_from_config(&config)?; + let stored_file_system_policy = session_permission_profile_state + .permission_profile() + .file_system_sandbox_policy(); + assert!( + !stored_file_system_policy + .can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path()), + "session permission profile state should keep runtime workspace roots symbolic" + ); + + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.cwd = config.cwd.clone(); + session_configuration.workspace_roots = config.workspace_roots.clone(); + session_configuration.permission_profile_state = session_permission_profile_state; + + let initial_policy = session_configuration.file_system_sandbox_policy(); + assert!(initial_policy.can_write_path_with_cwd(old_root.as_path(), config.cwd.as_path())); + + let updated = session_configuration.apply(&SessionSettingsUpdate { + workspace_roots: Some(vec![new_root.clone()]), + ..Default::default() + })?; + let updated_policy = updated.file_system_sandbox_policy(); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<()> { let server = start_mock_server().await; @@ -2308,6 +2344,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(previous_context_item.clone()), @@ -2502,6 +2539,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(first_context_item.clone()), @@ -2528,6 +2566,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, )), RolloutItem::TurnContext(rolled_back_context_item), @@ -2610,6 +2649,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(first_context_item.clone()), RolloutItem::ResponseItem(user_message("turn 1 user")), @@ -2653,6 +2693,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(TurnContextItem { turn_id: Some(rolled_back_turn_id.clone()), @@ -2707,6 +2748,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(turn_context_item.clone()), RolloutItem::ResponseItem(user_message("turn 1 user")), @@ -2731,6 +2773,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(turn_context_item.clone()), RolloutItem::ResponseItem(user_message("turn 2 user")), @@ -2755,6 +2798,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::TurnContext(turn_context_item), RolloutItem::ResponseItem(user_message("turn 3 user")), @@ -2872,10 +2916,10 @@ async fn set_rate_limits_retains_previous_credits() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -2976,10 +3020,10 @@ async fn set_rate_limits_updates_plan_type_when_present() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3449,10 +3493,10 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3513,13 +3557,17 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd }, ]); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ), + ) + .expect("set permission profile"); + let expected_file_system_sandbox_policy = file_system_sandbox_policy + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -3530,7 +3578,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd assert_eq!( updated.file_system_sandbox_policy(), - file_system_sandbox_policy + expected_file_system_sandbox_policy ); } @@ -3554,13 +3602,15 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ ); existing_file_system_policy.glob_scan_max_depth = Some(2); existing_file_system_policy.entries.push(deny_entry.clone()); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&workspace_policy), - &existing_file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&workspace_policy), + &existing_file_system_policy, + NetworkSandboxPolicy::Restricted, + ), + ) + .expect("set permission profile"); let requested_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &workspace_policy, @@ -3577,7 +3627,8 @@ async fn session_configuration_apply_permission_profile_preserves_existing_deny_ }) .expect("permission profile update should succeed"); - let mut expected_file_system_policy = requested_file_system_policy; + let mut expected_file_system_policy = requested_file_system_policy + .materialize_project_roots_with_workspace_roots(&session_configuration.workspace_roots); expected_file_system_policy.glob_scan_max_depth = Some(2); expected_file_system_policy.entries.push(deny_entry); assert_eq!( @@ -3632,6 +3683,93 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo ); } +#[tokio::test] +async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspace_roots() { + let mut session_configuration = make_session_configuration_for_tests().await; + let old_root = tempfile::tempdir().expect("create old root"); + let new_root = tempfile::tempdir().expect("create new root"); + let profile_root = tempfile::tempdir().expect("create profile root"); + let old_root = old_root.path().abs(); + let new_root = new_root.path().abs(); + let profile_root = profile_root.path().abs(); + session_configuration.workspace_roots = vec![old_root.clone()]; + + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + workspace_roots: Some(vec![new_root.clone()]), + permission_profile: Some(permission_profile), + active_permission_profile: Some(ActivePermissionProfile::new("dev")), + profile_workspace_roots: Some(vec![profile_root.clone()]), + ..Default::default() + }) + .expect("permission profile update should succeed"); + + let updated_policy = updated.file_system_sandbox_policy(); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert_eq!( + updated.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) + ); + assert_eq!(updated.profile_workspace_roots(), &[profile_root]); +} + +#[tokio::test] +async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_update() { + let mut session_configuration = make_session_configuration_for_tests().await; + let old_root = tempfile::tempdir().expect("create old root"); + let new_root = tempfile::tempdir().expect("create new root"); + let extra_root = tempfile::tempdir().expect("create extra root"); + let old_root = old_root.path().abs(); + let new_root = new_root.path().abs(); + let extra_root = extra_root.path().abs(); + session_configuration.cwd = old_root.clone(); + session_configuration.workspace_roots = vec![old_root.clone(), extra_root.clone()]; + + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + session_configuration + .set_permission_profile_for_tests(permission_profile) + .expect("set permission profile"); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + cwd: Some(new_root.to_path_buf()), + ..Default::default() + }) + .expect("cwd-only update should succeed"); + + assert_eq!( + updated.workspace_roots, + vec![new_root.clone(), extra_root.clone()] + ); + let updated_policy = updated.file_system_sandbox_policy(); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); + assert!(updated_policy.can_write_path_with_cwd(extra_root.as_path(), updated.cwd.as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { @@ -3719,12 +3857,13 @@ enabled = false } #[tokio::test] -async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_update() { +async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_update() { let mut session_configuration = make_session_configuration_for_tests().await; let workspace = tempfile::tempdir().expect("create temp dir"); - let project_root = workspace.path().join("project"); - let original_cwd = project_root.join("subdir"); - session_configuration.cwd = original_cwd.abs(); + let original_cwd = workspace.path().join("repo-a").abs(); + let project_root = workspace.path().join("repo-b").abs(); + session_configuration.cwd = original_cwd.clone(); + session_configuration.workspace_roots = vec![session_configuration.cwd.clone()]; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), network_access: false, @@ -3735,30 +3874,35 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ &sandbox_policy, &session_configuration.cwd, ); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), - &file_system_sandbox_policy, - NetworkSandboxPolicy::from(&sandbox_policy), - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), + &file_system_sandbox_policy, + NetworkSandboxPolicy::from(&sandbox_policy), + ), + ) + .expect("set permission profile"); let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.clone()), + cwd: Some(project_root.to_path_buf()), ..Default::default() }) .expect("cwd-only update should succeed"); - let expected_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( - &updated.sandbox_policy(), - &project_root, - ); + assert_eq!(updated.workspace_roots, vec![project_root.clone()]); assert!( updated .file_system_sandbox_policy() - .is_semantically_equivalent_to(&expected_file_system_policy, &project_root), - "cwd-only update should rederive the legacy filesystem policy for the new cwd" + .can_write_path_with_cwd(project_root.as_path(), updated.cwd.as_path()), + "cwd-only update should keep the new cwd writable" + ); + assert!( + !updated + .file_system_sandbox_policy() + .can_write_path_with_cwd(original_cwd.as_path(), updated.cwd.as_path()), + "cwd-only update should not keep the old implicit cwd writable" ); } @@ -3787,13 +3931,15 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up access: FileSystemAccessMode::Write, }, ]); - session_configuration.permission_profile = codex_config::Constrained::allow_any( - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::Managed, - &file_system_sandbox_policy, - NetworkSandboxPolicy::Restricted, - ), - ); + session_configuration + .set_permission_profile_for_tests( + PermissionProfile::from_runtime_permissions_with_enforcement( + SandboxEnforcement::Managed, + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ), + ) + .expect("set permission profile"); let updated = session_configuration .apply(&SessionSettingsUpdate { @@ -3982,10 +4128,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -4091,10 +4237,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4136,10 +4282,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( - &config.permissions.approval_policy, - &config.permissions.permission_profile, - ))), + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_uninitialized_with_permission_profile( + &config.permissions.approval_policy, + config.permissions.permission_profile(), + ), + )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -4323,10 +4471,10 @@ async fn make_session_with_config_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4426,10 +4574,10 @@ async fn make_session_with_history_source_and_agent_control_and_rx( compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -5913,10 +6061,10 @@ where compact_prompt: config.compact_prompt.clone(), approval_policy: config.permissions.approval_policy.clone(), approvals_reviewer: config.approvals_reviewer, - permission_profile: config.permissions.permission_profile.clone(), - active_permission_profile: config.permissions.active_permission_profile(), + permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -5958,10 +6106,12 @@ where ); let services = SessionServices { - mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( - &config.permissions.approval_policy, - &config.permissions.permission_profile, - ))), + mcp_connection_manager: Arc::new(RwLock::new( + McpConnectionManager::new_uninitialized_with_permission_profile( + &config.permissions.approval_policy, + config.permissions.permission_profile(), + ), + )), mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, @@ -7327,6 +7477,7 @@ async fn record_context_updates_and_set_reference_context_item_persists_full_rei images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]) .await; @@ -7752,6 +7903,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() images, text_elements, local_images, + .. }) if message == "late pending input" && images == Some(Vec::new()) && text_elements.is_empty() @@ -8689,7 +8841,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a assert_eq!(complete_output["remainingTokens"], 0); assert_eq!( complete_output["completionBudgetReport"], - "Goal achieved. Report final budget usage to the user: tokens used: 580 of 500." + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." ); let requests = responses.requests(); let completion_followup_request = requests diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 718b9bf05f..5db4aab985 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -5,8 +5,9 @@ use crate::exec_policy::ExecPolicyManager; use crate::guardian::GUARDIAN_REVIEWER_NAME; use crate::sandboxing::SandboxPermissions; use crate::test_support::models_manager_with_provider; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::ConfigLayerSource; use codex_config::ConfigLayerEntry; @@ -22,8 +23,8 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::NetworkPermissions; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::AskForApproval; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -48,8 +49,23 @@ use tempfile::tempdir; use tokio::time::timeout; use tokio_util::sync::CancellationToken; -fn expect_text_output(output: &FunctionToolOutput) -> String { - function_call_output_content_items_to_text(&output.body).unwrap_or_default() +fn expect_text_output(output: &T) -> String +where + T: ToolOutput + ?Sized, +{ + let response = output.to_response_item( + "call-guardian", + &ToolPayload::Function { + arguments: "{}".to_string(), + }, + ); + match response { + ResponseInputItem::FunctionCallOutput { output, .. } + | ResponseInputItem::CustomToolCallOutput { output, .. } => { + output.body.to_text().unwrap_or_default() + } + other => panic!("expected function output, got {other:?}"), + } } #[tokio::test] diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 5d9b3b0c08..7ae572e6e2 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2003,6 +2003,7 @@ async fn try_run_sampling_request( | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => false, }; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index c646b5833d..92c7893d5e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -432,14 +432,18 @@ impl Session { let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); per_turn_config.cwd = cwd; + per_turn_config.workspace_roots = session_configuration.workspace_roots.clone(); + per_turn_config + .permissions + .set_workspace_roots(session_configuration.workspace_roots.clone()); per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; - per_turn_config.permissions.permission_profile = - session_configuration.permission_profile.clone(); + session_configuration + .apply_permission_profile_to_permissions(&mut per_turn_config.permissions); let permission_profile = session_configuration.permission_profile(); let resolved_web_search_mode = resolve_web_search_mode_for_turn(&per_turn_config.web_search_mode, &permission_profile); @@ -466,8 +470,10 @@ impl Session { Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); - config.permissions.active_permission_profile = - session_configuration.active_permission_profile.clone(); + config.workspace_roots = session_configuration.workspace_roots.clone(); + config + .permissions + .set_workspace_roots(session_configuration.workspace_roots.clone()); config } @@ -521,7 +527,7 @@ impl Session { .with_image_generation_capability(provider_capabilities.image_generation) .with_web_search_capability(provider_capabilities.web_search) .with_unified_exec_shell_mode_for_session( - crate::tools::spec::tool_user_shell_type(user_shell), + crate::tools::tool_user_shell_type(user_shell), shell_zsh_path, main_execve_wrapper_exe, ) diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 9fedff9470..956486f182 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1127,6 +1127,7 @@ fn completed_legacy_event_history_is_not_mid_turn() { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "done".to_string(), @@ -1154,6 +1155,7 @@ fn mixed_response_and_legacy_user_event_history_is_mid_turn() { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), ]); 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 26ef021ad2..e101e1a800 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -2,8 +2,9 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -89,8 +90,6 @@ impl CodeModeExecuteHandler { #[async_trait::async_trait] impl ToolExecutor for CodeModeExecuteHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(PUBLIC_TOOL_NAME) } @@ -99,7 +98,10 @@ impl ToolExecutor for CodeModeExecuteHandler { Some(self.spec.clone()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -110,9 +112,10 @@ impl ToolExecutor for CodeModeExecuteHandler { } = invocation; match payload { - ToolPayload::Custom { input } if is_exec_tool_name(&tool_name) => { - self.execute(session, turn, call_id, input).await - } + ToolPayload::Custom { input } if is_exec_tool_name(&tool_name) => self + .execute(session, turn, call_id, input) + .await + .map(boxed_tool_output), _ => Err(FunctionCallError::RespondToModel(format!( "{PUBLIC_TOOL_NAME} expects raw JavaScript source text" ))), @@ -120,7 +123,7 @@ impl ToolExecutor for CodeModeExecuteHandler { } } -impl ToolHandler for CodeModeExecuteHandler { +impl CoreToolRuntime for CodeModeExecuteHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Custom { .. }) } diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs index e20cf6a071..133d6e2fc1 100644 --- a/codex-rs/core/src/tools/code_mode/response_adapter.rs +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -17,8 +17,6 @@ impl IntoProtocol for CodeModeImageDetail { fn into_protocol(self) -> ImageDetail { let value = self; match value { - CodeModeImageDetail::Auto => ImageDetail::Auto, - CodeModeImageDetail::Low => ImageDetail::Low, CodeModeImageDetail::High => ImageDetail::High, CodeModeImageDetail::Original => ImageDetail::Original, } diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 4f828c8910..725535339f 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -1,11 +1,11 @@ use serde::Deserialize; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -43,8 +43,6 @@ where #[async_trait::async_trait] impl ToolExecutor for CodeModeWaitHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(WAIT_TOOL_NAME) } @@ -53,7 +51,10 @@ impl ToolExecutor for CodeModeWaitHandler { Some(create_wait_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -99,6 +100,7 @@ impl ToolExecutor for CodeModeWaitHandler { } handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at) .await + .map(boxed_tool_output) .map_err(FunctionCallError::RespondToModel) } _ => Err(FunctionCallError::RespondToModel(format!( @@ -108,4 +110,4 @@ impl ToolExecutor for CodeModeWaitHandler { } } -impl ToolHandler for CodeModeWaitHandler {} +impl CoreToolRuntime for CodeModeWaitHandler {} diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 0f3ae631a0..5fc19a2a2e 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -28,6 +28,13 @@ use tokio_util::sync::CancellationToken; pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; +pub(crate) fn boxed_tool_output(output: T) -> Box +where + T: ToolOutput + 'static, +{ + Box::new(output) +} + pub type SharedTurnDiffTracker = Arc>; #[derive(Clone, Debug, Eq, PartialEq)] @@ -91,6 +98,10 @@ impl ToolOutput for McpToolOutput { }) } + fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { + Some(self.tool_input.clone()) + } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { serde_json::to_value(&self.result).ok() } @@ -327,6 +338,20 @@ impl ToolOutput for ExecCommandToolOutput { ) } + fn post_tool_use_id(&self, call_id: &str) -> String { + if self.event_call_id.is_empty() { + call_id.to_string() + } else { + self.event_call_id.clone() + } + } + + fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { + self.hook_command + .as_ref() + .map(|command| serde_json::json!({ "command": command })) + } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { if self.process_id.is_some() || self.hook_command.is_none() { return None; 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 e08eb4b5fb..4641e793c8 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 @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::agent_jobs_spec::create_report_agent_job_result_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -14,8 +15,6 @@ pub struct ReportAgentJobResultHandler; #[async_trait::async_trait] impl ToolExecutor for ReportAgentJobResultHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("report_agent_job_result") } @@ -24,7 +23,10 @@ impl ToolExecutor for ReportAgentJobResultHandler { Some(create_report_agent_job_result_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, payload, .. } = invocation; @@ -38,11 +40,11 @@ impl ToolExecutor for ReportAgentJobResultHandler { } }; - handle(session, arguments).await + handle(session, arguments).await.map(boxed_tool_output) } } -impl ToolHandler for ReportAgentJobResultHandler { +impl CoreToolRuntime for ReportAgentJobResultHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 a384f2d8cc..0e51390e79 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 @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::agent_jobs_spec::create_spawn_agents_on_csv_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_utils_absolute_path::AbsolutePathBuf; @@ -15,8 +16,6 @@ pub struct SpawnAgentsOnCsvHandler; #[async_trait::async_trait] impl ToolExecutor for SpawnAgentsOnCsvHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("spawn_agents_on_csv") } @@ -25,7 +24,10 @@ impl ToolExecutor for SpawnAgentsOnCsvHandler { Some(create_spawn_agents_on_csv_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -42,11 +44,13 @@ impl ToolExecutor for SpawnAgentsOnCsvHandler { } }; - handle(session, turn, arguments).await + handle(session, turn, arguments) + .await + .map(boxed_tool_output) } } -impl ToolHandler for SpawnAgentsOnCsvHandler { +impl CoreToolRuntime for SpawnAgentsOnCsvHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index c8e2461e03..500751137b 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -17,8 +17,8 @@ use crate::tools::context::ApplyPatchToolOutput; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::handlers::apply_granted_turn_permissions; @@ -27,11 +27,11 @@ use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolArgumentDiffConsumer; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::runtimes::apply_patch::ApplyPatchRequest; use crate::tools::runtimes::apply_patch::ApplyPatchRuntime; use crate::tools::sandboxing::ToolCtx; @@ -299,8 +299,6 @@ async fn effective_patch_permissions( #[async_trait::async_trait] impl ToolExecutor for ApplyPatchHandler { - type Output = ApplyPatchToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("apply_patch") } @@ -309,7 +307,10 @@ impl ToolExecutor for ApplyPatchHandler { Some(create_apply_patch_freeform_tool(self.multi_environment)) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -359,7 +360,7 @@ impl ToolExecutor for ApplyPatchHandler { { InternalApplyPatchInvocation::Output(item) => { let content = item?; - Ok(ApplyPatchToolOutput::from_text(content)) + Ok(boxed_tool_output(ApplyPatchToolOutput::from_text(content))) } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); @@ -414,7 +415,7 @@ impl ToolExecutor for ApplyPatchHandler { Some(&tracker), ); let content = emitter.finish(event_ctx, out, delta.as_ref()).await?; - Ok(ApplyPatchToolOutput::from_text(content)) + Ok(boxed_tool_output(ApplyPatchToolOutput::from_text(content))) } } } @@ -438,7 +439,7 @@ impl ToolExecutor for ApplyPatchHandler { } } -impl ToolHandler for ApplyPatchHandler { +impl CoreToolRuntime for ApplyPatchHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Custom { .. }) } @@ -472,7 +473,7 @@ impl ToolHandler for ApplyPatchHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 29dc5199e3..5d20128ff3 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -4,10 +4,11 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; 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; use codex_protocol::dynamic_tools::DynamicToolCallRequest; @@ -62,8 +63,6 @@ impl DynamicToolHandler { #[async_trait::async_trait] impl ToolExecutor for DynamicToolHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { self.tool_name.clone() } @@ -76,7 +75,10 @@ impl ToolExecutor for DynamicToolHandler { self.exposure } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -117,11 +119,14 @@ impl ToolExecutor for DynamicToolHandler { .into_iter() .map(FunctionCallOutputContentItem::from) .collect::>(); - Ok(FunctionToolOutput::from_content(body, Some(success))) + Ok(boxed_tool_output(FunctionToolOutput::from_content( + body, + Some(success), + ))) } } -impl ToolHandler for DynamicToolHandler { +impl CoreToolRuntime for DynamicToolHandler { fn search_info(&self) -> Option { ToolSearchInfo::from_spec( self.search_text.clone(), diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index c4018fe936..37ad2d65fd 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use codex_extension_api::ExtensionToolExecutor; -use codex_extension_api::ExtensionToolOutput; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -13,18 +11,16 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; -pub(crate) struct ExtensionToolHandler { - executor: Arc, -} +pub(crate) struct ExtensionToolAdapter(Arc>); -impl ExtensionToolHandler { - pub(crate) fn new(executor: Arc) -> Self { - Self { executor } +impl ExtensionToolAdapter { + pub(crate) fn new(executor: Arc>) -> Self { + Self(executor) } fn arguments_from_payload<'a>(&self, payload: &'a ToolPayload) -> Option<&'a str> { @@ -36,23 +32,32 @@ impl ExtensionToolHandler { } #[async_trait::async_trait] -impl ToolExecutor for ExtensionToolHandler { - type Output = ExtensionToolOutput; - +impl ToolExecutor for ExtensionToolAdapter { fn tool_name(&self) -> ToolName { - self.executor.tool_name() + self.0.tool_name() } fn spec(&self) -> Option { - self.executor.spec() + self.0.spec() } - async fn handle(&self, invocation: ToolInvocation) -> Result { - self.executor.handle(to_extension_call(&invocation)).await + fn exposure(&self) -> crate::tools::registry::ToolExposure { + self.0.exposure() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.0.supports_parallel_tool_calls() + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + self.0.handle(to_extension_call(&invocation)).await } } -impl ToolHandler for ExtensionToolHandler { +impl CoreToolRuntime for ExtensionToolAdapter { fn matches_kind(&self, payload: &ToolPayload) -> bool { self.arguments_from_payload(payload).is_some() } @@ -68,7 +73,7 @@ impl ToolHandler for ExtensionToolHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn ToolOutput, ) -> Option { let arguments = self.arguments_from_payload(&invocation.payload)?; Some(PostToolUsePayload { @@ -104,22 +109,20 @@ mod tests { use pretty_assertions::assert_eq; use serde_json::json; - use super::ExtensionToolHandler; + use super::ExtensionToolAdapter; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; + use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; - use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; struct 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") } @@ -148,14 +151,16 @@ mod tests { async fn handle( &self, _call: codex_tools::ToolCall, - ) -> Result { - Ok(codex_tools::JsonToolOutput::new(json!({ "ok": true }))) + ) -> Result, codex_tools::FunctionCallError> { + Ok(Box::new(codex_tools::JsonToolOutput::new( + json!({ "ok": true }), + ))) } } #[tokio::test] async fn exposes_generic_hook_payloads() { - let handler = ExtensionToolHandler::new(Arc::new(StubExtensionExecutor)); + let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor)); let (session, turn) = crate::session::tests::make_session_and_context().await; let invocation = ToolInvocation { session: session.into(), @@ -172,14 +177,14 @@ mod tests { let output = codex_tools::JsonToolOutput::new(json!({ "ok": true })); assert_eq!( - ToolHandler::pre_tool_use_payload(&handler, &invocation), + CoreToolRuntime::pre_tool_use_payload(&handler, &invocation), Some(PreToolUsePayload { tool_name: HookToolName::new("extension_echo"), tool_input: json!({ "message": "hello" }), }) ); assert_eq!( - ToolHandler::post_tool_use_payload(&handler, &invocation, &output), + CoreToolRuntime::post_tool_use_payload(&handler, &invocation, &output), Some(PostToolUsePayload { tool_name: HookToolName::new("extension_echo"), tool_use_id: "call-extension".to_string(), diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs index 28e33f2be4..e50095e524 100644 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -87,20 +87,13 @@ fn goal_response( } fn completion_budget_report(goal: &ThreadGoal) -> Option { - let mut parts = Vec::new(); - if let Some(budget) = goal.token_budget { - parts.push(format!("tokens used: {} of {budget}", goal.tokens_used)); - } - if goal.time_used_seconds > 0 { - parts.push(format!("time used: {} seconds", goal.time_used_seconds)); - } - if parts.is_empty() { + if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { None } else { - Some(format!( - "Goal achieved. Report final budget usage to the user: {}.", - parts.join("; ") - )) + Some( + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." + .to_string(), + ) } } @@ -131,7 +124,7 @@ mod tests { goal: Some(goal), remaining_tokens: Some(6_750), completion_budget_report: Some( - "Goal achieved. Report final budget usage to the user: tokens used: 3250 of 10000; time used: 75 seconds." + "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." .to_string() ), } 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 f7575e1e52..9ae0107f34 100644 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/create_goal.rs @@ -1,13 +1,13 @@ use crate::function_tool::FunctionCallError; use crate::goals::CreateGoalRequest; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_create_goal_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -20,8 +20,6 @@ pub struct CreateGoalHandler; #[async_trait::async_trait] impl ToolExecutor for CreateGoalHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(CREATE_GOAL_TOOL_NAME) } @@ -30,7 +28,10 @@ impl ToolExecutor for CreateGoalHandler { Some(create_create_goal_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -70,8 +71,8 @@ impl ToolExecutor for CreateGoalHandler { FunctionCallError::RespondToModel(format_goal_error(err)) } })?; - goal_response(Some(goal), CompletionBudgetReport::Omit) + goal_response(Some(goal), CompletionBudgetReport::Omit).map(boxed_tool_output) } } -impl ToolHandler for CreateGoalHandler {} +impl CoreToolRuntime for CreateGoalHandler {} 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 20691b8b5b..ff40227fab 100644 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/get_goal.rs @@ -1,11 +1,11 @@ use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_get_goal_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -17,8 +17,6 @@ pub struct GetGoalHandler; #[async_trait::async_trait] impl ToolExecutor for GetGoalHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(GET_GOAL_TOOL_NAME) } @@ -27,7 +25,10 @@ impl ToolExecutor for GetGoalHandler { Some(create_get_goal_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, payload, .. } = invocation; @@ -38,7 +39,7 @@ impl ToolExecutor for GetGoalHandler { .get_thread_goal() .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit) + goal_response(goal, CompletionBudgetReport::Omit).map(boxed_tool_output) } _ => Err(FunctionCallError::RespondToModel( "get_goal handler received unsupported payload".to_string(), @@ -47,4 +48,4 @@ impl ToolExecutor for GetGoalHandler { } } -impl ToolHandler for GetGoalHandler {} +impl CoreToolRuntime for GetGoalHandler {} 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 bb8ed10efd..8cf8ce546a 100644 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ b/codex-rs/core/src/tools/handlers/goal/update_goal.rs @@ -1,14 +1,14 @@ use crate::function_tool::FunctionCallError; use crate::goals::GoalRuntimeEvent; use crate::goals::SetGoalRequest; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use crate::tools::handlers::goal_spec::create_update_goal_tool; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::protocol::ThreadGoalStatus; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -22,8 +22,6 @@ pub struct UpdateGoalHandler; #[async_trait::async_trait] impl ToolExecutor for UpdateGoalHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(UPDATE_GOAL_TOOL_NAME) } @@ -32,7 +30,10 @@ impl ToolExecutor for UpdateGoalHandler { Some(create_update_goal_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -73,8 +74,8 @@ impl ToolExecutor for UpdateGoalHandler { ) .await .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(Some(goal), CompletionBudgetReport::Include) + goal_response(Some(goal), CompletionBudgetReport::Include).map(boxed_tool_output) } } -impl ToolHandler for UpdateGoalHandler {} +impl CoreToolRuntime for UpdateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index f82f741346..6f4f79505b 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -6,15 +6,15 @@ use crate::mcp_tool_call::handle_mcp_tool_call; use crate::original_image_detail::can_request_original_image_detail; use crate::tools::context::McpToolOutput; use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; 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; use codex_mcp::ToolInfo; @@ -47,8 +47,6 @@ impl McpHandler { #[async_trait::async_trait] impl ToolExecutor for McpHandler { - type Output = McpToolOutput; - fn tool_name(&self) -> ToolName { self.tool_info.canonical_tool_name() } @@ -89,7 +87,10 @@ impl ToolExecutor for McpHandler { self.tool_info.supports_parallel_tool_calls } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -119,17 +120,17 @@ impl ToolExecutor for McpHandler { ) .await; - Ok(McpToolOutput { + Ok(boxed_tool_output(McpToolOutput { result: result.result, tool_input: result.tool_input, wall_time: started.elapsed(), original_image_detail_supported: can_request_original_image_detail(&turn.model_info), truncation_policy: turn.truncation_policy, - }) + })) } } -impl ToolHandler for McpHandler { +impl CoreToolRuntime for McpHandler { fn search_info(&self) -> Option { let source_name = self .tool_info @@ -156,12 +157,17 @@ impl ToolHandler for McpHandler { ) } - async fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags { - let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())]; - if let Some(origin) = &self.tool_info.server_origin { - tags.push(("mcp_server_origin", origin.clone())); - } - tags + fn telemetry_tags<'a>( + &'a self, + _invocation: &'a ToolInvocation, + ) -> futures::future::BoxFuture<'a, ToolTelemetryTags> { + Box::pin(async { + let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())]; + if let Some(origin) = &self.tool_info.server_origin { + tags.push(("mcp_server_origin", origin.clone())); + } + tags + }) } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { @@ -200,7 +206,7 @@ impl ToolHandler for McpHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { let ToolPayload::Function { .. } = &invocation.payload else { return None; @@ -211,7 +217,7 @@ impl ToolHandler for McpHandler { Some(PostToolUsePayload { tool_name: HookToolName::new(self.tool_name().to_string()), tool_use_id: invocation.call_id.clone(), - tool_input: result.tool_input.clone(), + tool_input: result.post_tool_use_input(&invocation.payload)?, tool_response, }) } 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 71749e9cd1..866c7a9e12 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 @@ -1,12 +1,12 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resource_templates_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; use codex_tools::ToolName; @@ -28,8 +28,6 @@ pub struct ListMcpResourceTemplatesHandler; #[async_trait::async_trait] impl ToolExecutor for ListMcpResourceTemplatesHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("list_mcp_resource_templates") } @@ -46,7 +44,10 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { clippy::await_holding_invalid_type, reason = "MCP resource template listing reads through the session-owned manager guard" )] - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -131,7 +132,7 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { Ok(call_tool_result_from_content(&content, output.success)), ) .await; - Ok(output) + Ok(boxed_tool_output(output)) } Err(err) => { let duration = start.elapsed(); @@ -166,4 +167,4 @@ impl ToolExecutor for ListMcpResourceTemplatesHandler { } } -impl ToolHandler for ListMcpResourceTemplatesHandler {} +impl CoreToolRuntime for ListMcpResourceTemplatesHandler {} 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 08b387376b..c87747eea4 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 @@ -1,12 +1,12 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resources_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; use codex_tools::ToolName; @@ -28,8 +28,6 @@ pub struct ListMcpResourcesHandler; #[async_trait::async_trait] impl ToolExecutor for ListMcpResourcesHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("list_mcp_resources") } @@ -46,7 +44,10 @@ impl ToolExecutor for ListMcpResourcesHandler { clippy::await_holding_invalid_type, reason = "MCP resource listing reads through the session-owned manager guard" )] - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -129,7 +130,7 @@ impl ToolExecutor for ListMcpResourcesHandler { Ok(call_tool_result_from_content(&content, output.success)), ) .await; - Ok(output) + Ok(boxed_tool_output(output)) } Err(err) => { let duration = start.elapsed(); @@ -164,4 +165,4 @@ impl ToolExecutor for ListMcpResourcesHandler { } } -impl ToolHandler for ListMcpResourcesHandler {} +impl CoreToolRuntime for ListMcpResourcesHandler {} 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 bd8172ac74..3bb4d7f0ec 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 @@ -1,12 +1,12 @@ use std::time::Instant; use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::mcp_resource_spec::create_read_mcp_resource_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::protocol::McpInvocation; use codex_tools::ToolName; @@ -28,8 +28,6 @@ pub struct ReadMcpResourceHandler; #[async_trait::async_trait] impl ToolExecutor for ReadMcpResourceHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("read_mcp_resource") } @@ -42,7 +40,10 @@ impl ToolExecutor for ReadMcpResourceHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -112,7 +113,7 @@ impl ToolExecutor for ReadMcpResourceHandler { Ok(call_tool_result_from_content(&content, output.success)), ) .await; - Ok(output) + Ok(boxed_tool_output(output)) } Err(err) => { let duration = start.elapsed(); @@ -147,4 +148,4 @@ impl ToolExecutor for ReadMcpResourceHandler { } } -impl ToolHandler for ReadMcpResourceHandler {} +impl CoreToolRuntime for ReadMcpResourceHandler {} diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index d0c5edfb77..1587c4e840 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -13,10 +13,11 @@ use crate::session::turn_context::TurnContext; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; pub(crate) use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::ThreadId; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; 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 6459a899fe..805f569b8e 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 @@ -7,8 +7,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = CloseAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("close_agent") } @@ -17,8 +15,11 @@ impl ToolExecutor for Handler { Some(create_close_agent_tool_v1()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_close_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_close_agent(invocation).await.map(boxed_tool_output) } } @@ -105,7 +106,7 @@ async fn handle_close_agent( }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 9dab2d999d..71b29fb423 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 @@ -9,8 +9,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = ResumeAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("resume_agent") } @@ -19,8 +17,11 @@ impl ToolExecutor for Handler { Some(create_resume_agent_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_resume_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_resume_agent(invocation).await.map(boxed_tool_output) } } @@ -133,7 +134,7 @@ async fn handle_resume_agent( Ok(ResumeAgentResult { status }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 a6067e5b12..74f7eb05b4 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 @@ -8,8 +8,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = SendInputResult; - fn tool_name(&self) -> ToolName { ToolName::plain("send_input") } @@ -18,7 +16,10 @@ impl ToolExecutor for Handler { Some(create_send_input_tool_v1()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -85,11 +86,11 @@ impl ToolExecutor for Handler { .await; let submission_id = result?; - Ok(SendInputResult { submission_id }) + Ok(boxed_tool_output(SendInputResult { submission_id })) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 91f5990210..d19849d443 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -24,8 +24,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = SpawnAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("spawn_agent") } @@ -34,8 +32,11 @@ impl ToolExecutor for Handler { Some(create_spawn_agent_tool_v1(self.options.clone())) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_spawn_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_spawn_agent(invocation).await.map(boxed_tool_output) } } @@ -198,7 +199,7 @@ async fn handle_spawn_agent( }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 d325edeeb0..a69784b901 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -29,8 +29,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = WaitAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("wait_agent") } @@ -39,7 +37,10 @@ impl ToolExecutor for Handler { Some(create_wait_agent_tool_v1(self.options)) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -197,11 +198,11 @@ impl ToolExecutor for Handler { ) .await; - Ok(result) + Ok(boxed_tool_output(result)) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 12c25aa810..0044bf8522 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -317,7 +317,8 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() { })), )) .await - .expect_err("fork_context should reject agent_type overrides"); + .err() + .expect("fork_context should reject agent_type overrides"); assert_eq!( err, @@ -351,7 +352,8 @@ async fn spawn_agent_fork_context_rejects_child_model_overrides() { })), )) .await - .expect_err("forked spawn should reject child model overrides"); + .err() + .expect("forked spawn should reject child model overrides"); assert_eq!( err, @@ -395,7 +397,8 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { })), )) .await - .expect_err("fork_turns=all should reject agent_type overrides"); + .err() + .expect("fork_turns=all should reject agent_type overrides"); assert_eq!( err, @@ -435,7 +438,8 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over })), )) .await - .expect_err("default full fork should reject child model overrides"); + .err() + .expect("default full fork should reject child model overrides"); assert_eq!( err, @@ -505,7 +509,8 @@ async fn spawn_agent_service_tier_override_validates_the_effective_child_model() })), )) .await - .expect_err("unknown service tier should be rejected"); + .err() + .expect("unknown service tier should be rejected"); assert_eq!( err, @@ -530,7 +535,8 @@ async fn spawn_agent_service_tier_override_validates_the_effective_child_model() })), )) .await - .expect_err("tier unsupported by the final child model should be rejected"); + .err() + .expect("tier unsupported by the final child model should be rejected"); assert_eq!( err, @@ -1116,7 +1122,8 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() { })), )) .await - .expect_err("legacy fork_context should be rejected"); + .err() + .expect("legacy fork_context should be rejected"); assert_eq!( err, @@ -1155,7 +1162,8 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() { })), )) .await - .expect_err("invalid fork_turns should be rejected"); + .err() + .expect("invalid fork_turns should be rejected"); assert_eq!( err, @@ -1194,7 +1202,8 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() { })), )) .await - .expect_err("zero turn count should be rejected"); + .err() + .expect("zero turn count should be rejected"); assert_eq!( err, @@ -2111,7 +2120,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { turn.permission_profile = expected_permission_profile.clone(); assert_ne!( expected_permission_profile, - turn.config.permissions.permission_profile(), + turn.config.permissions.effective_permission_profile(), "test requires a runtime profile override that differs from base config" ); @@ -3630,7 +3639,8 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { function_payload(json!({"target": "/root"})), )) .await - .expect_err("close_agent should reject the root path"); + .err() + .expect("close_agent should reject the root path"); assert_eq!( root_path_error, FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) @@ -3644,7 +3654,8 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { function_payload(json!({"target": root.thread_id.to_string()})), )) .await - .expect_err("close_agent should reject the root thread id"); + .err() + .expect("close_agent should reject the root thread id"); assert_eq!( root_id_error, FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) @@ -3900,7 +3911,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr #[tokio::test] async fn build_agent_spawn_config_uses_turn_context_values() { fn pick_allowed_sandbox_policy( - constraint: &crate::config::Constrained, + permissions: &crate::config::Permissions, base: SandboxPolicy, cwd: &std::path::Path, ) -> SandboxPolicy { @@ -3915,16 +3926,9 @@ async fn build_agent_spawn_config_uses_turn_context_values() { if *candidate == base { return false; } - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(candidate, cwd); - let network_sandbox_policy = NetworkSandboxPolicy::from(candidate); - let permission_profile = - PermissionProfile::from_runtime_permissions_with_enforcement( - SandboxEnforcement::from_legacy_sandbox_policy(candidate), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - constraint.can_set(&permission_profile).is_ok() + permissions + .can_set_legacy_sandbox_policy(candidate, cwd) + .is_ok() }) .unwrap_or(base) } @@ -3948,7 +3952,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { #[allow(deprecated)] let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( - &turn.config.permissions.permission_profile, + &turn.config.permissions, turn.config.legacy_sandbox_policy(), turn_cwd.as_path(), ); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 0190fe04ac..068f393379 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -6,10 +6,11 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::AgentPath; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; 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 7b575f8258..180cf1a9d8 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 @@ -7,8 +7,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = CloseAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("close_agent") } @@ -17,8 +15,11 @@ impl ToolExecutor for Handler { Some(create_close_agent_tool_v2()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_close_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_close_agent(invocation).await.map(boxed_tool_output) } } @@ -117,7 +118,7 @@ async fn handle_close_agent( }) } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 7d111f0418..4b3edcd487 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 @@ -2,7 +2,6 @@ use super::message_tool::FollowupTaskArgs; use super::message_tool::MessageDeliveryMode; use super::message_tool::handle_message_string_tool; use super::*; -use crate::tools::context::FunctionToolOutput; use crate::tools::handlers::multi_agents_spec::create_followup_task_tool; use codex_tools::ToolSpec; @@ -10,8 +9,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("followup_task") } @@ -20,7 +17,10 @@ impl ToolExecutor for Handler { Some(create_followup_task_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let arguments = function_arguments(invocation.payload.clone())?; let args: FollowupTaskArgs = parse_arguments(&arguments)?; handle_message_string_tool( @@ -30,10 +30,11 @@ impl ToolExecutor for Handler { args.message, ) .await + .map(boxed_tool_output) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 8b0ee551e3..b8b9072da9 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 @@ -7,8 +7,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = ListAgentsResult; - fn tool_name(&self) -> ToolName { ToolName::plain("list_agents") } @@ -17,7 +15,10 @@ impl ToolExecutor for Handler { Some(create_list_agents_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -37,11 +38,11 @@ impl ToolExecutor for Handler { .await .map_err(collab_spawn_error)?; - Ok(ListAgentsResult { agents }) + Ok(boxed_tool_output(ListAgentsResult { agents })) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 584feec61f..202e28e0c9 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 @@ -2,7 +2,6 @@ use super::message_tool::MessageDeliveryMode; use super::message_tool::SendMessageArgs; use super::message_tool::handle_message_string_tool; use super::*; -use crate::tools::context::FunctionToolOutput; use crate::tools::handlers::multi_agents_spec::create_send_message_tool; use codex_tools::ToolSpec; @@ -10,8 +9,6 @@ pub(crate) struct Handler; #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("send_message") } @@ -20,7 +17,10 @@ impl ToolExecutor for Handler { Some(create_send_message_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let arguments = function_arguments(invocation.payload.clone())?; let args: SendMessageArgs = parse_arguments(&arguments)?; handle_message_string_tool( @@ -30,10 +30,11 @@ impl ToolExecutor for Handler { args.message, ) .await + .map(boxed_tool_output) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 3ad7b87145..2daa1f07db 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 @@ -26,8 +26,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = SpawnAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("spawn_agent") } @@ -36,8 +34,11 @@ impl ToolExecutor for Handler { Some(create_spawn_agent_tool_v2(self.options.clone())) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - handle_spawn_agent(invocation).await + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + handle_spawn_agent(invocation).await.map(boxed_tool_output) } } @@ -229,7 +230,7 @@ async fn handle_spawn_agent( } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } 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 53c976c9d4..8913411b53 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 @@ -21,8 +21,6 @@ impl Handler { #[async_trait::async_trait] impl ToolExecutor for Handler { - type Output = WaitAgentResult; - fn tool_name(&self) -> ToolName { ToolName::plain("wait_agent") } @@ -31,7 +29,10 @@ impl ToolExecutor for Handler { Some(create_wait_agent_tool_v2(self.options)) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -97,11 +98,11 @@ impl ToolExecutor for Handler { ) .await; - Ok(result) + Ok(boxed_tool_output(result)) } } -impl ToolHandler for Handler { +impl CoreToolRuntime for Handler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 9868c77ca7..f5c94826f7 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::plan_spec::create_update_plan_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::config_types::ModeKind; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; @@ -46,8 +47,6 @@ impl ToolOutput for PlanToolOutput { #[async_trait::async_trait] impl ToolExecutor for PlanHandler { - type Output = PlanToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("update_plan") } @@ -56,7 +55,10 @@ impl ToolExecutor for PlanHandler { Some(create_update_plan_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -85,11 +87,11 @@ impl ToolExecutor for PlanHandler { .send_event(turn.as_ref(), EventMsg::PlanUpdate(args)) .await; - Ok(PlanToolOutput) + Ok(boxed_tool_output(PlanToolOutput)) } } -impl ToolHandler for PlanHandler {} +impl CoreToolRuntime for PlanHandler {} fn parse_update_plan_arguments(arguments: &str) -> Result { serde_json::from_str::(arguments).map_err(|e| { diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 2f2e71677e..007c8d2208 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -5,11 +5,12 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::handlers::shell_spec::create_request_permissions_tool; use crate::tools::handlers::shell_spec::request_permissions_tool_description; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -17,8 +18,6 @@ pub struct RequestPermissionsHandler; #[async_trait::async_trait] impl ToolExecutor for RequestPermissionsHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("request_permissions") } @@ -29,7 +28,10 @@ impl ToolExecutor for RequestPermissionsHandler { )) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -75,8 +77,11 @@ impl ToolExecutor for RequestPermissionsHandler { )) })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) } } -impl ToolHandler for RequestPermissionsHandler {} +impl CoreToolRuntime for RequestPermissionsHandler {} 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 6073831e02..3b53fd007a 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -32,10 +32,11 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; #[derive(Default)] pub struct RequestPluginInstallHandler { @@ -52,8 +53,6 @@ impl RequestPluginInstallHandler { #[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME) } @@ -70,7 +69,10 @@ impl ToolExecutor for RequestPluginInstallHandler { clippy::await_holding_invalid_type, reason = "plugin install discovery reads through the session-owned manager guard" )] - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { payload, session, @@ -190,11 +192,14 @@ impl ToolExecutor for RequestPluginInstallHandler { )) })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) } } -impl ToolHandler for RequestPluginInstallHandler {} +impl CoreToolRuntime for RequestPluginInstallHandler {} async fn maybe_persist_disabled_install_request( session: &crate::session::session::Session, 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 7597cb6004..8384e02637 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -2,14 +2,15 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME; use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool; use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_args; use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description; use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_tools::ToolName; @@ -21,8 +22,6 @@ pub struct RequestUserInputHandler { #[async_trait::async_trait] impl ToolExecutor for RequestUserInputHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME) } @@ -33,7 +32,10 @@ impl ToolExecutor for RequestUserInputHandler { )) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -80,11 +82,14 @@ impl ToolExecutor for RequestUserInputHandler { )) })?; - Ok(FunctionToolOutput::from_text(content, Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(true), + ))) } } -impl ToolHandler for RequestUserInputHandler {} +impl CoreToolRuntime for RequestUserInputHandler {} #[cfg(test)] #[path = "request_user_input_tests.rs"] 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 54d74f13b7..53156c5cfa 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -10,19 +10,18 @@ use crate::function_tool::FunctionCallError; use crate::maybe_emit_implicit_skill_invocation; use crate::session::turn_context::TurnContext; use crate::shell::Shell; -use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::handlers::resolve_workdir_base_path; use crate::tools::handlers::rewrite_function_string_argument; use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; 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; @@ -129,8 +128,6 @@ impl From for ShellCommandHandler { #[async_trait::async_trait] impl ToolExecutor for ShellCommandHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("shell_command") } @@ -148,7 +145,10 @@ impl ToolExecutor for ShellCommandHandler { self.options.is_some() } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -201,10 +201,11 @@ impl ToolExecutor for ShellCommandHandler { shell_runtime_backend: self.shell_runtime_backend(), }) .await + .map(boxed_tool_output) } } -impl ToolHandler for ShellCommandHandler { +impl CoreToolRuntime for ShellCommandHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } @@ -240,7 +241,7 @@ impl ToolHandler for ShellCommandHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 660bac6789..eda23533a3 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -18,7 +18,7 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::handlers::ShellCommandHandler; use crate::tools::hook_names::HookToolName; -use crate::tools::registry::ToolHandler; +use crate::tools::registry::CoreToolRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use codex_shell_command::is_safe_command::is_known_safe_command; use codex_shell_command::powershell::try_find_powershell_executable_blocking; diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index feee6470a3..d3c95dea7e 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -12,10 +12,11 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::test_sync_spec::create_test_sync_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -58,8 +59,6 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { #[async_trait::async_trait] impl ToolExecutor for TestSyncHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("test_sync_tool") } @@ -72,7 +71,10 @@ impl ToolExecutor for TestSyncHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { payload, .. } = invocation; let arguments = match payload { @@ -102,11 +104,14 @@ impl ToolExecutor for TestSyncHandler { sleep(Duration::from_millis(delay)).await; } - Ok(FunctionToolOutput::from_text("ok".to_string(), Some(true))) + Ok(boxed_tool_output(FunctionToolOutput::from_text( + "ok".to_string(), + Some(true), + ))) } } -impl ToolHandler for TestSyncHandler {} +impl CoreToolRuntime for TestSyncHandler {} async fn wait_on_barrier(args: BarrierArgs) -> Result<(), FunctionCallError> { if args.participants == 0 { diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 579826ac04..ffb25cb880 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -2,9 +2,10 @@ use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::ToolSearchOutput; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::tool_search_spec::create_tool_search_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::tool_search_entry::ToolSearchEntry; use crate::tools::tool_search_entry::ToolSearchInfo; use bm25::Document; @@ -54,8 +55,6 @@ impl ToolSearchHandler { #[async_trait::async_trait] impl ToolExecutor for ToolSearchHandler { - type Output = ToolSearchOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(TOOL_SEARCH_TOOL_NAME) } @@ -74,7 +73,7 @@ impl ToolExecutor for ToolSearchHandler { async fn handle( &self, invocation: ToolInvocation, - ) -> Result { + ) -> Result, FunctionCallError> { let ToolInvocation { payload, .. } = invocation; let args = match payload { @@ -101,16 +100,16 @@ impl ToolExecutor for ToolSearchHandler { } if self.entries.is_empty() { - return Ok(ToolSearchOutput { tools: Vec::new() }); + return Ok(boxed_tool_output(ToolSearchOutput { tools: Vec::new() })); } let tools = self.search(query, limit)?; - Ok(ToolSearchOutput { tools }) + Ok(boxed_tool_output(ToolSearchOutput { tools })) } } -impl ToolHandler for ToolSearchHandler {} +impl CoreToolRuntime for ToolSearchHandler {} impl ToolSearchHandler { fn search( diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 22e0a16dd8..b4e7bd6acf 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -2,7 +2,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; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -88,23 +87,19 @@ pub(crate) struct ResolvedCommand { fn post_unified_exec_tool_use_payload( invocation: &ToolInvocation, - result: &ExecCommandToolOutput, + result: &dyn ToolOutput, ) -> Option { let ToolPayload::Function { .. } = &invocation.payload else { return None; }; - let command = result.hook_command.clone()?; - let tool_use_id = if result.event_call_id.is_empty() { - invocation.call_id.clone() - } else { - result.event_call_id.clone() - }; + let tool_input = result.post_tool_use_input(&invocation.payload)?; + let tool_use_id = result.post_tool_use_id(&invocation.call_id); let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?; Some(PostToolUsePayload { tool_name: HookToolName::bash(), tool_use_id, - tool_input: serde_json::json!({ "command": command }), + tool_input, tool_response, }) } 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 2df924b5f4..b048227cf1 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 @@ -5,6 +5,7 @@ use crate::maybe_emit_implicit_skill_invocation; use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::apply_granted_turn_permissions; use crate::tools::handlers::apply_patch::intercept_apply_patch; use crate::tools::handlers::implicit_granted_permissions; @@ -15,10 +16,10 @@ use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::rewrite_function_string_argument; use crate::tools::handlers::updated_hook_command; use crate::tools::hook_names::HookToolName; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::UnifiedExecContext; use crate::unified_exec::UnifiedExecError; @@ -70,8 +71,6 @@ impl ExecCommandHandler { #[async_trait::async_trait] impl ToolExecutor for ExecCommandHandler { - type Output = ExecCommandToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("exec_command") } @@ -90,7 +89,10 @@ impl ToolExecutor for ExecCommandHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -234,7 +236,7 @@ impl ToolExecutor for ExecCommandHandler { .await? { manager.release_process_id(process_id).await; - return Ok(ExecCommandToolOutput { + return Ok(boxed_tool_output(ExecCommandToolOutput { event_call_id: String::new(), chunk_id: String::new(), wall_time: std::time::Duration::ZERO, @@ -244,7 +246,7 @@ impl ToolExecutor for ExecCommandHandler { exit_code: None, original_token_count: None, hook_command: None, - }); + })); } emit_unified_exec_tty_metric(&turn.session_telemetry, tty); @@ -273,11 +275,11 @@ impl ToolExecutor for ExecCommandHandler { ) .await { - Ok(response) => Ok(response), + Ok(response) => Ok(boxed_tool_output(response)), Err(UnifiedExecError::SandboxDenied { output, .. }) => { let output_text = output.aggregated_output.text; let original_token_count = approx_token_count(&output_text); - Ok(ExecCommandToolOutput { + Ok(boxed_tool_output(ExecCommandToolOutput { event_call_id: context.call_id.clone(), chunk_id: generate_chunk_id(), wall_time: output.duration, @@ -289,7 +291,7 @@ impl ToolExecutor for ExecCommandHandler { exit_code: Some(output.exit_code), original_token_count: Some(original_token_count), hook_command: Some(hook_command), - }) + })) } Err(err) => Err(FunctionCallError::RespondToModel(format!( "exec_command failed for `{command_for_display}`: {err:?}" @@ -298,7 +300,7 @@ impl ToolExecutor for ExecCommandHandler { } } -impl ToolHandler for ExecCommandHandler { +impl CoreToolRuntime for ExecCommandHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } @@ -340,7 +342,7 @@ impl ToolHandler for ExecCommandHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { post_unified_exec_tool_use_payload(invocation, result) } 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 29ee4b4ea3..3dea7092f5 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 @@ -1,11 +1,11 @@ use crate::function_tool::FunctionCallError; -use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::unified_exec::WriteStdinRequest; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TerminalInteractionEvent; @@ -33,8 +33,6 @@ pub struct WriteStdinHandler; #[async_trait::async_trait] impl ToolExecutor for WriteStdinHandler { - type Output = ExecCommandToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("write_stdin") } @@ -43,7 +41,10 @@ impl ToolExecutor for WriteStdinHandler { Some(create_write_stdin_tool()) } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { let ToolInvocation { session, turn, @@ -86,11 +87,11 @@ impl ToolExecutor for WriteStdinHandler { .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) .await; - Ok(response) + Ok(boxed_tool_output(response)) } } -impl ToolHandler for WriteStdinHandler { +impl CoreToolRuntime for WriteStdinHandler { fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. }) } @@ -98,7 +99,7 @@ impl ToolHandler for WriteStdinHandler { fn post_tool_use_payload( &self, invocation: &ToolInvocation, - result: &Self::Output, + result: &dyn crate::tools::context::ToolOutput, ) -> Option { post_unified_exec_tool_use_payload(invocation, result) } 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 6ab2752b94..50a8e44214 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -13,7 +13,7 @@ use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; -use crate::tools::registry::ToolHandler; +use crate::tools::registry::CoreToolRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use tokio::sync::Mutex; diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 7727fb4370..c3e6792c06 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -16,12 +16,13 @@ use crate::original_image_detail::can_request_original_image_detail; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; use crate::tools::handlers::parse_arguments; use crate::tools::handlers::resolve_tool_environment; use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::handlers::view_image_spec::create_view_image_tool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; @@ -59,13 +60,12 @@ struct ViewImageArgs { #[derive(Clone, Copy, Eq, PartialEq)] enum ViewImageDetail { + High, Original, } #[async_trait::async_trait] impl ToolExecutor for ViewImageHandler { - type Output = ViewImageOutput; - fn tool_name(&self) -> ToolName { ToolName::plain("view_image") } @@ -78,7 +78,10 @@ impl ToolExecutor for ViewImageHandler { true } - async fn handle(&self, invocation: ToolInvocation) -> Result { + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { if !invocation .turn .model_info @@ -112,16 +115,15 @@ impl ToolExecutor for ViewImageHandler { environment_id, detail, } = parse_arguments(&arguments)?; - // `view_image` accepts only its documented detail values: omit - // `detail` for the default path or set it to `original`. - // Other string values remain invalid rather than being silently - // reinterpreted. + // `high` is the explicit spelling of the default resized path. + // Other string values remain invalid rather than being silently reinterpreted. let detail = match detail.as_deref() { None => None, + Some("high") => Some(ViewImageDetail::High), Some("original") => Some(ViewImageDetail::Original), Some(detail) => { return Err(FunctionCallError::RespondToModel(format!( - "view_image.detail only supports `original`; omit `detail` for default resized behavior, got `{detail}`" + "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `{detail}`" ))); } }; @@ -173,11 +175,11 @@ impl ToolExecutor for ViewImageHandler { } else { PromptImageMode::ResizeToFit }; - let image_detail = Some(if use_original_detail { + let image_detail = if use_original_detail { ImageDetail::Original } else { DEFAULT_IMAGE_DETAIL - }); + }; let image = load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| { @@ -195,18 +197,18 @@ impl ToolExecutor for ViewImageHandler { session.emit_turn_item_started(turn.as_ref(), &item).await; session.emit_turn_item_completed(turn.as_ref(), item).await; - Ok(ViewImageOutput { + Ok(boxed_tool_output(ViewImageOutput { image_url, image_detail, - }) + })) } } -impl ToolHandler for ViewImageHandler {} +impl CoreToolRuntime for ViewImageHandler {} pub struct ViewImageOutput { image_url: String, - image_detail: Option, + image_detail: ImageDetail, } impl ToolOutput for ViewImageOutput { @@ -222,7 +224,7 @@ impl ToolOutput for ViewImageOutput { let body = FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage { image_url: self.image_url.clone(), - detail: self.image_detail, + detail: Some(self.image_detail), }]); let output = FunctionCallOutputPayload { body, @@ -261,7 +263,7 @@ mod tests { fn code_mode_result_returns_image_url_object() { let output = ViewImageOutput { image_url: "data:image/png;base64,AAA".to_string(), - image_detail: Some(DEFAULT_IMAGE_DETAIL), + image_detail: DEFAULT_IMAGE_DETAIL, }; let result = output.code_mode_result(&ToolPayload::Function { @@ -315,4 +317,68 @@ mod tests { "{message}" ); } + + #[tokio::test] + async fn handle_rejects_unsupported_detail() { + let (session, turn) = make_session_and_context().await; + + let result = ViewImageHandler::default() + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-view-image".to_string(), + tool_name: codex_tools::ToolName::plain("view_image"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ "path": "image.png", "detail": "low" }).to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(message)) = result else { + panic!("expected unsupported detail error"); + }; + assert_eq!( + message, + "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn handle_accepts_explicit_high_detail() { + 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.environments + .turn_environments + .first_mut() + .expect("default local turn environment") + .cwd = image_cwd.clone(); + let image_path = image_cwd.join("image.png"); + std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); + turn.permission_profile = PermissionProfile::Disabled; + + let result = ViewImageHandler::default() + .handle(ToolInvocation { + session: Arc::new(session), + turn: Arc::new(turn), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-view-image".to_string(), + tool_name: codex_tools::ToolName::plain("view_image"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: json!({ "path": "image.png", "detail": "high" }).to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(message)) = result else { + panic!("expected image processing error"); + }; + assert!(message.contains("unable to process image"), "{message}"); + } } diff --git a/codex-rs/core/src/tools/handlers/view_image_spec.rs b/codex-rs/core/src/tools/handlers/view_image_spec.rs index 7d1422a037..662cd639a6 100644 --- a/codex-rs/core/src/tools/handlers/view_image_spec.rs +++ b/codex-rs/core/src/tools/handlers/view_image_spec.rs @@ -20,9 +20,12 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { if options.can_request_original_image_detail { properties.insert( "detail".to_string(), - JsonSchema::string(Some( - "Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(), - )), + JsonSchema::string_enum( + vec![json!("high"), json!("original")], + Some( + "Optional detail override. Supported values are `high` and `original`; omit this field for default high resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(), + ), + ), ); } if options.include_environment_id { @@ -55,8 +58,9 @@ fn view_image_output_schema() -> Value { "description": "Data URL for the loaded image." }, "detail": { - "type": ["string", "null"], - "description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`." + "type": "string", + "enum": ["high", "original"], + "description": "Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved." } }, "required": ["image_url", "detail"], diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 3073d9f9da..5cd943e199 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -11,9 +11,7 @@ pub(crate) mod registry; pub(crate) mod router; pub(crate) mod runtimes; pub(crate) mod sandboxing; -pub(crate) mod spec; pub(crate) mod spec_plan; -pub(crate) mod spec_plan_types; pub(crate) mod tool_dispatch_trace; pub(crate) mod tool_search_entry; @@ -48,6 +46,18 @@ pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { } } +pub(crate) fn tool_user_shell_type( + user_shell: &crate::shell::Shell, +) -> codex_tools::ToolUserShellType { + match user_shell.shell_type { + crate::shell::ShellType::Zsh => codex_tools::ToolUserShellType::Zsh, + crate::shell::ShellType::Bash => codex_tools::ToolUserShellType::Bash, + crate::shell::ShellType::PowerShell => codex_tools::ToolUserShellType::PowerShell, + crate::shell::ShellType::Sh => codex_tools::ToolUserShellType::Sh, + crate::shell::ShellType::Cmd => codex_tools::ToolUserShellType::Cmd, + } +} + /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. pub fn format_exec_output_for_model_structured( diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index a98d650688..25bcfe255d 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -4,7 +4,6 @@ use codex_network_proxy::BlockedRequestArgs; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use core_test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -189,10 +188,10 @@ fn only_never_policy_disables_network_approval_flow() { #[test] fn network_approval_flow_is_limited_to_restricted_sandbox_modes() { assert!(permission_profile_allows_network_approval_flow( - &PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()) + &PermissionProfile::read_only() )); assert!(permission_profile_allows_network_approval_flow( - &PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()) + &PermissionProfile::workspace_write() )); assert!(!permission_profile_allows_network_approval_flow( &PermissionProfile::Disabled diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 730c3b7ef9..1cf0e00623 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::future::Future; use std::sync::Arc; use std::time::Duration; @@ -35,7 +34,11 @@ pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; pub use codex_tools::ToolExecutor; pub use codex_tools::ToolExposure; -pub trait ToolHandler: ToolExecutor { +/// Typed runtime contract for locally executed tools. +/// +/// Implementers provide the shared `ToolExecutor` behavior plus optional +/// core-owned metadata for hooks, telemetry, tool search, and argument diffs. +pub(crate) trait CoreToolRuntime: ToolExecutor { fn search_info(&self) -> Option { None } @@ -47,17 +50,17 @@ pub trait ToolHandler: ToolExecutor { ) } - fn telemetry_tags( - &self, - _invocation: &ToolInvocation, - ) -> impl Future + Send { - async { Vec::new() } + fn telemetry_tags<'a>( + &'a self, + _invocation: &'a ToolInvocation, + ) -> BoxFuture<'a, ToolTelemetryTags> { + Box::pin(async { Vec::new() }) } fn post_tool_use_payload( &self, _invocation: &ToolInvocation, - _result: &Self::Output, + _result: &dyn ToolOutput, ) -> Option { None } @@ -154,117 +157,10 @@ pub(crate) struct PostToolUsePayload { pub(crate) tool_response: Value, } -/// 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; - - fn matches_kind(&self, payload: &ToolPayload) -> bool; - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option; - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: Value, - ) -> Result; - - fn telemetry_tags<'a>( - &'a self, - invocation: &'a ToolInvocation, - ) -> BoxFuture<'a, ToolTelemetryTags>; - - fn create_diff_consumer(&self) -> Option>; - fn handle_any<'a>( - &'a self, - invocation: ToolInvocation, - ) -> BoxFuture<'a, Result>; -} - -impl RegisteredTool for T -where - T: ToolHandler, -{ - fn tool_name(&self) -> ToolName { - ToolExecutor::tool_name(self) - } - - fn spec(&self) -> Option { - ToolExecutor::spec(self) - } - - fn exposure(&self) -> ToolExposure { - ToolExecutor::exposure(self) - } - - fn search_info(&self) -> Option { - ToolHandler::search_info(self) - } - - fn supports_parallel_tool_calls(&self) -> bool { - ToolExecutor::supports_parallel_tool_calls(self) - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - ToolHandler::matches_kind(self, payload) - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - ToolHandler::pre_tool_use_payload(self, invocation) - } - - fn with_updated_hook_input( - &self, - invocation: ToolInvocation, - updated_input: Value, - ) -> Result { - ToolHandler::with_updated_hook_input(self, invocation, updated_input) - } - - fn telemetry_tags<'a>( - &'a self, - invocation: &'a ToolInvocation, - ) -> BoxFuture<'a, ToolTelemetryTags> { - Box::pin(ToolHandler::telemetry_tags(self, invocation)) - } - - fn create_diff_consumer(&self) -> Option> { - ToolHandler::create_diff_consumer(self) - } - fn handle_any<'a>( - &'a self, - invocation: ToolInvocation, - ) -> BoxFuture<'a, Result> { - Box::pin(async move { - let call_id = invocation.call_id.clone(); - let payload = invocation.payload.clone(); - let output = ToolExecutor::handle(self, invocation.clone()).await?; - let post_tool_use_payload = - ToolHandler::post_tool_use_payload(self, &invocation, &output); - Ok(AnyToolResult { - call_id, - payload, - result: Box::new(output), - post_tool_use_payload, - }) - }) - } -} - pub(crate) fn override_tool_exposure( - handler: Arc, + handler: Arc, exposure: ToolExposure, -) -> Arc { +) -> Arc { if handler.exposure() == exposure { return handler; } @@ -273,11 +169,12 @@ pub(crate) fn override_tool_exposure( } struct ExposureOverride { - handler: Arc, + handler: Arc, exposure: ToolExposure, } -impl RegisteredTool for ExposureOverride { +#[async_trait::async_trait] +impl ToolExecutor for ExposureOverride { fn tool_name(&self) -> ToolName { self.handler.tool_name() } @@ -290,14 +187,23 @@ impl RegisteredTool for ExposureOverride { self.exposure } - fn search_info(&self) -> Option { - self.handler.search_info() - } - fn supports_parallel_tool_calls(&self) -> bool { self.handler.supports_parallel_tool_calls() } + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + self.handler.handle(invocation).await + } +} + +impl CoreToolRuntime for ExposureOverride { + fn search_info(&self) -> Option { + self.handler.search_info() + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { self.handler.matches_kind(payload) } @@ -306,6 +212,14 @@ impl RegisteredTool for ExposureOverride { self.handler.pre_tool_use_payload(invocation) } + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &dyn ToolOutput, + ) -> Option { + self.handler.post_tool_use_payload(invocation, result) + } + fn with_updated_hook_input( &self, invocation: ToolInvocation, @@ -325,22 +239,28 @@ impl RegisteredTool for ExposureOverride { 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>, + tools: HashMap>, } impl ToolRegistry { - fn new(handlers: HashMap>) -> Self { - Self { handlers } + fn new(tools: HashMap>) -> Self { + Self { tools } + } + + pub(crate) fn from_tools(tools: impl IntoIterator>) -> Self { + let mut tools_by_name = HashMap::new(); + for tool in tools { + let name = tool.tool_name(); + if tools_by_name.contains_key(&name) { + error_or_panic(format!("tool {name} already registered")); + continue; + } + tools_by_name.insert(name, tool); + } + Self::new(tools_by_name) } #[cfg(test)] @@ -351,35 +271,35 @@ impl ToolRegistry { #[cfg(test)] pub(crate) fn with_handler_for_test(handler: Arc) -> Self where - T: ToolHandler + 'static, + T: CoreToolRuntime + '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> { - self.handlers.get(name).map(Arc::clone) + fn tool(&self, name: &ToolName) -> Option> { + self.tools.get(name).map(Arc::clone) } pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option { - self.handlers.get(name).map(|handler| handler.exposure()) + self.tools.get(name).map(|tool| tool.exposure()) } #[cfg(test)] - pub(crate) fn has_handler(&self, name: &ToolName) -> bool { - self.handler(name).is_some() + pub(crate) fn has_tool(&self, name: &ToolName) -> bool { + self.tool(name).is_some() } pub(crate) fn create_diff_consumer( &self, name: &ToolName, ) -> Option> { - self.handler(name)?.create_diff_consumer() + self.tool(name)?.create_diff_consumer() } pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option { - let handler = self.handler(name)?; - Some(handler.supports_parallel_tool_calls()) + let tool = self.tool(name)?; + Some(tool.supports_parallel_tool_calls()) } #[expect( @@ -422,8 +342,8 @@ impl ToolRegistry { } let dispatch_trace = ToolDispatchTrace::start(&invocation); - let handler = match self.handler(&tool_name) { - Some(handler) => handler, + let tool = match self.tool(&tool_name) { + Some(tool) => tool, None => { let message = unsupported_tool_call_message(&invocation.payload, &tool_name); let log_payload = invocation.payload.log_payload(); @@ -443,7 +363,7 @@ impl ToolRegistry { } }; - let telemetry_tags = handler.telemetry_tags(&invocation).await; + let telemetry_tags = tool.telemetry_tags(&invocation).await; let mut tool_result_tags = Vec::with_capacity(base_tool_result_tags.len() + telemetry_tags.len()); let mut extra_trace_fields = Vec::new(); @@ -455,7 +375,7 @@ impl ToolRegistry { tool_result_tags.push((*key, value.as_str())); } } - if !handler.matches_kind(&invocation.payload) { + if !tool.matches_kind(&invocation.payload) { let message = format!("tool {tool_name} invoked with incompatible payload"); let log_payload = invocation.payload.log_payload(); otel.tool_result_with_tags( @@ -473,7 +393,7 @@ impl ToolRegistry { return Err(err); } - if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation) { + if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) { match run_pre_tool_use_hooks( &invocation.session, &invocation.turn, @@ -491,7 +411,7 @@ impl ToolRegistry { PreToolUseHookResult::Continue { updated_input: Some(updated_input), } => { - invocation = handler.with_updated_hook_input(invocation, updated_input)?; + invocation = tool.with_updated_hook_input(invocation, updated_input)?; } PreToolUseHookResult::Continue { updated_input: None, @@ -511,10 +431,10 @@ impl ToolRegistry { &tool_result_tags, &extra_trace_fields, || { - let handler = handler.clone(); + let tool = tool.clone(); let response_cell = &response_cell; async move { - match handler.handle_any(invocation_for_tool).await { + match handle_any_tool(tool.as_ref(), invocation_for_tool).await { Ok(result) => { let preview = result.result.log_preview(); let success = result.result.success_for_logging(); @@ -620,52 +540,21 @@ impl ToolRegistry { } } -pub struct ToolRegistryBuilder { - handlers: HashMap>, - specs: Vec, -} - -impl ToolRegistryBuilder { - pub fn new() -> Self { - Self { - handlers: HashMap::new(), - specs: Vec::new(), - } - } - - pub(crate) fn push_spec(&mut self, spec: ToolSpec) { - self.specs.push(spec); - } - - pub(crate) fn register_tool(&mut self, handler: Arc) { - self.register_tool_internal(handler, /*include_spec*/ true); - } - - pub(crate) fn register_tool_without_spec(&mut self, handler: Arc) { - self.register_tool_internal(handler, /*include_spec*/ false); - } - - 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 - && handler.exposure().is_direct() - && let Some(spec) = handler.spec() - { - self.push_spec(spec); - } - - self.handlers.insert(name, handler); - } - - pub fn build(self) -> (Vec, ToolRegistry) { - let registry = ToolRegistry::new(self.handlers); - (self.specs, registry) - } +async fn handle_any_tool( + tool: &dyn CoreToolRuntime, + invocation: ToolInvocation, +) -> Result { + let call_id = invocation.call_id.clone(); + let payload = invocation.payload.clone(); + let output = tool.handle(invocation.clone()).await?; + let post_tool_use_payload = + CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref()); + Ok(AnyToolResult { + call_id, + payload, + result: output, + post_tool_use_payload, + }) } fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String { diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index b6c8eadadf..defacf33c0 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -1,7 +1,4 @@ use super::*; -use crate::tools::handlers::GetGoalHandler; -use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_get_goal_tool; use pretty_assertions::assert_eq; struct TestHandler { @@ -10,21 +7,21 @@ struct TestHandler { #[async_trait::async_trait] impl ToolExecutor for TestHandler { - type Output = crate::tools::context::FunctionToolOutput; - fn tool_name(&self) -> codex_tools::ToolName { self.tool_name.clone() } - async fn handle(&self, _invocation: ToolInvocation) -> Result { - Ok(crate::tools::context::FunctionToolOutput::from_text( - "ok".to_string(), - Some(true), + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(Box::new( + crate::tools::context::FunctionToolOutput::from_text("ok".to_string(), Some(true)), )) } } -impl ToolHandler for TestHandler {} +impl CoreToolRuntime for TestHandler {} #[test] fn handler_looks_up_namespaced_aliases_explicitly() { @@ -34,18 +31,18 @@ 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)), ])); - let plain = registry.handler(&plain_name); - let namespaced = registry.handler(&namespaced_name); - let missing_namespaced = registry.handler(&codex_tools::ToolName::namespaced( + let plain = registry.tool(&plain_name); + let namespaced = registry.tool(&namespaced_name); + let missing_namespaced = registry.tool(&codex_tools::ToolName::namespaced( "mcp__codex_apps__calendar", tool_name, )); @@ -64,15 +61,3 @@ fn handler_looks_up_namespaced_aliases_explicitly() { .is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler)) ); } - -#[test] -fn register_tool_adds_executor_and_spec() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_tool(Arc::new(GetGoalHandler)); - - let (specs, registry) = builder.build(); - - assert_eq!(specs.len(), 1); - assert_eq!(specs[0], create_get_goal_tool()); - assert!(registry.has_handler(&codex_tools::ToolName::plain(GET_GOAL_TOOL_NAME))); -} diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 0f111922c6..2477ba347c 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -5,18 +5,16 @@ 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::collect_tool_router_parts; -use crate::tools::spec_plan::build_tool_registry_builder_from_executors; -use codex_extension_api::ExtensionToolExecutor; +use crate::tools::spec_plan::build_tool_router; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::ResponseItem; use codex_protocol::models::SearchToolCallParams; use codex_tools::DiscoverableTool; +use codex_tools::ToolCall as ExtensionToolCall; +use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; @@ -42,42 +40,16 @@ pub(crate) struct ToolRouterParams<'a> { pub(crate) mcp_tools: Option>, pub(crate) deferred_mcp_tools: Option>, pub(crate) discoverable_tools: Option>, - pub(crate) extension_tool_executors: Vec>, + pub(crate) extension_tool_executors: Vec>>, pub(crate) dynamic_tools: &'a [DynamicToolSpec], } impl ToolRouter { pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self { - let ToolRouterParams { - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - extension_tool_executors, - dynamic_tools, - } = params; - let parts = collect_tool_router_parts( - config, - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - &extension_tool_executors, - dynamic_tools, - ); - Self::from_executors(config, parts.executors, parts.hosted_specs) + build_tool_router(config, params) } - 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(); - + pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec) -> Self { Self { registry, model_visible_specs, @@ -182,22 +154,9 @@ 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> { +pub(crate) fn extension_tool_executors( + session: &Session, +) -> Vec>> { session .services .extensions diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index dd791d9135..c9bb4bcafc 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -7,7 +7,6 @@ use crate::turn_diff_tracker::TurnDiffTracker; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; @@ -37,7 +36,7 @@ impl codex_extension_api::ToolContributor for ExtensionEchoContributor { &self, _session_store: &ExtensionData, _thread_store: &ExtensionData, - ) -> Vec> { + ) -> Vec>> { vec![Arc::new(ExtensionEchoExecutor)] } } @@ -46,8 +45,6 @@ struct ExtensionEchoExecutor; #[async_trait::async_trait] impl ToolExecutor for ExtensionEchoExecutor { - type Output = codex_tools::JsonToolOutput; - fn tool_name(&self) -> ToolName { ToolName::namespaced("extension/", "echo") } @@ -78,14 +75,14 @@ impl ToolExecutor for ExtensionEchoExecutor { async fn handle( &self, call: ExtensionToolCall, - ) -> Result { + ) -> Result, codex_tools::FunctionCallError> { let arguments: serde_json::Value = serde_json::from_str(call.function_arguments()?).expect("test arguments should parse"); - Ok(codex_tools::JsonToolOutput::new(json!({ + Ok(Box::new(codex_tools::JsonToolOutput::new(json!({ "arguments": arguments, "callId": call.call_id, "ok": true, - }))) + })))) } } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 7c2aa5e8e5..e8a14083fc 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -28,7 +28,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianCommandSource; -use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; @@ -67,10 +66,6 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { }]) } -fn permission_profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - fn test_sandbox_cwd() -> AbsolutePathBuf { AbsolutePathBuf::try_from(host_absolute_path(&["workspace"])).unwrap() } @@ -429,9 +424,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul call_id: "execve-hook-call".to_string(), tool_name: GuardianCommandSource::Shell, approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -498,9 +491,7 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars ], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -553,9 +544,7 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() ], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -599,9 +588,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) &["git".to_string(), "status".to_string()], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, @@ -633,7 +620,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let program = AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(); let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; - let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + let permission_profile = PermissionProfile::workspace_write(); let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); let sandbox_cwd = test_sandbox_cwd(); @@ -643,7 +630,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( &argv, InterceptedExecPolicyContext { approval_policy, - permission_profile: permission_profile_from_sandbox_policy(&sandbox_policy), + permission_profile: permission_profile.clone(), file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: super::approval_sandbox_permissions( @@ -659,7 +646,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( &argv, InterceptedExecPolicyContext { approval_policy, - permission_profile: permission_profile_from_sandbox_policy(&sandbox_policy), + permission_profile, file_system_sandbox_policy: &file_system_sandbox_policy, sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, @@ -694,9 +681,7 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) &["git".to_string(), "status".to_string()], InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, - permission_profile: permission_profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), sandbox_cwd: sandbox_cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs deleted file mode 100644 index 64dfdb8316..0000000000 --- a/codex-rs/core/src/tools/spec.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; -use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; -use crate::shell::Shell; -use crate::shell::ShellType; -use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; -use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use crate::tools::registry::RegisteredTool; -use crate::tools::spec_plan::collect_tool_executors; -use crate::tools::spec_plan::hosted_model_tool_specs; -use crate::tools::spec_plan_types::ToolRegistryBuildParams; -use codex_extension_api::ExtensionToolExecutor; -use codex_mcp::ToolInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_tools::DiscoverableTool; -use codex_tools::ToolUserShellType; -use codex_tools::ToolsConfig; -use std::sync::Arc; - -pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { - match user_shell.shell_type { - ShellType::Zsh => ToolUserShellType::Zsh, - ShellType::Bash => ToolUserShellType::Bash, - ShellType::PowerShell => ToolUserShellType::PowerShell, - ShellType::Sh => ToolUserShellType::Sh, - ShellType::Cmd => ToolUserShellType::Cmd, - } -} - -pub(crate) struct ToolRouterParts { - pub(crate) executors: Vec>, - pub(crate) hosted_specs: Vec, -} - -pub(crate) fn collect_tool_router_parts( - config: &ToolsConfig, - mcp_tools: Option>, - deferred_mcp_tools: Option>, - discoverable_tools: Option>, - extension_tool_executors: &[Arc], - dynamic_tools: &[DynamicToolSpec], -) -> ToolRouterParts { - let default_agent_type_description = - crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); - let (min_wait_timeout_ms, max_wait_timeout_ms, default_wait_timeout_ms) = - if config.multi_agent_v2 { - let min_wait_timeout_ms = config - .wait_agent_min_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS); - let max_wait_timeout_ms = config - .wait_agent_max_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS); - let default_wait_timeout_ms = config - .wait_agent_default_timeout_ms - .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS); - ( - min_wait_timeout_ms, - max_wait_timeout_ms, - default_wait_timeout_ms, - ) - } else { - ( - MIN_WAIT_TIMEOUT_MS, - MAX_WAIT_TIMEOUT_MS, - DEFAULT_WAIT_TIMEOUT_MS, - ) - }; - let executors = collect_tool_executors( - config, - ToolRegistryBuildParams { - mcp_tools: mcp_tools.as_deref(), - deferred_mcp_tools: deferred_mcp_tools.as_deref(), - discoverable_tools: discoverable_tools.as_deref(), - extension_tool_executors, - dynamic_tools, - default_agent_type_description: &default_agent_type_description, - wait_agent_timeouts: WaitAgentTimeoutOptions { - default_timeout_ms: default_wait_timeout_ms, - min_timeout_ms: min_wait_timeout_ms, - max_timeout_ms: max_wait_timeout_ms, - }, - }, - ); - ToolRouterParts { - executors, - hosted_specs: hosted_model_tool_specs(config), - } -} - -#[cfg(test)] -#[path = "spec_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 24f59a0f04..be5e6741d4 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,3 +1,6 @@ +use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; +use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; +use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; @@ -24,13 +27,17 @@ use crate::tools::handlers::ViewImageHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; -use crate::tools::handlers::extension_tools::ExtensionToolHandler; +use crate::tools::handlers::extension_tools::ExtensionToolAdapter; use crate::tools::handlers::multi_agents::CloseAgentHandler; use crate::tools::handlers::multi_agents::ResumeAgentHandler; use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; +use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; +use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; +use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; @@ -41,17 +48,21 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::hosted_spec::WebSearchToolOptions; use crate::tools::hosted_spec::create_image_generation_tool; use crate::tools::hosted_spec::create_web_search_tool; -use crate::tools::registry::RegisteredTool; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExposure; -use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::registry::ToolRegistry; use crate::tools::registry::override_tool_exposure; -use crate::tools::spec_plan_types::ToolRegistryBuildParams; -use crate::tools::spec_plan_types::agent_type_description; -use codex_extension_api::ExtensionToolExecutor; +use crate::tools::router::ToolRouter; +use crate::tools::router::ToolRouterParams; +use codex_mcp::ToolInfo; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; +use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; +use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolEnvironmentMode; +use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::ToolsConfig; @@ -62,74 +73,94 @@ use std::collections::HashSet; use std::sync::Arc; use tracing::warn; -pub(crate) fn build_tool_registry_builder_from_executors( +#[derive(Clone, Copy)] +struct ToolRegistryBuildParams<'a> { + mcp_tools: Option<&'a [ToolInfo]>, + deferred_mcp_tools: Option<&'a [ToolInfo]>, + discoverable_tools: Option<&'a [DiscoverableTool]>, + extension_tool_executors: &'a [Arc>], + dynamic_tools: &'a [DynamicToolSpec], + default_agent_type_description: &'a str, + wait_agent_timeouts: WaitAgentTimeoutOptions, +} + +pub(crate) fn build_tool_router(config: &ToolsConfig, params: ToolRouterParams<'_>) -> ToolRouter { + let (model_visible_specs, registry) = build_tool_specs_and_registry(config, params); + ToolRouter::from_parts(registry, model_visible_specs) +} + +fn build_tool_specs_and_registry( config: &ToolsConfig, - executors: Vec>, - hosted_specs: Vec, -) -> ToolRegistryBuilder { - let mut builder = ToolRegistryBuilder::new(); - let deferred_tools_available = executors - .iter() - .any(|executor| executor.exposure() == ToolExposure::Deferred); - - for executor in build_code_mode_executors( + params: ToolRouterParams<'_>, +) -> (Vec, ToolRegistry) { + let ToolRouterParams { + mcp_tools, + deferred_mcp_tools, + discoverable_tools, + extension_tool_executors, + dynamic_tools, + } = params; + let default_agent_type_description = + crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); + let mut executors = collect_tool_executors( config, - &executors, - config.search_tool && deferred_tools_available, - ) { - builder.register_tool(executor); - } - - let mut non_deferred_specs = Vec::new(); - let mut deferred_search_infos = Vec::new(); - for executor in &executors { - match executor.exposure() { - ToolExposure::Direct | ToolExposure::DirectModelOnly => { - if let Some(spec) = executor.spec() { - non_deferred_specs.push((spec, executor.exposure())); - } - } - ToolExposure::Deferred => { - if let Some(search_info) = executor.search_info() { - deferred_search_infos.push(search_info); - } - } - } - } - - non_deferred_specs.extend( - hosted_specs - .into_iter() - .map(|spec| (spec, ToolExposure::Direct)), + ToolRegistryBuildParams { + mcp_tools: mcp_tools.as_deref(), + deferred_mcp_tools: deferred_mcp_tools.as_deref(), + discoverable_tools: discoverable_tools.as_deref(), + extension_tool_executors: &extension_tool_executors, + dynamic_tools, + default_agent_type_description: &default_agent_type_description, + wait_agent_timeouts: wait_agent_timeout_options(config), + }, ); + append_tool_search_executor(config, &mut executors); + prepend_code_mode_executors(config, &mut executors); + build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) +} - let non_deferred_specs = non_deferred_specs - .into_iter() - .map(|(spec, exposure)| { - if config.code_mode_enabled && exposure != ToolExposure::DirectModelOnly { - codex_tools::augment_tool_spec_for_code_mode(spec) - } else { - spec - } - }) - .collect(); - - for spec in merge_into_namespaces(non_deferred_specs) { - if !config.namespace_tools && matches!(spec, ToolSpec::Namespace(_)) { +fn build_model_visible_specs_and_registry( + config: &ToolsConfig, + executors: Vec>, + hosted_specs: Vec, +) -> (Vec, ToolRegistry) { + let mut specs = Vec::new(); + let mut seen_tool_names = HashSet::new(); + for executor in &executors { + if !seen_tool_names.insert(executor.tool_name()) { continue; } - builder.push_spec(spec); + if executor.exposure().is_direct() + && let Some(spec) = executor.spec() + { + specs.push(spec_for_model_request(config, executor.exposure(), spec)); + } } + specs.extend(hosted_specs); - for executor in executors { - builder.register_tool_without_spec(executor); + let registry = ToolRegistry::from_tools(executors); + let model_visible_specs = merge_into_namespaces(specs) + .into_iter() + .filter(|spec| config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_))) + .filter(|spec| !is_hidden_by_code_mode_only(config, ®istry, spec)) + .collect(); + + (model_visible_specs, registry) +} + +fn spec_for_model_request( + config: &ToolsConfig, + exposure: ToolExposure, + spec: ToolSpec, +) -> ToolSpec { + if config.code_mode_enabled + && exposure != ToolExposure::DirectModelOnly + && codex_code_mode::is_code_mode_nested_tool(spec.name()) + { + codex_tools::augment_tool_spec_for_code_mode(spec) + } else { + spec } - - if config.search_tool && config.namespace_tools && !deferred_search_infos.is_empty() { - builder.register_tool(Arc::new(ToolSearchHandler::new(deferred_search_infos))); - } - - builder } pub(crate) fn hosted_model_tool_specs(config: &ToolsConfig) -> Vec { @@ -147,11 +178,56 @@ pub(crate) fn hosted_model_tool_specs(config: &ToolsConfig) -> Vec { specs } +fn wait_agent_timeout_options(config: &ToolsConfig) -> WaitAgentTimeoutOptions { + if config.multi_agent_v2 { + return WaitAgentTimeoutOptions { + default_timeout_ms: config + .wait_agent_default_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS), + min_timeout_ms: config + .wait_agent_min_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS), + max_timeout_ms: config + .wait_agent_max_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS), + }; + } + + WaitAgentTimeoutOptions { + default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS, + min_timeout_ms: MIN_WAIT_TIMEOUT_MS, + max_timeout_ms: MAX_WAIT_TIMEOUT_MS, + } +} + +fn agent_type_description(config: &ToolsConfig, default_agent_type_description: &str) -> String { + if config.agent_type_description.is_empty() { + default_agent_type_description.to_string() + } else { + config.agent_type_description.clone() + } +} + +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 +} + fn build_code_mode_executors( config: &ToolsConfig, - executors: &[Arc], + executors: &[Arc], deferred_tools_available: bool, -) -> Vec> { +) -> Vec> { if !config.code_mode_enabled { return vec![]; } @@ -254,12 +330,12 @@ fn code_mode_namespace_descriptions( namespace_descriptions } -pub(crate) fn collect_tool_executors( +fn collect_tool_executors( config: &ToolsConfig, params: ToolRegistryBuildParams<'_>, -) -> Vec> { +) -> Vec> { let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; - let mut executors = Vec::>::new(); + let mut executors = Vec::>::new(); if config.environment_mode.has_environment() { let include_environment_id = @@ -444,10 +520,43 @@ pub(crate) fn collect_tool_executors( executors } +fn append_tool_search_executor( + config: &ToolsConfig, + executors: &mut Vec>, +) { + if !(config.search_tool && config.namespace_tools) { + return; + } + + let search_infos = executors + .iter() + .filter(|executor| executor.exposure() == ToolExposure::Deferred) + .filter_map(|executor| executor.search_info()) + .collect::>(); + if search_infos.is_empty() { + return; + } + + executors.push(Arc::new(ToolSearchHandler::new(search_infos))); +} + +fn prepend_code_mode_executors( + config: &ToolsConfig, + executors: &mut Vec>, +) { + let deferred_tools_available = config.search_tool + && executors + .iter() + .any(|executor| executor.exposure() == ToolExposure::Deferred); + let code_mode_executors = + build_code_mode_executors(config, executors, deferred_tools_available); + executors.splice(0..0, code_mode_executors); +} + fn append_extension_tool_executors( config: &ToolsConfig, - executors: &[Arc], - registered_executors: &mut Vec>, + executors: &[Arc>], + registered_executors: &mut Vec>, ) { if executors.is_empty() { return; @@ -473,17 +582,17 @@ fn append_extension_tool_executors( for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); if !reserved_tool_names.insert(tool_name.clone()) { - warn!("Skipping extension tool `{tool_name}`: handler already registered"); + warn!("Skipping extension tool `{tool_name}`: tool already registered"); continue; } - registered_executors.push(Arc::new(ExtensionToolHandler::new(executor))); + registered_executors.push(Arc::new(ExtensionToolAdapter::new(executor))); } } fn multi_agent_v2_handler( - handler: impl RegisteredTool + 'static, + handler: impl CoreToolRuntime + 'static, exposure: ToolExposure, -) -> Arc { +) -> Arc { override_tool_exposure(Arc::new(handler), exposure) } @@ -512,6 +621,9 @@ fn code_mode_namespace_name<'a>( .map(|namespace_description| namespace_description.name.as_str()) } +#[cfg(test)] +#[path = "spec_plan_model_tests.rs"] +mod model_tests; #[cfg(test)] #[path = "spec_plan_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_plan_model_tests.rs similarity index 87% rename from codex-rs/core/src/tools/spec_tests.rs rename to codex-rs/core/src/tools/spec_plan_model_tests.rs index ca8739ccff..dcc1be30ed 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_model_tests.rs @@ -3,17 +3,18 @@ use crate::shell::Shell; use crate::shell::ShellType; use crate::test_support::construct_model_info_offline; use crate::tools::ToolRouter; -use crate::tools::registry::ToolRegistryBuilder; use crate::tools::router::ToolRouterParams; -use crate::tools::spec_plan::build_tool_registry_builder_from_executors; +use crate::tools::tool_user_shell_type; use codex_app_server_protocol::AppInfo; use codex_features::Feature; use codex_features::Features; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::ToolInfo; use codex_models_manager::bundled_models_response; use codex_models_manager::model_info::with_config_overrides; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -40,8 +41,6 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; -use super::*; - fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { rmcp::model::Tool { name: name.to_string().into(), @@ -56,21 +55,6 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r } } -fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo { - ToolInfo { - server_name: "test_server".to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: tool.name.to_string(), - callable_namespace: "mcp__test_server__".to_string(), - namespace_description: None, - tool, - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - } -} - fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo { let (callable_namespace, callable_name) = display_name .rsplit_once('/') @@ -240,13 +224,12 @@ async fn multi_agent_v2_tools_config() -> ToolsConfig { } fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String { - let (tools, _) = build_specs( + let tools = build_specs( tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let spawn_agent = find_tool(&tools, "spawn_agent"); let ToolSpec::Function(ResponsesApiTool { description, .. }) = spawn_agent else { panic!("spawn_agent should be a function tool"); @@ -266,13 +249,13 @@ async fn model_info_from_models_json(slug: &str) -> ModelInfo { with_config_overrides(model, &config.to_models_manager_config()) } -/// Builds the tool registry builder while collecting tool specs for later serialization. +/// Builds tool specs and the matching registry from the same executor list. fn build_specs( config: &ToolsConfig, mcp_tools: Option>, deferred_mcp_tools: Option>, dynamic_tools: &[DynamicToolSpec], -) -> ToolRegistryBuilder { +) -> Vec { build_specs_with_inputs_for_test( config, mcp_tools, @@ -288,18 +271,22 @@ fn build_specs_with_inputs_for_test( mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, - extension_tool_executors: &[Arc], + extension_tool_executors: &[Arc< + dyn codex_extension_api::ToolExecutor, + >], dynamic_tools: &[DynamicToolSpec], -) -> ToolRegistryBuilder { - let parts = collect_tool_router_parts( +) -> Vec { + ToolRouter::from_config( config, - mcp_tools, - deferred_mcp_tools, - discoverable_tools, - extension_tool_executors, - dynamic_tools, - ); - build_tool_registry_builder_from_executors(config, parts.executors, parts.hosted_specs) + ToolRouterParams { + mcp_tools, + deferred_mcp_tools, + discoverable_tools, + extension_tool_executors: extension_tool_executors.to_vec(), + dynamic_tools, + }, + ) + .model_visible_specs() } #[tokio::test] @@ -319,13 +306,12 @@ async fn get_memory_requires_feature_flag() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); assert!( !tools.iter().any(|t| t.name() == "get_memory"), "get_memory should be disabled when memory_tool feature is off" @@ -633,13 +619,12 @@ async fn test_build_specs_default_shell_present() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(Vec::new()), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); // Only check the shell variant and a couple of core tools. let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; @@ -774,13 +759,12 @@ async fn multi_agent_v2_wait_agent_schema_uses_configured_timeouts() { .with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms) .with_wait_agent_max_timeout_ms(wait_agent_max_timeout_ms) .with_wait_agent_default_timeout_ms(wait_agent_default_timeout_ms); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let wait_agent = find_tool(&tools, "wait_agent"); let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = wait_agent else { panic!("wait_agent should be a function tool"); @@ -825,15 +809,14 @@ async fn request_plugin_install_requires_apps_and_plugins_features() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs_with_inputs_for_test( + let tools = build_specs_with_inputs_for_test( &tools_config, /*mcp_tools*/ None, /*deferred_mcp_tools*/ None, discoverable_tools.clone(), /*extension_tool_executors*/ &[], &[], - ) - .build(); + ); assert!( !tools @@ -862,13 +845,12 @@ async fn search_tool_is_hidden_without_deferred_tools() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, Some(Vec::new()), &[], - ) - .build(); + ); assert!( !tools .iter() @@ -894,7 +876,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, /*mcp_tools*/ None, Some(vec![ToolInfo { @@ -914,8 +896,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio plugin_display_names: Vec::new(), }]), &[], - ) - .build(); + ); let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME); let ToolSpec::ToolSearch { description, .. } = search_tool else { panic!("expected tool_search tool"); @@ -925,119 +906,6 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio assert!(!description.contains("- Calendar:")); } -#[tokio::test] -async fn search_tool_registers_namespaced_mcp_tool_aliases() { - let model_info = search_capable_model_info().await; - let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::ToolSearch); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (_, registry) = build_specs( - &tools_config, - /*mcp_tools*/ None, - Some(vec![ - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "_create_event".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - namespace_description: None, - tool: mcp_tool( - "calendar-create-event", - "Create calendar event", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }, - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "_list_events".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - namespace_description: None, - tool: mcp_tool( - "calendar-list-events", - "List calendar events", - serde_json::json!({"type": "object"}), - ), - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - plugin_display_names: Vec::new(), - }, - ToolInfo { - server_name: "rmcp".to_string(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name: "echo".to_string(), - callable_namespace: "mcp__rmcp__".to_string(), - namespace_description: None, - tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})), - connector_id: None, - connector_name: None, - plugin_display_names: Vec::new(), - }, - ]), - &[], - ) - .build(); - - let app_alias = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"); - let mcp_alias = ToolName::namespaced("mcp__rmcp__", "echo"); - - assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_handler(&app_alias)); - assert!(registry.has_handler(&mcp_alias)); -} - -#[tokio::test] -async fn direct_mcp_tools_register_namespaced_handlers() { - let config = test_config().await; - let model_info = construct_model_info_offline("gpt-5.4", &config); - let mut features = Features::with_defaults(); - features.enable(Feature::UnifiedExec); - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - permission_profile: &PermissionProfile::Disabled, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - - let (_, registry) = build_specs( - &tools_config, - Some(vec![mcp_tool_info(mcp_tool( - "echo", - "Echo", - serde_json::json!({"type": "object"}), - ))]), - /*deferred_mcp_tools*/ None, - &[], - ) - .build(); - - assert!(registry.has_handler(&ToolName::namespaced("mcp__test_server__", "echo"))); - assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo"))); -} - #[tokio::test] async fn test_mcp_tool_property_missing_type_defaults_to_string() { let config = test_config().await; @@ -1056,7 +924,7 @@ async fn test_mcp_tool_property_missing_type_defaults_to_string() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/search", @@ -1073,8 +941,7 @@ async fn test_mcp_tool_property_missing_type_defaults_to_string() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "search"); assert_eq!( @@ -1116,7 +983,7 @@ async fn test_mcp_tool_preserves_integer_schema() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/paginate", @@ -1131,8 +998,7 @@ async fn test_mcp_tool_preserves_integer_schema() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "paginate"); assert_eq!( @@ -1162,7 +1028,6 @@ async fn test_mcp_tool_array_without_items_gets_default_string_items() { let model_info = construct_model_info_offline("gpt-5.4", &config); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::ApplyPatchFreeform); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1175,7 +1040,7 @@ async fn test_mcp_tool_array_without_items_gets_default_string_items() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/tags", @@ -1190,8 +1055,7 @@ async fn test_mcp_tool_array_without_items_gets_default_string_items() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "tags"); assert_eq!( @@ -1236,7 +1100,7 @@ async fn test_mcp_tool_anyof_defaults_to_string() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "dash/value", @@ -1253,8 +1117,7 @@ async fn test_mcp_tool_anyof_defaults_to_string() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "dash/", "value"); assert_eq!( @@ -1301,7 +1164,7 @@ async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { permission_profile: &PermissionProfile::Disabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - let (tools, _) = build_specs( + let tools = build_specs( &tools_config, Some(vec![mcp_tool_info_with_display_name( "test_server/do_something_cool", @@ -1335,8 +1198,7 @@ async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { )]), /*deferred_mcp_tools*/ None, &[], - ) - .build(); + ); let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool"); assert_eq!( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index dd690b5631..333efe6f06 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -26,7 +26,6 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions; use crate::tools::handlers::view_image_spec::create_view_image_tool; use crate::tools::registry::ToolRegistry; use codex_app_server_protocol::AppInfo; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::ToolCall as ExtensionToolCall; use codex_extension_api::ToolExecutor; use codex_features::Feature; @@ -74,7 +73,10 @@ const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000; -fn extension_tool_executor(name: &str, description: &str) -> Arc { +fn extension_tool_executor( + name: &str, + description: &str, +) -> Arc> { struct SpecOnlyExtensionExecutor { name: String, description: String, @@ -82,8 +84,6 @@ fn extension_tool_executor(name: &str, description: &str) -> Arc for SpecOnlyExtensionExecutor { - type Output = codex_tools::JsonToolOutput; - fn tool_name(&self) -> ToolName { ToolName::plain(self.name.as_str()) } @@ -109,7 +109,7 @@ fn extension_tool_executor(name: &str, description: &str) -> Arc Result { + ) -> Result, codex_tools::FunctionCallError> { panic!("spec planning should not execute extension tools") } } @@ -711,13 +711,14 @@ fn view_image_tool_includes_detail_with_original_detail_support() { }; let (properties, _) = expect_object_schema(parameters); assert!(properties.contains_key("detail")); - let description = expect_string_description( - properties - .get("detail") - .expect("view_image detail should include a description"), - ); - assert!(description.contains("only supported value is `original`")); - assert!(description.contains("omit this field for default resized behavior")); + let detail_schema = properties + .get("detail") + .expect("view_image detail should include a description"); + let description = expect_string_description(detail_schema); + let expected = vec![json!("high"), json!("original")]; + assert_eq!(detail_schema.enum_values.as_ref(), Some(&expected)); + assert!(description.contains("Supported values are `high` and `original`")); + assert!(description.contains("omit this field for default high resized behavior")); } #[test] @@ -1440,7 +1441,7 @@ fn namespace_specs_are_hidden_when_namespace_tools_are_disabled() { ); assert_lacks_tool_name(&tools, "mcp__sample__"); - assert!(registry.has_handler(&ToolName::namespaced("mcp__sample__", "echo"))); + assert!(registry.has_tool(&ToolName::namespaced("mcp__sample__", "echo"))); } #[test] @@ -1646,11 +1647,11 @@ fn search_tool_description_lists_each_mcp_source_once() { assert!(description.contains("- rmcp: Remote memory tools.")); assert!(!description.contains("mcp__rmcp__echo")); - assert!(registry.has_handler(&ToolName::namespaced( + assert!(registry.has_tool(&ToolName::namespaced( "mcp__codex_apps__calendar", "_create_event", ))); - assert!(registry.has_handler(&ToolName::namespaced("mcp__rmcp__", "echo"))); + assert!(registry.has_tool(&ToolName::namespaced("mcp__rmcp__", "echo"))); } #[test] @@ -1758,7 +1759,7 @@ fn no_search_tool_when_namespaces_disabled() { ); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); - assert!(!registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); } #[test] @@ -1816,9 +1817,9 @@ fn search_tool_registers_for_deferred_dynamic_tools() { assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread.")); assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]); assert_lacks_tool_name(&tools, "codex_app"); - assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_list"))); + assert!(registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); + assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_list"))); } #[test] @@ -1865,9 +1866,9 @@ fn search_tool_is_hidden_for_deferred_dynamic_tools_when_namespace_tools_are_dis assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); assert_lacks_tool_name(&tools, "codex_app"); assert_lacks_tool_name(&tools, "plain_dynamic"); - assert!(!registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); - assert!(registry.has_handler(&ToolName::namespaced("codex_app", "automation_update"))); - assert!(registry.has_handler(&ToolName::plain("plain_dynamic"))); + assert!(!registry.has_tool(&ToolName::plain(TOOL_SEARCH_TOOL_NAME))); + assert!(registry.has_tool(&ToolName::namespaced("codex_app", "automation_update"))); + assert!(registry.has_tool(&ToolName::plain("plain_dynamic"))); } #[test] @@ -2007,7 +2008,7 @@ fn request_plugin_install_description_lists_discoverable_tools() { /*extension_tool_executors*/ &[], &[], ); - assert!(registry.has_handler(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); + assert!(registry.has_tool(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME))); let request_plugin_install = find_tool(&tools, REQUEST_PLUGIN_INSTALL_TOOL_NAME); let ToolSpec::Function(ResponsesApiTool { @@ -2109,18 +2110,19 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() { &[], ); - let ResponsesApiTool { description, .. } = - find_namespace_function_tool(&tools, "mcp__sample__", "echo"); + let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { + panic!("expected freeform tool"); + }; - assert_eq!( - description, - r#"Echo text + assert!(description.contains( + r#"### `mcp__sample__echo` +Echo text exec tool declaration: ```ts declare const tools: { mcp__sample__echo(args: { message: string; }): Promise; }; ```"# - ); + )); } #[test] @@ -2238,7 +2240,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() { assert_eq!( description, - "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`.\n detail: string | null;\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```" + "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```" ); } @@ -2413,7 +2415,7 @@ fn build_specs_with_inputs_for_test( mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, - extension_tool_executors: &[Arc], + extension_tool_executors: &[Arc>], dynamic_tools: &[DynamicToolSpec], ) -> (Vec, ToolRegistry) { let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| { @@ -2431,13 +2433,10 @@ fn build_specs_with_inputs_for_test( default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION, wait_agent_timeouts: wait_agent_timeout_options(), }; - let executors = collect_tool_executors(config, params); - let builder = build_tool_registry_builder_from_executors( - config, - executors, - hosted_model_tool_specs(config), - ); - builder.build() + let mut executors = collect_tool_executors(config, params); + append_tool_search_executor(config, &mut executors); + prepend_code_mode_executors(config, &mut executors); + build_model_visible_specs_and_registry(config, executors, hosted_model_tool_specs(config)) } fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool { @@ -2539,18 +2538,19 @@ fn code_mode_augments_mcp_tool_descriptions_with_structured_output_sample() { &[], ); - let ResponsesApiTool { description, .. } = - find_namespace_function_tool(&tools, "mcp__sample__", "echo"); + let ToolSpec::Freeform(FreeformTool { description, .. }) = find_tool(&tools, "exec") else { + panic!("expected freeform tool"); + }; - assert_eq!( - description, - r#"Echo text + assert!(description.contains( + r#"### `mcp__sample__echo` +Echo text exec tool declaration: ```ts declare const tools: { mcp__sample__echo(args: { message: string; }): Promise>; }; ```"# - ); + )); } fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool { diff --git a/codex-rs/core/src/tools/spec_plan_types.rs b/codex-rs/core/src/tools/spec_plan_types.rs deleted file mode 100644 index 247fa05715..0000000000 --- a/codex-rs/core/src/tools/spec_plan_types.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions; -use codex_extension_api::ExtensionToolExecutor; -use codex_mcp::ToolInfo; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_tools::DiscoverableTool; -use codex_tools::ToolsConfig; -use std::sync::Arc; - -#[derive(Clone, Copy)] -pub struct ToolRegistryBuildParams<'a> { - pub mcp_tools: Option<&'a [ToolInfo]>, - pub deferred_mcp_tools: Option<&'a [ToolInfo]>, - pub discoverable_tools: Option<&'a [DiscoverableTool]>, - pub extension_tool_executors: &'a [Arc], - pub dynamic_tools: &'a [DynamicToolSpec], - pub default_agent_type_description: &'a str, - pub wait_agent_timeouts: WaitAgentTimeoutOptions, -} - -pub(crate) fn agent_type_description( - config: &ToolsConfig, - default_agent_type_description: &str, -) -> String { - if config.agent_type_description.is_empty() { - default_agent_type_description.to_string() - } else { - config.agent_type_description.clone() - } -} diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index cb0eff9ca9..bccaf60c82 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -21,8 +21,8 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolRegistry; use crate::turn_diff_tracker::TurnDiffTracker; @@ -32,18 +32,22 @@ struct TestHandler { #[async_trait::async_trait] impl ToolExecutor for TestHandler { - type Output = FunctionToolOutput; - fn tool_name(&self) -> codex_tools::ToolName { self.tool_name.clone() } - async fn handle(&self, _invocation: ToolInvocation) -> Result { - Ok(FunctionToolOutput::from_text("ok".to_string(), Some(true))) + async fn handle( + &self, + _invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + Ok(Box::new(FunctionToolOutput::from_text( + "ok".to_string(), + Some(true), + ))) } } -impl ToolHandler for TestHandler {} +impl CoreToolRuntime for TestHandler {} #[tokio::test] async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 02760582f2..1b2c6b4b12 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use codex_utils_string::to_ascii_json_string; use serde::Serialize; @@ -23,6 +25,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; +const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; pub(crate) struct McpTurnMetadataContext<'a> { pub(crate) model: &'a str, @@ -186,6 +189,7 @@ pub(crate) struct TurnMetadataState { enriched_header: Arc>>, turn_started_at_unix_ms: Arc>>, responsesapi_client_metadata: Arc>>>, + user_input_requested_during_turn: Arc, enrichment_task: Arc>>>, } @@ -231,6 +235,7 @@ impl TurnMetadataState { enriched_header: Arc::new(RwLock::new(None)), turn_started_at_unix_ms: Arc::new(RwLock::new(None)), responsesapi_client_metadata: Arc::new(RwLock::new(None)), + user_input_requested_during_turn: Arc::new(AtomicBool::new(false)), enrichment_task: Arc::new(Mutex::new(None)), } } @@ -285,9 +290,25 @@ impl TurnMetadataState { metadata.remove(REASONING_EFFORT_KEY); } } + if self + .user_input_requested_during_turn + .load(Ordering::Relaxed) + { + metadata.insert( + USER_INPUT_REQUESTED_DURING_TURN_KEY.to_string(), + Value::Bool(true), + ); + } else { + metadata.remove(USER_INPUT_REQUESTED_DURING_TURN_KEY); + } Some(Value::Object(metadata)) } + pub(crate) fn mark_user_input_requested_during_turn(&self) { + self.user_input_requested_during_turn + .store(true, Ordering::Relaxed); + } + pub(crate) fn set_responsesapi_client_metadata( &self, responsesapi_client_metadata: HashMap, diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 2a38447f86..2f79927103 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -1,9 +1,8 @@ use super::*; -use crate::sandbox_tags::sandbox_tag; +use crate::sandbox_tags::permission_profile_sandbox_tag; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::ThreadSource; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -89,7 +88,6 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { fn turn_metadata_state_uses_platform_sandbox_tag() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); - let sandbox_policy = SandboxPolicy::new_read_only_policy(); let permission_profile = PermissionProfile::read_only(); let state = TurnMetadataState::new( @@ -110,7 +108,11 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); - let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); + let expected_sandbox = permission_profile_sandbox_tag( + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); assert_eq!(sandbox_name, Some(expected_sandbox)); assert_eq!(session_id, Some("session-a")); assert_eq!(thread_id, Some("thread-a")); @@ -213,6 +215,56 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( ); } +#[test] +fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_request_meta() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*thread_source*/ None, + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let header_json: Value = serde_json::from_str(&header).expect("json"); + assert!( + header_json + .get(USER_INPUT_REQUESTED_DURING_TURN_KEY) + .is_none() + ); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert!(meta.get(USER_INPUT_REQUESTED_DURING_TURN_KEY).is_none()); + + state.mark_user_input_requested_during_turn(); + + let header = state.current_header_value().expect("header"); + let header_json: Value = serde_json::from_str(&header).expect("json"); + assert!( + header_json + .get(USER_INPUT_REQUESTED_DURING_TURN_KEY) + .is_none() + ); + + let meta = state + .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) + .expect("turn metadata should be present"); + assert_eq!( + meta.get(USER_INPUT_REQUESTED_DURING_TURN_KEY) + .and_then(Value::as_bool), + Some(true) + ); +} + #[test] fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 3d35afae95..570e4a6e32 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -185,6 +185,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 97fb44ba11..631f9a9464 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -2,12 +2,9 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; -use codex_protocol::ThreadId; use rand::Rng; use tracing::error; -use codex_shell_command::parse_command::shlex_join; - const INITIAL_DELAY_MS: u64 = 200; const BACKOFF_FACTOR: f64 = 2.0; @@ -118,22 +115,6 @@ pub fn normalize_thread_name(name: &str) -> Option { } } -pub fn resume_command(thread_name: Option<&str>, thread_id: Option) -> Option { - let resume_target = thread_name - .filter(|name| !name.is_empty()) - .map(str::to_string) - .or_else(|| thread_id.map(|thread_id| thread_id.to_string())); - resume_target.map(|target| { - let needs_double_dash = target.starts_with('-'); - let escaped = shlex_join(&[target]); - if needs_double_dash { - format!("codex resume -- {escaped}") - } else { - format!("codex resume {escaped}") - } - }) -} - #[cfg(test)] #[path = "util_tests.rs"] mod tests; diff --git a/codex-rs/core/src/util_tests.rs b/codex-rs/core/src/util_tests.rs index 48dd6fc9a0..fdc7535159 100644 --- a/codex-rs/core/src/util_tests.rs +++ b/codex-rs/core/src/util_tests.rs @@ -432,41 +432,3 @@ fn normalize_thread_name_trims_and_rejects_empty() { Some("my thread".to_string()) ); } - -#[test] -fn resume_command_prefers_name_over_id() { - let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let command = resume_command(Some("my-thread"), Some(thread_id)); - assert_eq!(command, Some("codex resume my-thread".to_string())); -} - -#[test] -fn resume_command_with_only_id() { - let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap(); - let command = resume_command(/*thread_name*/ None, Some(thread_id)); - assert_eq!( - command, - Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string()) - ); -} - -#[test] -fn resume_command_with_no_name_or_id() { - let command = resume_command(/*thread_name*/ None, /*thread_id*/ None); - assert_eq!(command, None); -} - -#[test] -fn resume_command_quotes_thread_name_when_needed() { - let command = resume_command(Some("-starts-with-dash"), /*thread_id*/ None); - assert_eq!( - command, - Some("codex resume -- -starts-with-dash".to_string()) - ); - - let command = resume_command(Some("two words"), /*thread_id*/ None); - assert_eq!(command, Some("codex resume 'two words'".to_string())); - - let command = resume_command(Some("quote'case"), /*thread_id*/ None); - assert_eq!(command, Some("codex resume \"quote'case\"".to_string())); -} diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 757fc146f2..702f96ef2d 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -1,4 +1,10 @@ +use crate::test_codex::TestCodexBuilder; +use crate::test_codex::test_codex; use anyhow::Result; +use codex_core::config::Config; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_models_manager::bundled_models_response; use serde_json::Value; use serde_json::json; use wiremock::Mock; @@ -15,10 +21,21 @@ const CONNECTOR_NAME: &str = "Calendar"; const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44"; const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; +const CODEX_APPS_META_KEY: &str = "_codex_apps"; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; const SEARCHABLE_TOOL_COUNT: usize = 100; +const CALENDAR_CREATE_EVENT_TOOL_NAME: &str = "calendar_create_event"; +pub const CALENDAR_EXTRACT_TEXT_TOOL_NAME: &str = "calendar_extract_text"; +const CALENDAR_LIST_EVENTS_TOOL_NAME: &str = "calendar_list_events"; +pub const DIRECT_CALENDAR_CREATE_EVENT_TOOL: &str = "mcp__codex_apps__calendar_create_event"; +pub const DIRECT_CALENDAR_LIST_EVENTS_TOOL: &str = "mcp__codex_apps__calendar_list_events"; +pub const DIRECT_CALENDAR_EXTRACT_TEXT_TOOL: &str = "mcp__codex_apps__calendar_extract_text"; +pub const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; +pub const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event"; +pub const SEARCH_CALENDAR_EXTRACT_TEXT_TOOL: &str = "_extract_text"; +pub const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events"; pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str = "connector://calendar/tools/calendar_create_event"; pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str = @@ -71,6 +88,103 @@ impl AppsTestServer { } } +pub fn configure_search_capable_model(config: &mut Config) { + let mut model_catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("gpt-5.4 exists in bundled models.json"); + config.model = Some("gpt-5.4".to_string()); + model.supports_search_tool = true; + config.model_catalog = Some(model_catalog); +} + +fn configure_apps(config: &mut Config, apps_base_url: &str) { + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config.chatgpt_base_url = apps_base_url.to_string(); +} + +pub fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) { + configure_apps(config, apps_base_url); + configure_search_capable_model(config); +} + +pub fn apps_enabled_builder(apps_base_url: impl Into) -> TestCodexBuilder { + let apps_base_url = apps_base_url.into(); + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| configure_apps(config, apps_base_url.as_str())) +} + +pub fn search_capable_apps_builder(apps_base_url: impl Into) -> TestCodexBuilder { + let apps_base_url = apps_base_url.into(); + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| configure_search_capable_apps(config, apps_base_url.as_str())) +} + +fn apps_tool_call_id(body: &Value) -> Option<&str> { + body.get("params")? + .get("_meta")? + .get(CODEX_APPS_META_KEY)? + .get("call_id")? + .as_str() +} + +async fn recorded_apps_tool_calls(server: &MockServer) -> Vec { + server + .received_requests() + .await + .expect("mock server should capture requests") + .into_iter() + .filter_map(|request| { + let body: Value = serde_json::from_slice(&request.body).ok()?; + (request.url.path() == "/api/codex/apps" + && body.get("method").and_then(Value::as_str) == Some("tools/call")) + .then_some(body) + }) + .collect() +} + +pub async fn recorded_apps_tool_call_by_call_id(server: &MockServer, call_id: &str) -> Value { + let matches = recorded_apps_tool_calls(server) + .await + .into_iter() + .filter(|body| apps_tool_call_id(body) == Some(call_id)) + .collect::>(); + assert_eq!( + matches.len(), + 1, + "expected exactly one apps tools/call request for call_id {call_id}" + ); + matches + .into_iter() + .next() + .expect("matching apps tools/call request should be recorded") +} + +pub async fn recorded_apps_tool_call_by_name(server: &MockServer, tool_name: &str) -> Value { + let matches = recorded_apps_tool_calls(server) + .await + .into_iter() + .filter(|body| body.pointer("/params/name").and_then(Value::as_str) == Some(tool_name)) + .collect::>(); + assert_eq!( + matches.len(), + 1, + "expected exactly one apps tools/call request for tool {tool_name}" + ); + matches + .into_iter() + .next() + .expect("matching apps tools/call request should be recorded") +} + async fn mount_oauth_metadata(server: &MockServer) { Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server/mcp")) @@ -187,7 +301,7 @@ impl Respond for CodexAppsJsonRpcResponder { "result": { "tools": [ { - "name": "calendar_create_event", + "name": CALENDAR_CREATE_EVENT_TOOL_NAME, "description": "Create a calendar event.", "annotations": { "readOnlyHint": false, @@ -217,7 +331,7 @@ impl Respond for CodexAppsJsonRpcResponder { } }, { - "name": "calendar_list_events", + "name": CALENDAR_LIST_EVENTS_TOOL_NAME, "description": "List calendar events.", "annotations": { "readOnlyHint": true @@ -242,7 +356,7 @@ impl Respond for CodexAppsJsonRpcResponder { } }, { - "name": "calendar_extract_text", + "name": CALENDAR_EXTRACT_TEXT_TOOL_NAME, "description": "Extract text from an uploaded document.", "annotations": { "readOnlyHint": false diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 70e1a3f0e4..ad7858f804 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -237,54 +237,6 @@ pub fn find_codex_linux_sandbox_exe() -> Result { codex_utils_cargo_bin::cargo_bin("codex-linux-sandbox") } -/// Builds an SSE stream body from a JSON fixture. -/// -/// The fixture must contain an array of objects where each object represents a -/// single SSE event with at least a `type` field matching the `event:` value. -/// Additional fields become the JSON payload for the `data:` line. An object -/// with only a `type` field results in an event with no `data:` section. This -/// makes it trivial to extend the fixtures as OpenAI adds new event kinds or -/// fields. -pub fn load_sse_fixture(path: impl AsRef) -> String { - let events: Vec = - serde_json::from_reader(std::fs::File::open(path).expect("read fixture")) - .expect("parse JSON fixture"); - events - .into_iter() - .map(|e| { - let kind = e - .get("type") - .and_then(|v| v.as_str()) - .expect("fixture event missing type"); - if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { - format!("event: {kind}\n\n") - } else { - format!("event: {kind}\ndata: {e}\n\n") - } - }) - .collect() -} - -pub fn load_sse_fixture_with_id_from_str(raw: &str, id: &str) -> String { - let replaced = raw.replace("__ID__", id); - let events: Vec = - serde_json::from_str(&replaced).expect("parse JSON fixture"); - events - .into_iter() - .map(|e| { - let kind = e - .get("type") - .and_then(|v| v.as_str()) - .expect("fixture event missing type"); - if e.as_object().map(|o| o.len() == 1).unwrap_or(false) { - format!("event: {kind}\n\n") - } else { - format!("event: {kind}\ndata: {e}\n\n") - } - }) - .collect() -} - pub async fn wait_for_event( codex: &CodexThread, predicate: F, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index fd53e828a9..947111d3fc 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -24,7 +24,6 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use codex_extension_api::empty_extension_registry; -use codex_features::Feature; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; @@ -581,12 +580,6 @@ impl TestCodexBuilder { } ensure_test_model_catalog(&mut config)?; - if config.include_apply_patch_tool { - config.features.enable(Feature::ApplyPatchFreeform)?; - } else { - config.features.disable(Feature::ApplyPatchFreeform)?; - } - Ok((config, cwd)) } } diff --git a/codex-rs/core/tests/fixtures/incomplete_sse.json b/codex-rs/core/tests/fixtures/incomplete_sse.json deleted file mode 100644 index 2876bbfd29..0000000000 --- a/codex-rs/core/tests/fixtures/incomplete_sse.json +++ /dev/null @@ -1,3 +0,0 @@ -[ - {"type": "response.output_item.done"} -] diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index b692d32568..9185b8a7bd 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -59,9 +59,7 @@ pub async fn apply_patch_harness() -> Result { async fn apply_patch_harness_with( configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, ) -> Result { - let builder = configure(test_codex()).with_config(|config| { - config.include_apply_patch_tool = true; - }); + let builder = configure(test_codex()); // Box harness construction so apply_patch_cli tests do not inline the // full test-thread startup path into each test future. Box::pin(TestCodexHarness::with_remote_env_builder(builder)).await @@ -989,15 +987,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects( ) -> Result<()> { skip_if_no_network!(Ok(())); - let harness = apply_patch_harness_with(|builder| { - builder.with_config(|config| { - config - .features - .enable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); - }) - }) - .await?; + let harness = apply_patch_harness().await?; // Compose a patch that would create a file, then fail verification on an update. let call_id = "apply-partial-no-side-effects"; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 439eab5be2..5045755e32 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1408,7 +1408,7 @@ fn scenarios() -> Vec { content: "freeform-patch-danger", }, sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![Feature::ApplyPatchFreeform], + features: vec![], model_override: Some("gpt-5.4"), outcome: Outcome::Auto, expectation: Expectation::PatchApplied { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8a461edc63..42bacb9bdc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -52,6 +52,7 @@ use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_message_item_added; @@ -3016,22 +3017,15 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let server = MockServer::start().await; // Build a small SSE stream with deltas and a final assistant message. - // We emit the same body for all 3 turns; ids vary but are unused by assertions. - let sse_raw = r##"[ - {"type":"response.output_item.added", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":""}] - }}, - {"type":"response.output_text.delta", "delta":"Hey "}, - {"type":"response.output_text.delta", "delta":"there"}, - {"type":"response.output_text.delta", "delta":"!\n"}, - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":"Hey there!\n"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"##; - let sse1 = core_test_support::load_sse_fixture_with_id_from_str(sse_raw, "resp1"); + // We emit the same body for all 3 turns. + let sse1 = sse(vec![ + ev_message_item_added("msg-1", ""), + ev_output_text_delta("Hey "), + ev_output_text_delta("there"), + ev_output_text_delta("!\n"), + ev_assistant_message("msg-1", "Hey there!\n"), + ev_completed("resp1"), + ]); let request_log = mount_sse_sequence(&server, vec![sse1.clone(), sse1.clone(), sse1]).await; diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 030aecd29e..a14ee6518f 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -253,7 +253,7 @@ async fn responses_websocket_sends_response_processed_after_remote_compaction_v2 json!({ "type": "response.output_item.done", "item": { - "type": "context_compaction", + "type": "compaction", "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", } }), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index af63d092f8..f117631c4a 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -144,13 +144,11 @@ async fn run_code_mode_turn( server: &MockServer, prompt: &str, code: &str, - include_apply_patch: bool, ) -> Result<(TestCodex, ResponseMock)> { let mut builder = test_codex() .with_model("test-gpt-5.1-codex") .with_config(move |config| { let _ = config.features.enable(Feature::CodeMode); - config.include_apply_patch_tool = include_apply_patch; }); let test = builder.build(server).await?; @@ -291,7 +289,6 @@ async fn code_mode_can_return_exec_command_output() -> Result<()> { r#" text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marker" }))); "#, - /*include_apply_patch*/ false, ) .await?; @@ -541,7 +538,6 @@ const result = await tools.update_plan({ }); text(JSON.stringify(result)); "#, - /*include_apply_patch*/ false, ) .await?; @@ -666,7 +662,6 @@ text(JSON.stringify(await tools.exec_command({ max_output_tokens: 100 }))); "#, - /*include_apply_patch*/ false, ) .await?; @@ -705,7 +700,6 @@ text("before crash"); text("still before crash"); throw new Error("boom"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -752,7 +746,6 @@ try { text(`caught:${error?.message ?? String(error)}`); } "#, - /*include_apply_patch*/ false, ) .await?; @@ -1769,7 +1762,6 @@ async fn code_mode_can_output_serialized_text_via_global_helper() -> Result<()> r#" text({ json: true }); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1801,7 +1793,6 @@ async fn code_mode_can_resume_after_set_timeout() -> Result<()> { await new Promise((resolve) => setTimeout(resolve, 10)); text("timer done"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1830,7 +1821,6 @@ notify("code_mode_notify_marker"); await tools.test_sync_tool({}); text("done"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1868,7 +1858,6 @@ text("before"); exit(); text("after"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1907,7 +1896,6 @@ const circular = {}; circular.self = circular; text(circular); "#, - /*include_apply_patch*/ false, ) .await?; @@ -1947,7 +1935,6 @@ async fn code_mode_can_output_images_via_global_helper() -> Result<()> { image("https://example.com/image.jpg"); image("data:image/png;base64,AAA"); "#, - /*include_apply_patch*/ false, ) .await?; @@ -2137,13 +2124,8 @@ async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> { ); let code = format!("text(await tools.apply_patch({patch:?}));\n"); - let (test, second_mock) = run_code_mode_turn( - &server, - "use exec to run apply_patch", - &code, - /*include_apply_patch*/ true, - ) - .await?; + let (test, second_mock) = + run_code_mode_turn(&server, "use exec to run apply_patch", &code).await?; let req = second_mock.single_request(); let items = custom_tool_output_items(&req, "call-1"); @@ -2465,13 +2447,8 @@ const tool = ALL_TOOLS.find(({ name }) => name === "view_image"); text(JSON.stringify(tool)); "#; - let (_test, second_mock) = run_code_mode_turn( - &server, - "use exec to inspect ALL_TOOLS", - code, - /*include_apply_patch*/ false, - ) - .await?; + let (_test, second_mock) = + run_code_mode_turn(&server, "use exec to inspect ALL_TOOLS", code).await?; let req = second_mock.single_request(); let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); @@ -2489,7 +2466,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`.\n detail: string | null;\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); @@ -2890,7 +2867,6 @@ text(JSON.stringify({ waited_long_enough: end_ms - start_ms >= 100, })); "#, - /*include_apply_patch*/ false, ) .await?; diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index cc31aa5c77..f5db9856df 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -153,7 +153,6 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { .permissions .set_permission_profile(PermissionProfile::read_only()) .expect("set permission profile"); - config.include_apply_patch_tool = true; }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 72dbce67a9..5da56210cd 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3262,6 +3262,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess items: vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, }, UserInput::Text { text: "USER_THREE".to_string(), diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index dd61dde7d5..fa7aef23ed 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -556,7 +556,10 @@ async fn assert_remote_manual_compact_request_parity( .submit(Op::UserInput { environments: None, items: vec![ - UserInput::Image { image_url }, + UserInput::Image { + image_url, + detail: None, + }, UserInput::Text { text: "TURN_FOUR_IMAGE_USER".to_string(), text_elements: Vec::new(), @@ -697,7 +700,7 @@ async fn remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> { +async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -721,7 +724,7 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( serde_json::json!({ "type": "response.output_item.done", "item": { - "type": "context_compaction", + "type": "compaction", "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", } }), @@ -778,8 +781,8 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( assert_eq!(compact_request.path(), "/v1/responses"); let compact_body = compact_request.body_json().to_string(); assert!( - compact_body.contains("\"type\":\"context_compaction\""), - "expected v2 compaction request to include the context_compaction trigger item" + compact_body.contains("\"type\":\"compaction_trigger\""), + "expected v2 compaction request to include the compaction_trigger item" ); assert!( !compact_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"), @@ -789,12 +792,12 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( let follow_up_request = response_requests.last().expect("follow-up request missing"); let follow_up_body = follow_up_request.body_json().to_string(); assert!( - follow_up_body.contains("\"type\":\"context_compaction\""), - "expected follow-up request to preserve the v2 context_compaction item" + follow_up_body.contains("\"type\":\"compaction\""), + "expected follow-up request to preserve the compaction item" ); assert!( follow_up_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"), - "expected follow-up request to include the context compaction payload" + "expected follow-up request to include the compaction payload" ); assert!( follow_up_body.contains("hello remote compact"), @@ -805,8 +808,7 @@ async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_compact_v2_accepts_additional_output_items_before_context_compaction() -> Result<()> -{ +async fn remote_compact_v2_accepts_additional_output_items_before_compaction() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( @@ -831,7 +833,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_context_compac serde_json::json!({ "type": "response.output_item.done", "item": { - "type": "context_compaction", + "type": "compaction", "encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY", } }), @@ -878,12 +880,12 @@ async fn remote_compact_v2_accepts_additional_output_items_before_context_compac let follow_up_request = response_requests.last().expect("follow-up request missing"); let follow_up_body = follow_up_request.body_json().to_string(); assert!( - follow_up_body.contains("\"type\":\"context_compaction\""), - "expected follow-up request to preserve the v2 context_compaction item" + follow_up_body.contains("\"type\":\"compaction\""), + "expected follow-up request to preserve the compaction item" ); assert!( follow_up_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"), - "expected follow-up request to include the context compaction payload" + "expected follow-up request to include the compaction payload" ); assert!( !follow_up_body.contains("IGNORED_COMPACT_REPLY"), diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs new file mode 100644 index 0000000000..32c13b0b27 --- /dev/null +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -0,0 +1,1161 @@ +#![allow(clippy::expect_used)] + +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Result; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::user_input::UserInput; +use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::responses; +use core_test_support::responses::ResponseMock; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodexHarness; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +const FIXED_CWD: &str = "/tmp/codex_remote_compaction_parity_workspace"; +const IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +const SUMMARY: &str = "REMOTE_COMPACTION_PARITY_ENCRYPTED_SUMMARY"; +const DUMMY_FUNCTION_NAME: &str = "test_tool"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Mode { + Legacy, + V2, +} + +#[derive(Clone, Copy, Debug)] +enum AuthCase { + ChatGpt, + ApiKey, +} + +impl AuthCase { + fn build(self) -> CodexAuth { + match self { + AuthCase::ChatGpt => CodexAuth::create_dummy_chatgpt_auth_for_testing(), + AuthCase::ApiKey => CodexAuth::from_api_key("dummy"), + } + } +} + +#[derive(Clone, Copy, Debug)] +struct RunSettings { + auth: AuthCase, + service_tier_fast: bool, +} + +impl Default for RunSettings { + fn default() -> Self { + Self { + auth: AuthCase::ChatGpt, + service_tier_fast: false, + } + } +} + +#[derive(Clone, Copy, Debug)] +enum Step { + Assistant, + ReasoningAssistant, + FunctionTool, + ShellTool, + ImageAssistant, + WebSearchAssistant, +} + +impl Step { + fn label(self) -> &'static str { + match self { + Step::Assistant => "assistant", + Step::ReasoningAssistant => "reasoning_assistant", + Step::FunctionTool => "function_tool", + Step::ShellTool => "shell_tool", + Step::ImageAssistant => "image_assistant", + Step::WebSearchAssistant => "web_search_assistant", + } + } +} + +#[derive(Debug)] +struct Scenario { + name: &'static str, + steps: &'static [Step], +} + +#[derive(Debug)] +struct Capture { + compact_body: Value, + follow_up_body: Value, + replacement_history: Value, + normal_response_requests: usize, + compact_requests: usize, +} + +const ASSISTANT_ONLY: &[Step] = &[Step::Assistant]; +const REASONING_IMAGE: &[Step] = &[Step::ReasoningAssistant, Step::ImageAssistant]; +const TOOL_MIX: &[Step] = &[Step::Assistant, Step::FunctionTool, Step::ShellTool]; +const FULL_MIX: &[Step] = &[ + Step::ReasoningAssistant, + Step::FunctionTool, + Step::ImageAssistant, + Step::ShellTool, + Step::WebSearchAssistant, + Step::Assistant, +]; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_manual_transcripts() -> Result<()> { + skip_if_no_network!(Ok(())); + + let scenarios = [ + Scenario { + name: "assistant_only", + steps: ASSISTANT_ONLY, + }, + Scenario { + name: "reasoning_image", + steps: REASONING_IMAGE, + }, + Scenario { + name: "tool_mix", + steps: TOOL_MIX, + }, + Scenario { + name: "full_mix", + steps: FULL_MIX, + }, + ]; + + for scenario in scenarios { + compare_manual_scenario(&scenario, RunSettings::default()).await?; + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_v2_api_key_sends_service_tier_upgrade() -> Result<()> { + skip_if_no_network!(Ok(())); + + let scenario = Scenario { + name: "api_key_service_tier", + steps: TOOL_MIX, + }; + let settings = RunSettings { + auth: AuthCase::ApiKey, + service_tier_fast: true, + }; + let legacy = run_manual_session(&scenario, Mode::Legacy, settings).await?; + let v2 = run_manual_session(&scenario, Mode::V2, settings).await?; + + assert_eq!( + legacy.compact_body.get("service_tier"), + None, + "legacy /responses/compact should continue omitting service_tier for API-key auth" + ); + assert_eq!( + v2.compact_body.get("service_tier").and_then(Value::as_str), + Some(ServiceTier::Fast.request_value()), + "v2 compaction should send service_tier through /responses for API-key auth" + ); + + assert_compact_requests_eq_except_v2_service_tier("api-key service tier", &legacy, &v2); + assert_follow_up_and_history_eq("api-key service tier", &legacy, &v2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_manual_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_manual_hook_session(Mode::Legacy).await?; + let v2 = run_manual_hook_session(Mode::V2).await?; + assert_json_eq("manual compact hook payload parity mismatch", &legacy, &v2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_pre_turn_auto() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_pre_turn_auto_session(Mode::Legacy).await?; + let v2 = run_pre_turn_auto_session(Mode::V2).await?; + assert_capture_eq("pre-turn auto", &legacy, &v2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_compaction_parity_mid_turn_auto() -> Result<()> { + skip_if_no_network!(Ok(())); + + let legacy = run_mid_turn_auto_session(Mode::Legacy).await?; + let v2 = run_mid_turn_auto_session(Mode::V2).await?; + assert_capture_eq("mid-turn auto", &legacy, &v2); + Ok(()) +} + +async fn compare_manual_scenario(scenario: &Scenario, settings: RunSettings) -> Result<()> { + let legacy = run_manual_session(scenario, Mode::Legacy, settings).await?; + let v2 = run_manual_session(scenario, Mode::V2, settings).await?; + assert_capture_eq(scenario.name, &legacy, &v2); + Ok(()) +} + +fn assert_capture_eq(label: &str, legacy: &Capture, v2: &Capture) { + assert_eq!( + legacy.compact_requests, 1, + "legacy compact endpoint should be called exactly once for {label}", + ); + assert_eq!( + v2.compact_requests, 0, + "v2 should not call /responses/compact for {label}", + ); + + let legacy_compact = compact_request_view(&legacy.compact_body, Mode::Legacy); + let v2_compact = compact_request_view(&v2.compact_body, Mode::V2); + assert_json_eq( + &format!("compact request parity mismatch for {label}"), + &legacy_compact, + &v2_compact, + ); + + let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); + let v2_follow_up = follow_up_request_view(&v2.follow_up_body); + assert_json_eq( + &format!("post-compact follow-up request parity mismatch for {label}"), + &legacy_follow_up, + &v2_follow_up, + ); + + assert_json_eq( + &format!("replacement history parity mismatch for {label}"), + &legacy.replacement_history, + &v2.replacement_history, + ); + + println!( + "PARITY_OK scenario={} normal_response_requests={} compact_input_items={} replacement_history_items={} follow_up_input_items={}", + label, + legacy.normal_response_requests, + compact_input_len(&legacy.compact_body, Mode::Legacy), + replacement_history_len(&legacy.replacement_history), + follow_up_input_len(&legacy.follow_up_body) + ); +} + +fn assert_compact_requests_eq_except_v2_service_tier(label: &str, legacy: &Capture, v2: &Capture) { + assert_eq!( + legacy.compact_requests, 1, + "legacy compact endpoint should be called exactly once for {label}", + ); + assert_eq!( + v2.compact_requests, 0, + "v2 should not call /responses/compact for {label}", + ); + + let legacy_compact = compact_request_view(&legacy.compact_body, Mode::Legacy); + let mut v2_compact = compact_request_view(&v2.compact_body, Mode::V2); + remove_object_field(&mut v2_compact, "service_tier"); + assert_json_eq( + &format!("compact request parity mismatch for {label} after service_tier upgrade"), + &legacy_compact, + &v2_compact, + ); +} + +fn assert_follow_up_and_history_eq(label: &str, legacy: &Capture, v2: &Capture) { + let legacy_follow_up = follow_up_request_view(&legacy.follow_up_body); + let v2_follow_up = follow_up_request_view(&v2.follow_up_body); + assert_json_eq( + &format!("post-compact follow-up request parity mismatch for {label}"), + &legacy_follow_up, + &v2_follow_up, + ); + + assert_json_eq( + &format!("replacement history parity mismatch for {label}"), + &legacy.replacement_history, + &v2.replacement_history, + ); +} + +async fn run_manual_session( + scenario: &Scenario, + mode: Mode, + settings: RunSettings, +) -> Result { + let mut response_bodies = response_bodies_for_scenario(scenario); + if mode == Mode::V2 { + response_bodies.push(compaction_v2_response_body()); + } + response_bodies.push(after_compact_response_body(scenario.name)); + + let harness = build_harness(mode, settings, /*hooks*/ false).await?; + let rollout_path = rollout_path(&harness); + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + for (idx, step) in scenario.steps.iter().enumerate() { + submit_user_input(&codex, user_input_for_step(scenario.name, idx, *step)).await?; + } + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: format!("{}_AFTER_COMPACT_USER", scenario.name), + text_elements: Vec::new(), + }], + ) + .await?; + + capture_from_requests( + mode, + &codex, + &rollout_path, + &responses_mock, + compact_mock.as_ref(), + follow_up_index(responses_mock.requests().len()), + ) + .await +} + +async fn run_pre_turn_auto_session(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![ + responses::sse(vec![ + responses::ev_assistant_message("pre-turn-first-message", "PRE_TURN_FIRST_REPLY"), + responses::ev_completed_with_tokens( + "pre-turn-first-response", + /*total_tokens*/ 500, + ), + ]), + after_compact_response_body("pre_turn_auto"), + ], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_assistant_message("pre-turn-first-message", "PRE_TURN_FIRST_REPLY"), + responses::ev_completed_with_tokens( + "pre-turn-first-response", + /*total_tokens*/ 500, + ), + ]), + compaction_v2_response_body(), + after_compact_response_body("pre_turn_auto"), + ], + }; + let harness = build_auto_harness(mode).await?; + let rollout_path = rollout_path(&harness); + let codex = harness.test().codex.clone(); + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "pre_turn_auto_before".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + submit_user_input( + &codex, + vec![UserInput::Text { + text: "pre_turn_auto_after".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + + capture_from_requests( + mode, + &codex, + &rollout_path, + &responses_mock, + compact_mock.as_ref(), + follow_up_index(responses_mock.requests().len()), + ) + .await +} + +async fn run_mid_turn_auto_session(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![ + responses::sse(vec![ + responses::ev_function_call("mid-turn-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens( + "mid-turn-call-response", + /*total_tokens*/ 500, + ), + ]), + after_compact_response_body("mid_turn_auto"), + ], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_function_call("mid-turn-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens( + "mid-turn-call-response", + /*total_tokens*/ 500, + ), + ]), + compaction_v2_response_body(), + after_compact_response_body("mid_turn_auto"), + ], + }; + let harness = build_auto_harness(mode).await?; + let rollout_path = rollout_path(&harness); + let codex = harness.test().codex.clone(); + let responses_mock = responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "mid_turn_auto_user".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + + capture_from_requests( + mode, + &codex, + &rollout_path, + &responses_mock, + compact_mock.as_ref(), + follow_up_index(responses_mock.requests().len()), + ) + .await +} + +async fn run_manual_hook_session(mode: Mode) -> Result { + let response_bodies = match mode { + Mode::Legacy => vec![responses::sse(vec![ + responses::ev_assistant_message("hook-first-message", "HOOK_FIRST_REPLY"), + responses::ev_completed("hook-first-response"), + ])], + Mode::V2 => vec![ + responses::sse(vec![ + responses::ev_assistant_message("hook-first-message", "HOOK_FIRST_REPLY"), + responses::ev_completed("hook-first-response"), + ]), + compaction_v2_response_body(), + ], + }; + let harness = build_harness(mode, RunSettings::default(), /*hooks*/ true).await?; + let codex = harness.test().codex.clone(); + responses::mount_sse_sequence(harness.server(), response_bodies).await; + let compact_mock = mount_legacy_compact_if_needed(&harness, mode).await; + + submit_user_input( + &codex, + vec![UserInput::Text { + text: "manual_hooks_before".to_string(), + text_elements: Vec::new(), + }], + ) + .await?; + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + if let Some(compact_mock) = compact_mock { + assert_eq!(compact_mock.requests().len(), 1); + } + + let home = harness.test().codex_home_path(); + let pre = hook_log_view(&home.join("pre_compact_manual_log.jsonl"))?; + let post = hook_log_view(&home.join("post_compact_manual_log.jsonl"))?; + Ok(json!({ + "pre": pre, + "post": post, + })) +} + +async fn build_auto_harness(mode: Mode) -> Result { + build_harness_inner( + mode, + RunSettings::default(), + /*hooks*/ false, + Some(200), + ) + .await +} + +async fn build_harness(mode: Mode, settings: RunSettings, hooks: bool) -> Result { + build_harness_inner(mode, settings, hooks, /*auto_compact_limit*/ None).await +} + +async fn build_harness_inner( + mode: Mode, + settings: RunSettings, + hooks: bool, + auto_compact_limit: Option, +) -> Result { + fs::create_dir_all(FIXED_CWD)?; + let mut builder = test_codex().with_auth(settings.auth.build()); + if hooks { + builder = builder.with_pre_build_hook(write_manual_compact_hooks); + } + TestCodexHarness::with_builder(builder.with_config(move |config| { + config.cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(PathBuf::from( + FIXED_CWD, + )) + .expect("fixed cwd should be absolute"); + config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string()); + config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); + if settings.service_tier_fast { + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + } + config.model_auto_compact_token_limit = auto_compact_limit; + if hooks { + trust_discovered_hooks(config); + } + if mode == Mode::V2 { + let _ = config.features.enable(Feature::RemoteCompactionV2); + } + })) + .await +} + +fn rollout_path(harness: &TestCodexHarness) -> PathBuf { + harness + .test() + .session_configured + .rollout_path + .clone() + .expect("rollout path") +} + +async fn mount_legacy_compact_if_needed( + harness: &TestCodexHarness, + mode: Mode, +) -> Option { + match mode { + Mode::Legacy => Some( + responses::mount_compact_user_history_with_summary_once(harness.server(), SUMMARY) + .await, + ), + Mode::V2 => None, + } +} + +fn follow_up_index(request_count: usize) -> usize { + request_count.checked_sub(1).expect("follow-up request") +} + +async fn capture_from_requests( + mode: Mode, + codex: &codex_core::CodexThread, + rollout_path: &Path, + responses_mock: &ResponseMock, + compact_mock: Option<&ResponseMock>, + follow_up_index: usize, +) -> Result { + let response_requests = responses_mock.requests(); + let follow_up_body = response_requests + .get(follow_up_index) + .expect("follow-up request should be present") + .body_json(); + + let (compact_body, compact_requests) = match (mode, compact_mock) { + (Mode::Legacy, Some(compact_mock)) => { + let compact_requests = compact_mock.requests().len(); + (compact_mock.single_request().body_json(), compact_requests) + } + (Mode::V2, None) => { + let compact_index = follow_up_index + .checked_sub(1) + .expect("v2 compact request should precede follow-up"); + (response_requests[compact_index].body_json(), 0) + } + (Mode::Legacy, None) | (Mode::V2, Some(_)) => panic!("unexpected compact mock state"), + }; + + codex.submit(Op::Shutdown).await?; + wait_for_event(codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; + + Ok(Capture { + compact_body, + follow_up_body, + replacement_history: replacement_history_from_rollout(rollout_path)?, + normal_response_requests: response_requests.len(), + compact_requests, + }) +} + +async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec) -> Result<()> { + codex + .submit(Op::UserInput { + environments: None, + items, + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(codex).await; + Ok(()) +} + +async fn wait_for_turn_complete(codex: &codex_core::CodexThread) { + wait_for_event(codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; +} + +fn user_input_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec { + let mut items = Vec::new(); + if matches!(step, Step::ImageAssistant) { + items.push(UserInput::Image { + image_url: IMAGE_URL.to_string(), + detail: None, + }); + } + items.push(UserInput::Text { + text: format!("{}_USER_TURN_{}_{}", scenario_name, idx, step.label()), + text_elements: Vec::new(), + }); + items +} + +fn response_bodies_for_scenario(scenario: &Scenario) -> Vec { + scenario + .steps + .iter() + .enumerate() + .flat_map(|(idx, step)| response_bodies_for_step(scenario.name, idx, *step)) + .collect() +} + +fn response_bodies_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec { + let response_id = format!("{scenario_name}-{idx}-{}", step.label()); + match step { + Step::Assistant => vec![responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} assistant reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + Step::ReasoningAssistant => vec![responses::sse(vec![ + responses::ev_reasoning_item( + &format!("{response_id}-reasoning"), + &["PARITY_REASONING_SUMMARY"], + &["parity raw reasoning content"], + ), + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} reasoning reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + Step::FunctionTool => vec![ + responses::sse(vec![ + responses::ev_function_call( + &format!("{response_id}-call"), + DUMMY_FUNCTION_NAME, + r#"{"case":"parity"}"#, + ), + responses::ev_completed(&format!("{response_id}-tool-response")), + ]), + responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-final-message"), + &format!("{scenario_name} function follow-up {idx}"), + ), + responses::ev_completed(&format!("{response_id}-final-response")), + ]), + ], + Step::ShellTool => vec![ + responses::sse(vec![ + responses::ev_shell_command_call( + &format!("{response_id}-shell-call"), + &format!("echo {scenario_name}_{idx}_SHELL_TOOL"), + ), + responses::ev_completed(&format!("{response_id}-shell-response")), + ]), + responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-final-message"), + &format!("{scenario_name} shell follow-up {idx}"), + ), + responses::ev_completed(&format!("{response_id}-final-response")), + ]), + ], + Step::ImageAssistant => vec![responses::sse(vec![ + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} image reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + Step::WebSearchAssistant => vec![responses::sse(vec![ + responses::ev_web_search_call_done( + &format!("{response_id}-web-search"), + "completed", + &format!("{scenario_name} parity query"), + ), + responses::ev_assistant_message( + &format!("{response_id}-message"), + &format!("{scenario_name} web search reply {idx}"), + ), + responses::ev_completed(&format!("{response_id}-response")), + ])], + } +} + +fn compaction_v2_response_body() -> String { + responses::sse(vec![ + json!({ + "type": "response.output_item.done", + "item": { + "type": "compaction", + "encrypted_content": SUMMARY, + } + }), + responses::ev_completed("remote-compaction-v2-response"), + ]) +} + +fn after_compact_response_body(scenario_name: &str) -> String { + responses::sse(vec![ + responses::ev_assistant_message( + &format!("{scenario_name}-after-compact-message"), + &format!("{scenario_name} after compact reply"), + ), + responses::ev_completed(&format!("{scenario_name}-after-compact-response")), + ]) +} + +fn compact_request_view(body: &Value, mode: Mode) -> Value { + let mut input = body + .get("input") + .and_then(Value::as_array) + .cloned() + .expect("compact request should include input"); + if mode == Mode::V2 { + let trigger = input + .pop() + .expect("v2 compact input should end with trigger"); + assert_eq!( + trigger, + json!({"type": "compaction_trigger"}), + "v2 compact input should append exactly one compaction_trigger" + ); + } + + let mut selected = selected_request_fields(body, SelectedFieldsMode::Compact); + selected["input"] = normalize_value(Value::Array(input)); + canonical_json(&normalize_value(selected)) +} + +fn follow_up_request_view(body: &Value) -> Value { + let mut selected = selected_request_fields(body, SelectedFieldsMode::FollowUp); + selected["input"] = normalize_value( + body.get("input") + .cloned() + .expect("follow-up request should include input"), + ); + canonical_json(&normalize_value(selected)) +} + +fn replacement_history_from_rollout(path: &Path) -> Result { + let rollout_text = fs::read_to_string(path)?; + let mut replacement_history = None; + for line in rollout_text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + { + let Ok(entry) = serde_json::from_str::(line) else { + continue; + }; + if let RolloutItem::Compacted(compacted) = entry.item + && compacted.message.is_empty() + && let Some(items) = compacted.replacement_history + { + let values = items + .into_iter() + .map(|item| serde_json::to_value(item).expect("serialize replacement item")) + .collect::>(); + replacement_history = Some(Value::Array(values)); + } + } + let replacement_history = + replacement_history.expect("expected compacted rollout replacement history"); + Ok(canonical_json(&normalize_value(replacement_history))) +} + +fn write_manual_compact_hooks(home: &Path) { + write_hook_script( + &home.join("pre_compact_manual.py"), + &home.join("pre_compact_manual_log.jsonl"), + ); + write_hook_script( + &home.join("post_compact_manual.py"), + &home.join("post_compact_manual_log.jsonl"), + ); + let hooks = json!({ + "hooks": { + "PreCompact": [{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": python_hook_command(&home.join("pre_compact_manual.py")), + }] + }], + "PostCompact": [{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": python_hook_command(&home.join("post_compact_manual.py")), + }] + }] + } + }); + fs::write(home.join("hooks.json"), hooks.to_string()).expect("write hooks.json"); +} + +fn write_hook_script(script_path: &Path, log_path: &Path) { + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, sort_keys=True) + "\n") +"#, + log_path = log_path.display(), + ); + fs::write(script_path, script).expect("write compact hook script"); +} + +fn python_hook_command(script_path: &Path) -> String { + format!("python3 \"{}\"", script_path.display()) +} + +fn hook_log_view(path: &Path) -> Result { + let text = fs::read_to_string(path)?; + let values = text + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let payload: Value = serde_json::from_str(line).expect("parse compact hook payload"); + json!({ + "hook_event_name": payload["hook_event_name"].clone(), + "trigger": payload["trigger"].clone(), + "model": payload["model"].clone(), + "has_reason": payload.get("reason").is_some(), + "has_phase": payload.get("phase").is_some(), + "has_implementation": payload.get("implementation").is_some(), + "has_status": payload.get("status").is_some(), + "has_error": payload.get("error").is_some(), + }) + }) + .collect::>(); + Ok(Value::Array(values)) +} + +#[derive(Clone, Copy)] +enum SelectedFieldsMode { + Compact, + FollowUp, +} + +fn selected_request_fields(body: &Value, mode: SelectedFieldsMode) -> Value { + let mut selected = serde_json::Map::new(); + let fields: &[&str] = match mode { + SelectedFieldsMode::Compact => &[ + "model", + "instructions", + "parallel_tool_calls", + "reasoning", + "service_tier", + "prompt_cache_key", + "text", + "tools", + "previous_response_id", + ], + SelectedFieldsMode::FollowUp => &[ + "model", + "instructions", + "parallel_tool_calls", + "reasoning", + "service_tier", + "prompt_cache_key", + "text", + "tools", + "tool_choice", + "previous_response_id", + "store", + "stream", + "include", + ], + }; + for field in fields { + if let Some(value) = body.get(field) { + selected.insert((*field).to_string(), normalize_value(value.clone())); + } + } + Value::Object(selected) +} + +fn normalize_value(value: Value) -> Value { + match value { + Value::String(text) => Value::String(normalize_string(&text)), + Value::Array(values) => Value::Array(values.into_iter().map(normalize_value).collect()), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, value)| (key, normalize_value(value))) + .collect(), + ), + Value::Null | Value::Bool(_) | Value::Number(_) => value, + } +} + +fn normalize_string(value: &str) -> String { + if is_uuid_like(value) { + return "".to_string(); + } + + let mut text = value.to_string(); + normalize_tmp_prefix_before_marker(&mut text, "/skills/"); + normalize_tmp_prefix_before_marker(&mut text, "\\skills\\"); + + let mut search_start = 0; + let wall_time_prefix = "Wall time: "; + let wall_time_suffix = " seconds"; + while let Some(relative_start) = text[search_start..].find(wall_time_prefix) { + let value_start = search_start + relative_start + wall_time_prefix.len(); + let Some(relative_end) = text[value_start..].find(wall_time_suffix) else { + break; + }; + let value_end = value_start + relative_end; + let value = &text[value_start..value_end]; + if !value.is_empty() + && value.chars().any(|ch| ch.is_ascii_digit()) + && value.chars().all(|ch| ch.is_ascii_digit() || ch == '.') + { + text.replace_range(value_start..value_end, ""); + search_start = value_start + "".len() + wall_time_suffix.len(); + } else { + search_start = value_end + wall_time_suffix.len(); + } + } + text +} + +fn is_uuid_like(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && [8, 13, 18, 23].iter().all(|idx| bytes[*idx] == b'-') + && bytes + .iter() + .enumerate() + .all(|(idx, byte)| [8, 13, 18, 23].contains(&idx) || byte.is_ascii_hexdigit()) +} + +fn normalize_tmp_prefix_before_marker(text: &mut String, marker: &str) { + let mut search_start = 0; + while let Some(relative_marker_index) = text[search_start..].find(marker) { + let marker_index = search_start + relative_marker_index; + let prefix = &text[..marker_index]; + let windows_appdata_temp_start = prefix + .rfind("/AppData/Local/Temp/.tmp") + .and_then(|temp_index| prefix[..temp_index].rfind(":/Users/")) + .and_then(|colon_index| colon_index.checked_sub(1)) + .or_else(|| { + prefix + .rfind("\\AppData\\Local\\Temp\\.tmp") + .and_then(|temp_index| prefix[..temp_index].rfind(":\\Users\\")) + .and_then(|colon_index| colon_index.checked_sub(1)) + }); + let start = prefix + .rfind("/private/var/folders/") + .or_else(|| prefix.rfind("/var/folders/")) + .or_else(|| prefix.rfind("/private/tmp/.tmp")) + .or_else(|| prefix.rfind("/tmp/.tmp")) + .or(windows_appdata_temp_start); + if let Some(start_index) = start { + text.replace_range(start_index..marker_index, ""); + search_start = start_index + "".len() + marker.len(); + } else { + search_start = marker_index + marker.len(); + } + } +} + +#[test] +fn normalize_string_rewrites_linux_temp_skill_paths() { + let text = normalize_string( + "file: /tmp/.tmp5YYdK3/skills/.system/imagegen/SKILL.md and \ + /private/tmp/.tmpw3wqF9/skills/custom/SKILL.md", + ); + + assert_eq!( + text, + "file: /skills/.system/imagegen/SKILL.md and \ + /skills/custom/SKILL.md" + ); +} + +#[test] +fn normalize_string_rewrites_windows_temp_skill_paths() { + let text = normalize_string( + "file: C:/Users/runneradmin/AppData/Local/Temp/.tmpDuYxa3/skills/.system/imagegen/SKILL.md and \ + C:\\Users\\runneradmin\\AppData\\Local\\Temp\\.tmpiP36Yr\\skills\\custom\\SKILL.md", + ); + + assert_eq!( + text, + "file: /skills/.system/imagegen/SKILL.md and \ + \\skills\\custom\\SKILL.md" + ); +} + +#[test] +fn normalize_string_rewrites_shell_wall_times() { + let text = normalize_string( + "Exit code: 0\nWall time: 0 seconds\nOutput:\nok\n\ + Exit code: 0\nWall time: 0.1 seconds\nOutput:\nok", + ); + + assert_eq!( + text, + "Exit code: 0\nWall time: seconds\nOutput:\nok\n\ + Exit code: 0\nWall time: seconds\nOutput:\nok" + ); +} + +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(), + } +} + +fn remove_object_field(value: &mut Value, field: &str) { + if let Value::Object(map) = value { + map.remove(field); + } +} + +fn assert_json_eq(label: &str, left: &Value, right: &Value) { + if left != right { + panic!("{}\n{}", label, first_json_diff(left, right, "$")); + } +} + +fn first_json_diff(left: &Value, right: &Value, path: &str) -> String { + match (left, right) { + (Value::Object(left_map), Value::Object(right_map)) => { + let mut keys = left_map.keys().chain(right_map.keys()).collect::>(); + keys.sort(); + keys.dedup(); + for key in keys { + match (left_map.get(key), right_map.get(key)) { + (Some(left_value), Some(right_value)) if left_value != right_value => { + return first_json_diff(left_value, right_value, &format!("{path}.{key}")); + } + (None, Some(right_value)) => { + return format!( + "{path}.{key}: missing on left, right={}", + short_json(right_value) + ); + } + (Some(left_value), None) => { + return format!( + "{path}.{key}: left={}, missing on right", + short_json(left_value) + ); + } + (Some(_), Some(_)) | (None, None) => {} + } + } + format!("{path}: object mismatch") + } + (Value::Array(left_values), Value::Array(right_values)) => { + let len = left_values.len().min(right_values.len()); + for idx in 0..len { + if left_values[idx] != right_values[idx] { + return first_json_diff( + &left_values[idx], + &right_values[idx], + &format!("{path}[{idx}]"), + ); + } + } + if left_values.len() != right_values.len() { + return format!( + "{path}: array len left={} right={}", + left_values.len(), + right_values.len() + ); + } + format!("{path}: array mismatch") + } + _ => format!( + "{path}: left={} right={}", + short_json(left), + short_json(right) + ), + } +} + +fn short_json(value: &Value) -> String { + let text = serde_json::to_string(value).expect("serialize short json value"); + const MAX_LEN: usize = 1000; + if text.len() <= MAX_LEN { + text + } else { + let prefix = text.chars().take(MAX_LEN).collect::(); + format!("{prefix}...<{} chars>", text.len()) + } +} + +fn compact_input_len(body: &Value, mode: Mode) -> usize { + let len = body + .get("input") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or_default(); + match mode { + Mode::Legacy => len, + Mode::V2 => len.saturating_sub(1), + } +} + +fn follow_up_input_len(body: &Value) -> usize { + body.get("input") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or_default() +} + +fn replacement_history_len(body: &Value) -> usize { + body.as_array().map(Vec::len).unwrap_or_default() +} diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 00f9e63b9c..0250add6d4 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -30,6 +30,7 @@ use core_test_support::responses::ResponseMock; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -535,7 +536,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { ev_assistant_message("m2", "turn 2 assistant"), ev_completed("r2"), ]), - sse(vec![ev_completed("r3")]), + sse(vec![ev_response_created("r3"), ev_completed("r3")]), ], ) .await; diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 36b5fe9d5d..c41ff47f2a 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -1,23 +1,16 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; -use codex_app_server_protocol::ConfigLayerSource; -use codex_config::ConfigLayerEntry; -use codex_config::ConfigLayerStack; -use codex_config::ConfigRequirements; -use codex_config::ConfigRequirementsToml; use codex_features::Feature; use codex_protocol::protocol::DeprecationNoticeEvent; use codex_protocol::protocol::EventMsg; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::test_absolute_path; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use std::collections::BTreeMap; -use toml::Value as TomlValue; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()> { @@ -60,62 +53,6 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<() Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - - let mut builder = test_codex().with_config(|config| { - let mut table = toml::map::Map::new(); - table.insert( - "experimental_instructions_file".to_string(), - TomlValue::String("legacy.md".to_string()), - ); - let config_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { - file: test_absolute_path("/tmp/config.toml"), - profile: None, - }, - TomlValue::Table(table), - ); - let config_layer_stack = ConfigLayerStack::new( - vec![config_layer], - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - ) - .expect("build config layer stack"); - config.config_layer_stack = config_layer_stack; - }); - - let TestCodex { codex, .. } = builder.build(&server).await?; - - let notice = wait_for_event_match(&codex, |event| match event { - EventMsg::DeprecationNotice(ev) - if ev.summary.contains("experimental_instructions_file") => - { - Some(ev.clone()) - } - _ => None, - }) - .await; - - let DeprecationNoticeEvent { summary, details } = notice; - assert_eq!( - summary, - "`experimental_instructions_file` is deprecated and ignored. Use `model_instructions_file` instead." - .to_string(), - ); - assert_eq!( - details.as_deref(), - Some( - "Move the setting to `model_instructions_file` in config.toml (or under a profile) to load instructions from a file." - ), - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_deprecation_notice_for_web_search_feature_flag_values() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index f059998052..53649afff8 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1658,7 +1658,6 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -2880,7 +2879,6 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> { } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -2959,7 +2957,6 @@ async fn pre_tool_use_rewrites_apply_patch_before_execution() -> Result<()> { } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3025,7 +3022,6 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> { } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3627,7 +3623,6 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<() } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -3715,7 +3710,6 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<( } }) .with_config(|config| { - config.include_apply_patch_tool = true; trust_discovered_hooks(config); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index f819e1cfdf..3404edbb1e 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -118,6 +118,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu items: vec![ UserInput::LocalImage { path: abs_path.clone(), + detail: None, }, UserInput::Text { text: "pasted image".to_string(), @@ -208,6 +209,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> items: vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, }, UserInput::Text { text: "dropped image".to_string(), diff --git a/codex-rs/core/tests/suite/live_cli.rs b/codex-rs/core/tests/suite/live_cli.rs index c5c26a1c8e..5e2c0415ea 100644 --- a/codex-rs/core/tests/suite/live_cli.rs +++ b/codex-rs/core/tests/suite/live_cli.rs @@ -23,6 +23,9 @@ fn run_live(prompt: &str) -> (assert_cmd::assert::Assert, TempDir) { use std::thread; let dir = TempDir::new().unwrap(); + let home = TempDir::new().unwrap(); + let codex_home = home.path().join(".codex"); + std::fs::create_dir_all(&codex_home).unwrap(); // Build a plain `std::process::Command` so we have full control over the underlying stdio // handles. `assert_cmd`’s own `Command` wrapper always forces stdout/stderr to be piped @@ -33,6 +36,8 @@ fn run_live(prompt: &str) -> (assert_cmd::assert::Assert, TempDir) { let mut cmd = Command::new(codex_utils_cargo_bin::cargo_bin("codex-rs").unwrap()); cmd.current_dir(dir.path()); cmd.env("OPENAI_API_KEY", require_api_key()); + cmd.env("HOME", home.path()); + cmd.env("CODEX_HOME", &codex_home); // We want three things at once: // 1. live streaming of the child’s stdout/stderr while the test is running diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs new file mode 100644 index 0000000000..17fe33dc8b --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -0,0 +1,312 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_config::types::AppToolApproval; +use codex_core::config::Config; +use codex_features::Feature; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ElicitationAction; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::PathExt; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::HashMap; + +fn set_calendar_approval_mode(config: &mut Config, approval_mode: AppToolApproval) { + let approval_mode = match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }; + let user_config_path = config.codex_home.join("config.toml").abs(); + let user_config = toml::from_str(&format!( + r#" +[apps.calendar] +default_tools_approval_mode = "{approval_mode}" +"# + )) + .expect("apps config should parse"); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&user_config_path, user_config); +} + +async fn submit_user_turn( + test: &TestCodex, + text: &str, + approval_policy: AskForApproval, + collaboration_mode: Option, +) -> Result<()> { + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.cwd.path()); + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + approvals_reviewer: None, + sandbox_policy, + permission_profile, + model: test.session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode, + personality: None, + }) + .await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let call_id = "calendar-call-approval"; + let calendar_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &calendar_args, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config + .features + .enable(Feature::ToolCallMcpElicitation) + .expect("test config should allow feature update"); + set_calendar_approval_mode(config, AppToolApproval::Prompt); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Use [$calendar](app://calendar) to create a calendar event.", + AskForApproval::OnRequest, + /*collaboration_mode*/ None, + ) + .await?; + + let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallBegin(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.call_id, call_id); + + let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::ElicitationRequest(_)) + }) + .await + else { + unreachable!("event guard guarantees ElicitationRequest"); + }; + + test.codex + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(mock.requests().len(), 2); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, call_id).await; + + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/user_input_requested_during_turn"), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_tool_call_metadata_records_prior_request_user_input_tool() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let request_user_input_call_id = "user-input-call"; + let calendar_call_id = "calendar-call-after-user-input"; + let request_user_input_args = json!({ + "questions": [{ + "id": "confirm_path", + "header": "Confirm", + "question": "Proceed with the plan?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the current plan." + }, { + "label": "No", + "description": "Stop and revisit the approach." + }] + }] + }) + .to_string(); + let calendar_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + request_user_input_call_id, + "request_user_input", + &request_user_input_args, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call_with_namespace( + calendar_call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &calendar_args, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + set_calendar_approval_mode(config, AppToolApproval::Approve); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Ask for confirmation, then create a calendar event.", + AskForApproval::Never, + Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ) + .await?; + + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.call_id, request_user_input_call_id); + + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "confirm_path".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + + let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallBegin(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.call_id, calendar_call_id); + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(mock.requests().len(), 3); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, calendar_call_id).await; + + assert_eq!( + apps_tool_call + .pointer("/params/_meta/x-codex-turn-metadata/user_input_requested_during_turn"), + Some(&json!(true)) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 2b5caf5a52..87772aa279 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -43,6 +43,7 @@ mod codex_delegate; mod collaboration_instructions; mod compact; mod compact_remote; +mod compact_remote_parity; mod compact_resume_fork; mod deprecation_notice; mod exec; @@ -57,6 +58,7 @@ mod image_rollout; mod items; mod json_result; mod live_cli; +mod mcp_turn_metadata; mod model_overrides; mod model_switching; mod model_visible_layout; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 14f744d4f8..b0df30213f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -420,6 +420,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, }, UserInput::Text { text: "first turn".to_string(), diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index 0f0dcf46f1..a25cc36a86 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -5,13 +5,16 @@ use std::path::Path; use anyhow::Context; use anyhow::Result; -use codex_core::config::Config; -use codex_features::Feature; -use codex_login::CodexAuth; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::CALENDAR_EXTRACT_TEXT_TOOL_NAME; +use core_test_support::apps_test_server::DIRECT_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_HOOK_MATCHER; use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI; +use core_test_support::apps_test_server::SEARCH_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE as DOCUMENT_EXTRACT_NAMESPACE; +use core_test_support::apps_test_server::apps_enabled_builder; +use core_test_support::apps_test_server::recorded_apps_tool_call_by_name; use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -20,7 +23,6 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -31,17 +33,6 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; -const DOCUMENT_EXTRACT_NAMESPACE: &str = "mcp__codex_apps__calendar"; -const DOCUMENT_EXTRACT_TOOL: &str = "_extract_text"; -const DOCUMENT_EXTRACT_HOOK_MATCHER: &str = "mcp__codex_apps__calendar_extract_text"; - -fn configure_apps(config: &mut Config, chatgpt_base_url: &str) { - if let Err(err) = config.features.enable(Feature::Apps) { - panic!("test config should allow feature update: {err}"); - } - config.chatgpt_base_url = chatgpt_base_url.to_string(); -} - fn write_post_tool_use_hook(home: &Path) -> Result<()> { let script_path = home.join("post_tool_use_hook.py"); let log_path = home.join("post_tool_use_hook_log.jsonl"); @@ -154,15 +145,13 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res ) .await; - let mut builder = test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) .with_pre_build_hook(move |home| { if let Err(error) = write_post_tool_use_hook(home) { panic!("failed to write apps file post tool use hook fixture: {error}"); } }) .with_config(move |config| { - configure_apps(config, apps_server.chatgpt_base_url.as_str()); trust_discovered_hooks(config); }); let test = builder.build(&server).await?; @@ -192,20 +181,8 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res })) ); - let apps_tool_call = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find_map(|request| { - let body: Value = serde_json::from_slice(&request.body).ok()?; - (request.url.path() == "/api/codex/apps" - && body.get("method").and_then(Value::as_str) == Some("tools/call") - && body.pointer("/params/name").and_then(Value::as_str) - == Some("calendar_extract_text")) - .then_some(body) - }) - .expect("apps calendar_extract_text tools/call request should be recorded"); + let apps_tool_call = + recorded_apps_tool_call_by_name(&server, CALENDAR_EXTRACT_TEXT_TOOL_NAME).await; assert_eq!( apps_tool_call.pointer("/params/arguments/file"), diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index eb6d663cac..b0bc6b251b 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -502,8 +502,9 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .await; let writable = TempDir::new()?; let writable_root = AbsolutePathBuf::try_from(writable.path())?; + let writable_root_for_config = writable_root.clone(); let permission_profile = PermissionProfile::workspace_write_with( - &[writable_root], + std::slice::from_ref(&writable_root), NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ false, /*exclude_slash_tmp*/ false, @@ -515,6 +516,9 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { .permissions .set_permission_profile(permission_profile) .expect("test permission profile should be allowed"); + let workspace_roots = vec![config.cwd.clone(), writable_root_for_config]; + config.workspace_roots = workspace_roots.clone(); + config.permissions.set_workspace_roots(workspace_roots); config.config_layer_stack = ConfigLayerStack::default(); }); let test = builder.build(&server).await?; @@ -535,9 +539,9 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let permissions = permissions_texts(&req.single_request()); let normalize_line_endings = |s: &str| s.replace("\r\n", "\n"); let exec_policy = load_exec_policy(&test.config.config_layer_stack).await?; - let sandbox_policy = test.config.legacy_sandbox_policy(); - let expected = PermissionsInstructions::from_policy( - &sandbox_policy, + let permission_profile = test.config.permissions.effective_permission_profile(); + let expected = PermissionsInstructions::from_permission_profile( + &permission_profile, AskForApproval::OnRequest, test.config.approvals_reviewer, &exec_policy, diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 0b89a9cfba..5151d4e55d 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -88,6 +88,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 288b989df4..18286ba524 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -231,10 +231,6 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.user_instructions = Some("be consistent and helpful".to_string()); - config - .features - .disable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 9c3414baeb..e19379ffc8 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -347,9 +347,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< }; let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; - }); + let mut builder = test_codex(); let test = builder.build_with_remote_and_local_env(&server).await?; let local_cwd = TempDir::new()?; let file_name = "apply_patch_remote_freeform.txt"; @@ -435,7 +433,6 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); config.approvals_reviewer = ApprovalsReviewer::User; }); diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 455c1fabb9..0463ea3e2b 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -7,6 +7,8 @@ use codex_features::Feature; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::AdditionalPermissionProfile as PermissionProfile; use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; @@ -292,6 +294,15 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { } } +fn workspace_write_excluding_tmp_profile() -> CorePermissionProfile { + CorePermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ) +} + fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { RequestPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( @@ -320,13 +331,14 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -417,13 +429,14 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio mcp_elicitations: true, }); let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::RequestPermissionsTool) @@ -502,13 +515,14 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -605,13 +619,14 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -707,13 +722,14 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = CorePermissionProfile::read_only(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -808,13 +824,14 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -914,13 +931,14 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1021,13 +1039,14 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1147,13 +1166,14 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1267,13 +1287,14 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1381,13 +1402,14 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::RequestPermissionsTool) @@ -1495,13 +1517,14 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1661,13 +1684,14 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) @@ -1776,13 +1800,14 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = workspace_write_excluding_tmp(); - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = workspace_write_excluding_tmp_profile(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config - .set_legacy_sandbox_policy(sandbox_policy_for_config) - .expect("set sandbox policy"); + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); config .features .enable(Feature::ExecPermissionApprovals) diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 30b887ab08..cb13243966 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -340,7 +340,6 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu let permission_profile_for_config = permission_profile.clone(); let mut builder = test_codex().with_config(move |config| { - config.include_apply_patch_tool = true; config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .permissions diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index cb545df351..ac916ba66d 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -64,6 +64,7 @@ fn resume_history( images: None, local_images: vec![], text_elements: vec![], + ..Default::default() })), RolloutItem::TurnContext(turn_ctx), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 70eaf7335c..0ace177f60 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -18,7 +18,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; -use core_test_support::load_sse_fixture_with_id_from_str; +use core_test_support::responses; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::start_mock_server; @@ -61,17 +61,11 @@ async fn review_op_emits_lifecycle_and_review_output() { "overall_confidence_score": 0.8 }) .to_string(); - let sse_template = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":__REVIEW__}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let review_json_escaped = serde_json::to_string(&review_json).unwrap(); - let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped); - let (server, _request_log) = - start_responses_server_with_sse(&sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + assistant_message_sse(&review_json), + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -186,15 +180,11 @@ async fn review_op_emits_lifecycle_and_review_output() { async fn review_op_with_plain_text_emits_review_fallback() { skip_if_no_network!(); - let sse_raw = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":"just plain text"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let (server, _request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + assistant_message_sse("just plain text"), + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -240,22 +230,17 @@ async fn review_op_with_plain_text_emits_review_fallback() { async fn review_filters_agent_message_related_events() { skip_if_no_network!(); - // Stream simulating a typing assistant message with deltas and finalization. - let sse_raw = r#"[ - {"type":"response.output_item.added", "item":{ - "type":"message", "role":"assistant", "id":"msg-1", - "content":[{"type":"output_text","text":""}] - }}, - {"type":"response.output_text.delta", "delta":"Hi"}, - {"type":"response.output_text.delta", "delta":" there"}, - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", "id":"msg-1", - "content":[{"type":"output_text","text":"Hi there"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let (server, _request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + vec![ + responses::ev_message_item_added("msg-1", ""), + responses::ev_output_text_delta("Hi"), + responses::ev_output_text_delta(" there"), + responses::ev_assistant_message("msg-1", "Hi there"), + responses::ev_completed("resp-1"), + ], + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -325,17 +310,11 @@ async fn review_does_not_emit_agent_message_on_structured_output() { "overall_confidence_score": 0.5 }) .to_string(); - let sse_template = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":__REVIEW__}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let review_json_escaped = serde_json::to_string(&review_json).unwrap(); - let sse_raw = sse_template.replace("__REVIEW__", &review_json_escaped); - let (server, _request_log) = - start_responses_server_with_sse(&sse_raw, /*expected_requests*/ 1).await; + let (server, _request_log) = start_responses_server_with_sse( + assistant_message_sse(&review_json), + /*expected_requests*/ 1, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -386,12 +365,8 @@ async fn review_does_not_emit_agent_message_on_structured_output() { async fn review_uses_custom_review_model_from_config() { skip_if_no_network!(); - // Minimal stream: just a completed event - let sse_raw = r#"[ - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); // Choose a review model different from the main model; ensure it is used. let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { @@ -441,12 +416,8 @@ async fn review_uses_custom_review_model_from_config() { async fn review_uses_session_model_when_review_model_unset() { skip_if_no_network!(); - // Minimal stream: just a completed event - let sse_raw = r#"[ - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |cfg| { cfg.model = Some("gpt-4.1".to_string()); @@ -496,12 +467,8 @@ async fn review_uses_session_model_when_review_model_unset() { async fn review_input_isolated_from_parent_history() { skip_if_no_network!(); - // Mock server for the single review request - let sse_raw = r#"[ - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; // Seed a parent session history via resume file with both user + assistant items. let codex_home = Arc::new(TempDir::new().unwrap()); @@ -674,16 +641,11 @@ async fn review_input_isolated_from_parent_history() { async fn review_history_surfaces_in_parent_session() { skip_if_no_network!(); - // Respond to both the review request and the subsequent parent request. - let sse_raw = r#"[ - {"type":"response.output_item.done", "item":{ - "type":"message", "role":"assistant", - "content":[{"type":"output_text","text":"review assistant output"}] - }}, - {"type":"response.completed", "response": {"id": "__ID__"}} - ]"#; - let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 2).await; + let (server, request_log) = start_responses_server_with_sse( + assistant_message_sse("review assistant output"), + /*expected_requests*/ 2, + ) + .await; let codex_home = Arc::new(TempDir::new().unwrap()); let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; @@ -776,9 +738,8 @@ async fn review_history_surfaces_in_parent_session() { async fn review_uses_overridden_cwd_for_base_branch_merge_base() { skip_if_no_network!(); - let sse_raw = r#"[{"type":"response.completed", "response": {"id": "__ID__"}}]"#; let (server, request_log) = - start_responses_server_with_sse(sse_raw, /*expected_requests*/ 1).await; + start_responses_server_with_sse(completed_sse(), /*expected_requests*/ 1).await; let initial_cwd = TempDir::new().unwrap(); @@ -871,13 +832,24 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { server.verify().await; } -/// Start a mock Responses API server and mount the given SSE stream body. +fn assistant_message_sse(text: &str) -> Vec { + vec![ + responses::ev_assistant_message("msg-1", text), + responses::ev_completed("resp-1"), + ] +} + +fn completed_sse() -> Vec { + vec![responses::ev_completed("resp-1")] +} + +/// Start a mock Responses API server and mount the given SSE events. async fn start_responses_server_with_sse( - sse_raw: &str, + events: Vec, expected_requests: usize, ) -> (MockServer, ResponseMock) { let server = start_mock_server().await; - let sse = load_sse_fixture_with_id_from_str(sse_raw, &Uuid::new_v4().to_string()); + let sse = responses::sse(events); let responses = vec![sse; expected_requests]; let request_log = mount_sse_sequence(&server, responses).await; (server, request_log) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index bb4ca84b29..69dc74dce3 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -7,7 +7,6 @@ use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; -use codex_models_manager::bundled_models_response; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::dynamic_tools::DynamicToolSpec; @@ -21,6 +20,15 @@ use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI; use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI; +use core_test_support::apps_test_server::DIRECT_CALENDAR_CREATE_EVENT_TOOL as CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::DIRECT_CALENDAR_LIST_EVENTS_TOOL as CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; +use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; +use core_test_support::apps_test_server::configure_search_capable_apps; +use core_test_support::apps_test_server::configure_search_capable_model; +use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; +use core_test_support::apps_test_server::search_capable_apps_builder as configured_builder; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -34,7 +42,6 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; -use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; @@ -48,11 +55,6 @@ const SEARCH_TOOL_DESCRIPTION_SNIPPETS: [&str; 2] = [ "- Calendar: Plan events and manage your calendar.", ]; const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; -const CALENDAR_CREATE_TOOL: &str = "mcp__codex_apps__calendar_create_event"; -const CALENDAR_LIST_TOOL: &str = "mcp__codex_apps__calendar_list_events"; -const SEARCH_CALENDAR_NAMESPACE: &str = "mcp__codex_apps__calendar"; -const SEARCH_CALENDAR_CREATE_TOOL: &str = "_create_event"; -const SEARCH_CALENDAR_LIST_TOOL: &str = "_list_events"; fn tool_names(body: &Value) -> Vec { body.get("tools") @@ -111,28 +113,6 @@ fn tool_search_output_has_namespace_child( namespace_child_tool(&output, namespace, tool_name).is_some() } -fn configure_search_capable_model(config: &mut Config) { - let mut model_catalog = bundled_models_response() - .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - let model = model_catalog - .models - .iter_mut() - .find(|model| model.slug == "gpt-5.4") - .expect("gpt-5.4 exists in bundled models.json"); - config.model = Some("gpt-5.4".to_string()); - model.supports_search_tool = true; - config.model_catalog = Some(model_catalog); -} - -fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) { - config - .features - .enable(Feature::Apps) - .expect("test config should allow feature update"); - config.chatgpt_base_url = apps_base_url.to_string(); - configure_search_capable_model(config); -} - fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) { configure_search_capable_apps(config, apps_base_url); config @@ -141,16 +121,6 @@ fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) .expect("test config should allow feature update"); } -fn configure_apps(config: &mut Config, apps_base_url: &str) { - configure_search_capable_apps(config, apps_base_url); -} - -fn configured_builder(apps_base_url: String) -> TestCodexBuilder { - test_codex() - .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) - .with_config(move |config| configure_apps(config, apps_base_url.as_str())) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { skip_if_no_network!(Ok(())); @@ -321,7 +291,9 @@ async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(move |config| configure_apps(config, apps_server.chatgpt_base_url.as_str())); + .with_config(move |config| { + configure_search_capable_apps(config, apps_server.chatgpt_base_url.as_str()) + }); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -585,18 +557,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - assert_eq!(requests.len(), 3); let first_request_body = requests[0].body_json(); - let apps_tool_call = server - .received_requests() - .await - .unwrap_or_default() - .into_iter() - .find_map(|request| { - let body: Value = serde_json::from_slice(&request.body).ok()?; - (request.url.path() == "/api/codex/apps" - && body.get("method").and_then(Value::as_str) == Some("tools/call")) - .then_some(body) - }) - .expect("apps tools/call request should be recorded"); + let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, "calendar-call-1").await; assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs index 1b3b3d0b40..eb545fce0b 100644 --- a/codex-rs/core/tests/suite/shell_command.rs +++ b/codex-rs/core/tests/suite/shell_command.rs @@ -61,9 +61,7 @@ fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec TestCodexBuilder, ) -> Result { - let builder = configure(test_codex()).with_config(|config| { - config.include_apply_patch_tool = true; - }); + let builder = configure(test_codex()); TestCodexHarness::with_builder(builder).await } diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 56afa59b73..dbe4957ecb 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -72,15 +72,10 @@ fn shell_responses( fn configure_shell_model( builder: TestCodexBuilder, output_type: ShellModelOutput, - include_apply_patch_tool: bool, ) -> TestCodexBuilder { - let builder = match output_type { + match output_type { ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"), - }; - - builder.with_config(move |config| { - config.include_apply_patch_tool = include_apply_patch_tool; - }) + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -91,11 +86,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ); + let mut builder = configure_shell_model(test_codex(), output_type); let test = builder.build(&server).await?; let call_id = "shell-structured"; @@ -139,11 +130,7 @@ async fn shell_output_structures_fixture_with_serialization( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ); + let mut builder = configure_shell_model(test_codex(), output_type); let test = builder.build(&server).await?; let fixture_path = test.cwd.path().join("fixture.json"); @@ -200,11 +187,7 @@ async fn shell_output_for_freeform_tool_records_duration( skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = configure_shell_model( - test_codex(), - output_type, - /*include_apply_patch_tool*/ true, - ); + let mut builder = configure_shell_model(test_codex(), output_type); let test = builder.build(&server).await?; let call_id = "shell-structured"; @@ -474,11 +457,7 @@ async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutp skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex() - .with_model("gpt-5.4") - .with_config(move |config| { - config.include_apply_patch_tool = true; - }); + let mut builder = test_codex().with_model("gpt-5.4"); let test = builder.build(&server).await?; let call_id = "shell-nonzero-exit"; @@ -512,9 +491,7 @@ async fn shell_command_output_is_freeform() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - config.include_apply_patch_tool = true; - }); + let mut builder = test_codex(); let test = builder.build(&server).await?; let call_id = "shell-command"; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index f757fa322a..49ef4956cd 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -531,7 +531,6 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { .features .enable(Feature::ShellSnapshot) .expect("test config should allow feature update"); - config.include_apply_patch_tool = true; }); let harness = TestCodexHarness::with_builder(builder).await?; diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 0cc7d07b16..ab0b4c27ea 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -168,6 +168,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }, ]; diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 984220a086..30574718f2 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -6,8 +6,6 @@ use codex_model_provider_info::WireApi; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; -use codex_utils_cargo_bin::find_resource; -use core_test_support::load_sse_fixture; use core_test_support::responses; use core_test_support::skip_if_no_network; use core_test_support::streaming_sse::StreamingSseChunk; @@ -17,9 +15,9 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; fn sse_incomplete() -> String { - let fixture = find_resource!("tests/fixtures/incomplete_sse.json") - .unwrap_or_else(|err| panic!("failed to resolve incomplete_sse fixture: {err}")); - load_sse_fixture(fixture) + responses::sse(vec![serde_json::json!({ + "type": "response.output_item.done", + })]) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index ac41d06c3f..38843f2d83 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -3,7 +3,6 @@ use std::fs; use assert_matches::assert_matches; -use codex_features::Feature; use codex_protocol::items::TurnItem; use codex_protocol::models::PermissionProfile; use codex_protocol::plan_tool::StepStatus; @@ -324,12 +323,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); - }); + let mut builder = test_codex(); let TestCodex { codex, cwd, @@ -467,12 +461,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ApplyPatchFreeform) - .expect("test config should allow feature update"); - }); + let mut builder = test_codex(); let TestCodex { codex, cwd, diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 0f46e50386..7b523a4686 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -71,7 +71,6 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> .features .enable(Feature::UnifiedExec) .expect("unified exec should enable for test"); - config.include_apply_patch_tool = true; }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 231d14f770..92b8aa9047 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -9,6 +9,7 @@ use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; @@ -235,7 +236,6 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { skip_if_windows!(Ok(())); let builder = test_codex().with_config(|config| { - config.include_apply_patch_tool = true; config.use_experimental_unified_exec_tool = true; if let Err(err) = config.features.enable(Feature::UnifiedExec) { panic!("test config should allow feature update: {err}"); @@ -891,11 +891,16 @@ mode = "limited" allow_local_binding = true "#, )?; - let mut sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - if let SandboxPolicy::WorkspaceWrite { network_access, .. } = &mut sandbox_policy { - *network_access = true; - } - let sandbox_policy_for_config = sandbox_policy.clone(); + let permission_profile_for_config = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); + let sandbox_policy = permission_profile_for_config + .clone() + .to_legacy_sandbox_policy(home.path()) + .expect("workspace-write profile should project to legacy policy"); let mut builder = test_codex() .with_home(home) .with_cloud_requirements(managed_network_requirements_loader()) @@ -906,9 +911,10 @@ allow_local_binding = true .enable(Feature::UnifiedExec) .expect("test config should allow feature update"); config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); - config.permissions.permission_profile = Constrained::allow_any( - PermissionProfile::from_legacy_sandbox_policy(&sandbox_policy_for_config), - ); + config + .permissions + .set_permission_profile(permission_profile_for_config) + .expect("set permission profile"); }); let test = builder.build_with_remote_env(server).await?; assert!( @@ -2721,7 +2727,6 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { - use codex_config::Constrained; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -2733,16 +2738,11 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { skip_if_sandbox!(Ok(())); let server = start_mock_server().await; - let read_only_policy = SandboxPolicy::new_read_only_policy(); - let read_only_policy_for_config = read_only_policy.clone(); let mut builder = test_codex().with_config(move |config| { config .features .enable(Feature::UnifiedExec) .expect("test config should allow feature update"); - config - .set_legacy_sandbox_policy(read_only_policy_for_config) - .expect("set sandbox policy"); let mut file_system_sandbox_policy = FileSystemSandboxPolicy::default(); file_system_sandbox_policy .entries @@ -2752,11 +2752,13 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { }, access: FileSystemAccessMode::None, }); - config.permissions.permission_profile = - Constrained::allow_any(PermissionProfile::from_runtime_permissions( + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, - )); + )) + .expect("set permission profile"); }); let TestCodex { codex, @@ -2797,6 +2799,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); + let read_only_policy = SandboxPolicy::new_read_only_policy(); codex .submit(Op::UserTurn { environments: None, diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 1285b9f925..4fe3586d7f 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -344,11 +344,13 @@ async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Re let server = responses::start_mock_server().await; let mut builder = core_test_support::test_codex::test_codex().with_config(|config| { let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); - config.permissions.permission_profile = - codex_config::Constrained::allow_any(PermissionProfile::from_runtime_permissions( + config + .permissions + .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, - )); + )) + .expect("set permission profile"); }); let test = builder.build(&server).await?; diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 5d5673b011..d78e8761fd 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -201,6 +201,7 @@ async fn assert_user_turn_local_image_resizes_to( &test, vec![UserInput::LocalImage { path: abs_path.clone(), + detail: None, }], session_model, )) @@ -814,7 +815,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho .expect("output text present"); assert_eq!( output_text, - "view_image.detail only supports `original`; omit `detail` for default resized behavior, got `low`" + "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`" ); assert!( @@ -1487,6 +1488,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> &test, vec![UserInput::LocalImage { path: abs_path.clone(), + detail: None, }], session_model, )) diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 546e4e44fb..37a577e2c8 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -39,6 +39,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-oss = { workspace = true } +codex-utils-sandbox-summary = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 92248c19ec..c3190b8740 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,5 +1,4 @@ use std::io::IsTerminal; -use std::path::Path; use std::path::PathBuf; use codex_app_server_protocol::CommandExecutionStatus; @@ -11,11 +10,9 @@ use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::TurnStatus; use codex_core::config::Config; use codex_model_provider_info::WireApi; -use codex_protocol::models::PermissionProfile; use codex_protocol::num_format::format_with_separators; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; -use codex_utils_absolute_path::canonicalize_preserving_symlinks; +use codex_utils_sandbox_summary::summarize_permission_profile; use owo_colors::OwoColorize; use owo_colors::Style; @@ -423,6 +420,7 @@ fn config_summary_entries( config: &Config, session_configured_event: &SessionConfiguredEvent, ) -> Vec<(&'static str, String)> { + let permission_profile = config.permissions.effective_permission_profile(); let mut entries = vec![ ("workdir", config.cwd.display().to_string()), ("model", session_configured_event.model.clone()), @@ -437,8 +435,9 @@ fn config_summary_entries( ( "sandbox", summarize_permission_profile( - config.permissions.permission_profile.get(), - config.cwd.as_path(), + &permission_profile, + &config.cwd, + config.effective_workspace_roots().as_slice(), ), ), ]; @@ -465,83 +464,6 @@ fn config_summary_entries( entries } -fn summarize_permission_profile(permission_profile: &PermissionProfile, cwd: &Path) -> String { - match permission_profile { - PermissionProfile::Disabled => "danger-full-access".to_string(), - PermissionProfile::External { network } => { - let mut summary = "external-sandbox".to_string(); - append_network_summary(&mut summary, *network); - summary - } - PermissionProfile::Managed { .. } => { - let file_system_policy = permission_profile.file_system_sandbox_policy(); - let network_policy = permission_profile.network_sandbox_policy(); - if file_system_policy.has_full_disk_write_access() { - let mut summary = "workspace-write [/]".to_string(); - append_network_summary(&mut summary, network_policy); - return summary; - } - - let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd); - if writable_roots.is_empty() { - let mut summary = "read-only".to_string(); - append_network_summary(&mut summary, network_policy); - return summary; - } - - let mut summary = "workspace-write".to_string(); - let writable_entries = writable_roots - .iter() - .map(|root| writable_root_label(root.root.as_path(), cwd)) - .collect::>(); - summary.push_str(&format!(" [{}]", writable_entries.join(", "))); - append_network_summary(&mut summary, network_policy); - summary - } - } -} - -fn append_network_summary(summary: &mut String, network_policy: NetworkSandboxPolicy) { - if network_policy.is_enabled() { - summary.push_str(" (network access enabled)"); - } -} - -fn writable_root_label(root: &Path, cwd: &Path) -> String { - if paths_match_after_canonicalization(root, cwd) { - return "workdir".to_string(); - } - if paths_match_after_canonicalization(root, Path::new("/tmp")) { - return "/tmp".to_string(); - } - if std::env::var_os("TMPDIR") - .filter(|tmpdir| !tmpdir.is_empty()) - .is_some_and(|tmpdir| paths_match_after_canonicalization(root, Path::new(&tmpdir))) - { - return "$TMPDIR".to_string(); - } - display_path_label(root) -} - -fn paths_match_after_canonicalization(left: &Path, right: &Path) -> bool { - match ( - canonicalize_preserving_symlinks(left), - canonicalize_preserving_symlinks(right), - ) { - (Ok(left), Ok(right)) if left == right => true, - _ => display_path_label(left) == display_path_label(right), - } -} - -fn display_path_label(path: &Path) -> String { - path.strip_prefix("/private/tmp") - .ok() - .map(|suffix| Path::new("/tmp").join(suffix)) - .unwrap_or_else(|| path.to_path_buf()) - .to_string_lossy() - .to_string() -} - fn reasoning_text( summary: &[String], content: &[String], diff --git a/codex-rs/exec/src/event_processor_with_human_output_tests.rs b/codex-rs/exec/src/event_processor_with_human_output_tests.rs index 479758f9a0..1e900f2b61 100644 --- a/codex-rs/exec/src/event_processor_with_human_output_tests.rs +++ b/codex-rs/exec/src/event_processor_with_human_output_tests.rs @@ -2,24 +2,29 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; +use codex_core::config::ConfigBuilder; +use codex_protocol::SessionId; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SessionConfiguredEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_sandbox_summary::summarize_permission_profile; use owo_colors::Style; use pretty_assertions::assert_eq; use super::EventProcessorWithHumanOutput; +use super::config_summary_entries; use super::final_message_from_turn_items; -use super::paths_match_after_canonicalization; use super::reasoning_text; use super::should_print_final_message_to_stdout; use super::should_print_final_message_to_tty; -use super::summarize_permission_profile; use crate::event_processor::EventProcessor; #[test] @@ -101,10 +106,13 @@ fn reasoning_text_uses_raw_content_when_enabled() { #[test] fn summarizes_disabled_permission_profile_as_danger_full_access() { + let cwd = test_path_buf("/tmp").abs(); + assert_eq!( summarize_permission_profile( &PermissionProfile::Disabled, - test_path_buf("/tmp").as_path() + &cwd, + std::slice::from_ref(&cwd), ), "danger-full-access" ); @@ -112,12 +120,15 @@ fn summarizes_disabled_permission_profile_as_danger_full_access() { #[test] fn summarizes_external_permission_profile() { + let cwd = test_path_buf("/tmp").abs(); + assert_eq!( summarize_permission_profile( &PermissionProfile::External { network: NetworkSandboxPolicy::Enabled, }, - test_path_buf("/tmp").as_path(), + &cwd, + std::slice::from_ref(&cwd), ), "external-sandbox (network access enabled)" ); @@ -144,30 +155,88 @@ fn summarizes_managed_workspace_write_permission_profile() { ); assert_eq!( - summarize_permission_profile(&profile, cwd.as_path()), + summarize_permission_profile(&profile, &cwd, &[cwd.clone(), cache_root.clone()]), format!("workspace-write [workdir, {}]", cache_root.display()) ); } #[test] fn summarizes_managed_read_only_permission_profile() { + let cwd = test_path_buf("/tmp/project").abs(); let profile = PermissionProfile::from_runtime_permissions( &FileSystemSandboxPolicy::restricted(Vec::new()), NetworkSandboxPolicy::Restricted, ); assert_eq!( - summarize_permission_profile(&profile, test_path_buf("/tmp/project").as_path()), + summarize_permission_profile(&profile, &cwd, std::slice::from_ref(&cwd)), "read-only" ); } -#[test] -fn distinct_missing_paths_do_not_match_after_canonicalization() { - assert!(!paths_match_after_canonicalization( - test_path_buf("/tmp/codex-missing-left").as_path(), - test_path_buf("/tmp/codex-missing-right").as_path(), - )); +#[tokio::test] +async fn config_summary_entries_include_runtime_workspace_roots() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let cwd = tempfile::tempdir().expect("create cwd"); + let extra_root = tempfile::tempdir().expect("create extra root"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(cwd.path().to_path_buf())) + .build() + .await + .expect("build default config"); + let cwd = cwd.path().to_path_buf().abs(); + let extra_root = extra_root.path().to_path_buf().abs(); + let expected_extra_root_name = extra_root + .file_name() + .expect("extra root should have file name") + .to_string_lossy() + .to_string(); + config.cwd = cwd.clone(); + config.workspace_roots = vec![cwd.clone(), extra_root]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + )) + .expect("set permission profile"); + + let session_configured_event = SessionConfiguredEvent { + session_id: SessionId::new(), + thread_id: ThreadId::new(), + forked_from_id: None, + thread_source: None, + thread_name: None, + model: "gpt-5.4".to_string(), + model_provider_id: config.model_provider_id.clone(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: config.approvals_reviewer, + permission_profile: config.permissions.effective_permission_profile(), + active_permission_profile: None, + cwd, + reasoning_effort: None, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }; + + let summary_entries = config_summary_entries(&config, &session_configured_event); + let sandbox_summary = summary_entries + .iter() + .find_map(|(key, value)| (*key == "sandbox").then_some(value)) + .expect("sandbox summary entry"); + assert!( + sandbox_summary.starts_with("workspace-write [workdir, ") + && sandbox_summary.contains(&expected_extra_root_name), + "expected runtime workspace root in sandbox summary: {summary_entries:?}" + ); } #[test] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2485a97524..3e2cf21051 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -24,7 +24,6 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -83,7 +82,6 @@ use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewRequest; @@ -419,6 +417,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result permission_profile: None, default_permissions: None, cwd: resolved_cwd, + workspace_roots: None, model_provider: model_provider.clone(), service_tier: None, codex_self_exe: arg0_paths.codex_self_exe.clone(), @@ -638,7 +637,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let mut items: Vec = imgs .into_iter() .chain(args.images.iter().cloned()) - .map(|path| UserInput::LocalImage { path }) + .map(|path| UserInput::LocalImage { path, detail: None }) .collect(); items.push(UserInput::Text { text: prompt_text.clone(), @@ -658,7 +657,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let prompt_text = resolve_root_prompt(root_prompt); let mut items: Vec = imgs .into_iter() - .map(|path| UserInput::LocalImage { path }) + .map(|path| UserInput::LocalImage { path, detail: None }) .collect(); items.push(UserInput::Text { text: prompt_text.clone(), @@ -760,7 +759,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { event_processor.print_config_summary(&config, &prompt_summary, &session_configured); if !json_mode && let Some(message) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile()) { event_processor.process_warning(message); } @@ -790,6 +789,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { responsesapi_client_metadata: None, environments: None, cwd: Some(default_cwd), + runtime_workspace_roots: None, approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: None, @@ -953,7 +953,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { let permissions = permissions_selection_from_config(config); let sandbox = permissions.is_none().then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }); @@ -961,6 +961,13 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -975,7 +982,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa let permissions = permissions_selection_from_config(config); let sandbox = permissions.is_none().then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }); @@ -984,6 +991,13 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -1003,19 +1017,7 @@ fn permissions_selection_from_config(config: &Config) -> Option PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::new(active.id) } fn sandbox_mode_from_permission_profile( @@ -1087,11 +1089,7 @@ fn session_configured_from_thread_start_response( response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| config.permissions.permission_profile()), + config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, @@ -1112,11 +1110,7 @@ fn session_configured_from_thread_resume_response( response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), - response - .permission_profile - .clone() - .map(Into::into) - .unwrap_or_else(|| config.permissions.permission_profile()), + config.permissions.effective_permission_profile(), response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.reasoning_effort, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 321f4b8f9e..92dc0678e5 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -1,6 +1,8 @@ use super::*; use codex_otel::set_parent_from_w3c_trace_context; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use opentelemetry::trace::TraceContextExt; @@ -456,6 +458,18 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() ); } +#[test] +fn active_profile_selection_uses_profile_id_only() { + let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )); + + assert_eq!( + selection, + PermissionProfileSelectionParams::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) + ); +} + #[tokio::test] async fn thread_lifecycle_params_include_legacy_sandbox_when_no_active_profile() { let codex_home = tempdir().expect("create temp codex home"); @@ -514,7 +528,7 @@ async fn session_configured_from_thread_response_uses_review_policy_from_respons } #[tokio::test] -async fn session_configured_from_thread_response_uses_permission_profile_from_response() { +async fn session_configured_from_thread_response_uses_permission_profile_from_config() { let codex_home = tempdir().expect("create temp codex home"); let cwd = tempdir().expect("create temp cwd"); let config = ConfigBuilder::default() @@ -523,13 +537,15 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_re .build() .await .expect("build config"); - let mut response = sample_thread_start_response(); - response.permission_profile = Some(PermissionProfile::Disabled.into()); + let response = sample_thread_start_response(); let event = session_configured_from_thread_start_response(&response, &config) .expect("build bootstrap session configured event"); - assert_eq!(event.permission_profile, PermissionProfile::Disabled); + assert_eq!( + event.permission_profile, + config.permissions.effective_permission_profile() + ); } fn sample_thread_start_response() -> ThreadStartResponse { @@ -559,6 +575,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: codex_app_server_protocol::AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, @@ -568,7 +585,6 @@ fn sample_thread_start_response() -> ThreadStartResponse { exclude_tmpdir_env_var: false, exclude_slash_tmp: false, }, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, } diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 8f8eac3237..0a5381b1ef 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -1,6 +1,7 @@ #![cfg(unix)] use codex_core::spawn::StdioPolicy; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use std::collections::HashMap; @@ -14,7 +15,7 @@ use tokio::process::Child; async fn spawn_command_under_sandbox( command: Vec, command_cwd: AbsolutePathBuf, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, stdio_policy: StdioPolicy, env: HashMap, @@ -24,7 +25,6 @@ async fn spawn_command_under_sandbox( use codex_core::exec::build_exec_request; use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_protocol::models::PermissionProfile; use std::process::Stdio; let codex_linux_sandbox_exe = None; @@ -42,7 +42,7 @@ async fn spawn_command_under_sandbox( justification: None, arg0: None, }, - &PermissionProfile::from_legacy_sandbox_policy(sandbox_policy), + permission_profile, sandbox_cwd, &codex_linux_sandbox_exe, /*use_legacy_landlock*/ false, @@ -83,22 +83,20 @@ async fn spawn_command_under_sandbox( async fn spawn_command_under_sandbox( command: Vec, command_cwd: AbsolutePathBuf, - sandbox_policy: &SandboxPolicy, + permission_profile: &PermissionProfile, sandbox_cwd: &AbsolutePathBuf, stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { use codex_core::spawn_command_under_linux_sandbox; - use codex_protocol::models::PermissionProfile; let codex_linux_sandbox_exe = core_test_support::find_codex_linux_sandbox_exe() .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; - let permission_profile = PermissionProfile::from_legacy_sandbox_policy(sandbox_policy); spawn_command_under_linux_sandbox( codex_linux_sandbox_exe, command, command_cwd, - &permission_profile, + permission_profile, sandbox_cwd, /*use_legacy_landlock*/ false, stdio_policy, @@ -118,9 +116,16 @@ async fn spawn_command_under_sandbox( async fn linux_sandbox_test_env() -> Option> { let command_cwd = AbsolutePathBuf::current_dir().ok()?; let sandbox_cwd = command_cwd.clone(); - let policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = PermissionProfile::read_only(); - if can_apply_linux_sandbox_policy(&policy, &command_cwd, &sandbox_cwd, HashMap::new()).await { + if can_apply_linux_sandbox_policy( + &permission_profile, + &command_cwd, + &sandbox_cwd, + HashMap::new(), + ) + .await + { return Some(HashMap::new()); } @@ -135,7 +140,7 @@ async fn linux_sandbox_test_env() -> Option> { /// This is used as a capability probe so sandbox behavior tests only run when /// Landlock enforcement is actually active. async fn can_apply_linux_sandbox_policy( - policy: &SandboxPolicy, + permission_profile: &PermissionProfile, command_cwd: &AbsolutePathBuf, sandbox_cwd: &AbsolutePathBuf, env: HashMap, @@ -143,7 +148,7 @@ async fn can_apply_linux_sandbox_policy( let spawn_result = spawn_command_under_sandbox( vec!["/usr/bin/true".to_string()], command_cwd.clone(), - policy, + permission_profile, sandbox_cwd, StdioPolicy::RedirectForShellTool, env, @@ -180,12 +185,12 @@ async fn python_multiprocessing_lock_works_under_sandbox() { #[cfg(target_os = "linux")] let writable_roots: Vec = vec!["/dev/shm".try_into().unwrap()]; - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &writable_roots, + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let python_code = r#"import multiprocessing from multiprocessing import Lock, Process @@ -210,7 +215,7 @@ if __name__ == '__main__': python_code.to_string(), ], command_cwd, - &policy, + &permission_profile, &sandbox_cwd, StdioPolicy::Inherit, sandbox_env, @@ -242,7 +247,7 @@ async fn python_getpwuid_works_under_sandbox() { return; } - let policy = SandboxPolicy::new_read_only_policy(); + let permission_profile = PermissionProfile::read_only(); let command_cwd = AbsolutePathBuf::current_dir().expect("should be able to get current dir"); let sandbox_cwd = command_cwd.clone(); @@ -253,7 +258,7 @@ async fn python_getpwuid_works_under_sandbox() { "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), ], command_cwd, - &policy, + &permission_profile, &sandbox_cwd, StdioPolicy::RedirectForShellTool, sandbox_env, @@ -294,12 +299,12 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { // Note writable_roots is empty: verify that `canonical_allowed_path` is // writable only because it is under the sandbox policy cwd, not because it // is under a writable root. - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); // Attempt to write inside the command cwd, which is outside of the sandbox policy cwd. let mut child = spawn_command_under_sandbox( @@ -309,7 +314,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { "echo forbidden > forbidden.txt".to_string(), ], command_root.clone(), - &policy, + &permission_profile, &canonical_sandbox_root, StdioPolicy::Inherit, sandbox_env.clone(), @@ -340,7 +345,7 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { canonical_allowed_path.to_string_lossy().into_owned(), ], command_root, - &policy, + &permission_profile, &canonical_sandbox_root, StdioPolicy::Inherit, sandbox_env, @@ -375,12 +380,12 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { create_dir_all(&repo_root).await.expect("mkdir repo"); let dot_codex = repo_root.join(".codex"); let config_toml = dot_codex.join("config.toml"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); let mut child = spawn_command_under_sandbox( vec![ @@ -390,7 +395,7 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { .to_string(), ], repo_root.clone(), - &policy, + &permission_profile, &repo_root, StdioPolicy::RedirectForShellTool, sandbox_env, @@ -507,7 +512,7 @@ fn unix_sock_body() { async fn allow_unix_socketpair_recvfrom() { run_code_under_sandbox( "allow_unix_socketpair_recvfrom", - &SandboxPolicy::new_read_only_policy(), + &PermissionProfile::read_only(), || async { unix_sock_body() }, ) .await @@ -519,7 +524,7 @@ const IN_SANDBOX_ENV_VAR: &str = "IN_SANDBOX"; #[expect(clippy::expect_used)] pub async fn run_code_under_sandbox( test_selector: &str, - policy: &SandboxPolicy, + permission_profile: &PermissionProfile, child_body: F, ) -> io::Result> where @@ -544,7 +549,7 @@ where let mut child = spawn_command_under_sandbox( cmds, command_cwd, - policy, + permission_profile, &sandbox_cwd, stdio_policy, HashMap::from([("IN_SANDBOX".into(), "1".into())]), diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index ed9665f228..a8c2001200 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -4,12 +4,13 @@ use std::sync::Arc; use codex_protocol::items::TurnItem; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TokenUsageInfo; +use codex_tools::ToolCall; +use codex_tools::ToolExecutor; use crate::ExtensionData; mod prompt; mod thread_lifecycle; -mod tools; mod turn_lifecycle; pub use prompt::PromptFragment; @@ -17,8 +18,6 @@ pub use prompt::PromptSlot; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; -pub use tools::ExtensionToolExecutor; -pub use tools::ExtensionToolOutput; pub use turn_lifecycle::TurnAbortInput; pub use turn_lifecycle::TurnStartInput; pub use turn_lifecycle::TurnStopInput; @@ -106,7 +105,7 @@ pub trait ToolContributor: Send + Sync { &self, session_store: &ExtensionData, thread_store: &ExtensionData, - ) -> Vec>; + ) -> Vec>>; } /// Future returned by one claimed approval-review contribution. diff --git a/codex-rs/ext/extension-api/src/contributors/tools.rs b/codex-rs/ext/extension-api/src/contributors/tools.rs deleted file mode 100644 index a9f0f6dc32..0000000000 --- a/codex-rs/ext/extension-api/src/contributors/tools.rs +++ /dev/null @@ -1,15 +0,0 @@ -use codex_tools::JsonToolOutput; -use codex_tools::ToolCall; -use codex_tools::ToolExecutor; - -/// Model-facing output returned by extension-owned tools. -pub type ExtensionToolOutput = JsonToolOutput; - -/// Thin alias for extension-owned executable tools. -/// -/// Extensions implement the shared `ToolExecutor` contract directly; -/// the marker keeps contributor signatures readable while preserving one -/// executable-tool abstraction across host and extension tools. -pub trait ExtensionToolExecutor: ToolExecutor {} - -impl ExtensionToolExecutor for T where T: ToolExecutor {} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index a6d850468b..e8be7e0309 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -11,6 +11,7 @@ pub use codex_tools::ResponsesApiTool; pub use codex_tools::ToolCall; pub use codex_tools::ToolExecutor; pub use codex_tools::ToolName; +pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; pub use codex_tools::ToolSpec; pub use codex_tools::parse_tool_input_schema; @@ -18,8 +19,6 @@ pub use contributors::ApprovalReviewContributor; pub use contributors::ApprovalReviewFuture; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; -pub use contributors::ExtensionToolExecutor; -pub use contributors::ExtensionToolOutput; pub use contributors::PromptFragment; pub use contributors::PromptSlot; pub use contributors::ThreadLifecycleContributor; diff --git a/codex-rs/ext/memories/src/extension.rs b/codex-rs/ext/memories/src/extension.rs index 2b49559183..1a52eb2cb9 100644 --- a/codex-rs/ext/memories/src/extension.rs +++ b/codex-rs/ext/memories/src/extension.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use codex_core::config::Config; +use codex_extension_api::ConfigContributor; use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; @@ -25,6 +26,15 @@ pub(crate) struct MemoriesExtensionConfig { pub(crate) codex_home: AbsolutePathBuf, } +impl MemoriesExtensionConfig { + fn from_config(config: &Config) -> Self { + Self { + enabled: config.features.enabled(Feature::MemoryTool) && config.memories.use_memories, + codex_home: config.codex_home.clone(), + } + } +} + impl ContextContributor for MemoriesExtension { fn contribute<'a>( &'a self, @@ -50,11 +60,21 @@ impl ContextContributor for MemoriesExtension { impl ThreadLifecycleContributor for MemoriesExtension { fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { - input.thread_store.insert(MemoriesExtensionConfig { - enabled: input.config.features.enabled(Feature::MemoryTool) - && input.config.memories.use_memories, - codex_home: input.config.codex_home.clone(), - }); + input + .thread_store + .insert(MemoriesExtensionConfig::from_config(input.config)); + } +} + +impl ConfigContributor for MemoriesExtension { + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + thread_store.insert(MemoriesExtensionConfig::from_config(new_config)); } } @@ -63,7 +83,7 @@ impl ToolContributor for MemoriesExtension { &self, _session_store: &ExtensionData, thread_store: &ExtensionData, - ) -> Vec> { + ) -> Vec>> { let Some(config) = thread_store.get::() else { return Vec::new(); }; @@ -79,6 +99,8 @@ impl ToolContributor for MemoriesExtension { pub fn install(registry: &mut ExtensionRegistryBuilder) { let extension = Arc::new(MemoriesExtension); registry.thread_lifecycle_contributor(extension.clone()); - registry.prompt_contributor(extension.clone()); - registry.tool_contributor(extension); + registry.config_contributor(extension.clone()); + registry.prompt_contributor(extension); + // Keep the read/retrieval tools out of app-server until that rollout is intentional. + // registry.tool_contributor(extension); } diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index 47c58f4686..b732ca22c2 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use codex_extension_api::ContextContributor; use codex_extension_api::ExtensionData; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::PromptSlot; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; +use codex_extension_api::ToolExecutor; use codex_extension_api::ToolName; use codex_extension_api::ToolPayload; use codex_tools::ToolOutput; @@ -290,20 +290,23 @@ async fn search_tool_rejects_legacy_single_query() { .to_string(), }; - let err = tool + let result = tool .handle(ToolCall { call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), payload, }) - .await - .expect_err("legacy query field should be rejected"); + .await; + let err = match result { + Ok(_) => panic!("legacy query field should be rejected"), + Err(err) => err, + }; assert!(err.to_string().contains("unknown field")); assert!(err.to_string().contains("query")); } -fn memory_tool(memory_root: &Path, tool_name: &str) -> Arc { +fn memory_tool(memory_root: &Path, tool_name: &str) -> Arc> { let expected_tool_name = memory_tool_name(tool_name); crate::tools::memory_tools(LocalMemoriesBackend::from_memory_root(memory_root)) .into_iter() diff --git a/codex-rs/ext/memories/src/tools/list.rs b/codex-rs/ext/memories/src/tools/list.rs index 1509c2344c..baed3c30a2 100644 --- a/codex-rs/ext/memories/src/tools/list.rs +++ b/codex-rs/ext/memories/src/tools/list.rs @@ -39,8 +39,6 @@ impl ToolExecutor for ListTool where B: MemoriesBackend, { - type Output = JsonToolOutput; - fn tool_name(&self) -> ToolName { memory_tool_name(LIST_TOOL_NAME) } @@ -55,7 +53,8 @@ where async fn handle( &self, call: ToolCall, - ) -> Result { + ) -> Result, codex_extension_api::FunctionCallError> + { let backend = self.backend.clone(); let args: ListArgs = parse_args(&call)?; let response = backend @@ -70,6 +69,6 @@ where }) .await .map_err(backend_error_to_function_call)?; - Ok(JsonToolOutput::new(json!(response))) + Ok(Box::new(JsonToolOutput::new(json!(response)))) } } diff --git a/codex-rs/ext/memories/src/tools/mod.rs b/codex-rs/ext/memories/src/tools/mod.rs index f95922b28f..b78a125652 100644 --- a/codex-rs/ext/memories/src/tools/mod.rs +++ b/codex-rs/ext/memories/src/tools/mod.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use codex_extension_api::ExtensionToolExecutor; use codex_extension_api::FunctionCallError; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; use codex_extension_api::ToolName; use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema; @@ -23,7 +23,7 @@ mod list; mod read; mod search; -pub(crate) fn memory_tools(backend: B) -> Vec> +pub(crate) fn memory_tools(backend: B) -> Vec>> where B: MemoriesBackend, { diff --git a/codex-rs/ext/memories/src/tools/read.rs b/codex-rs/ext/memories/src/tools/read.rs index 06d8a1752c..6e7f771d75 100644 --- a/codex-rs/ext/memories/src/tools/read.rs +++ b/codex-rs/ext/memories/src/tools/read.rs @@ -38,8 +38,6 @@ impl ToolExecutor for ReadTool where B: MemoriesBackend, { - type Output = JsonToolOutput; - fn tool_name(&self) -> ToolName { memory_tool_name(READ_TOOL_NAME) } @@ -54,7 +52,8 @@ where async fn handle( &self, call: ToolCall, - ) -> Result { + ) -> Result, codex_extension_api::FunctionCallError> + { let backend = self.backend.clone(); let args: ReadArgs = parse_args(&call)?; let response = backend @@ -66,6 +65,6 @@ where }) .await .map_err(backend_error_to_function_call)?; - Ok(JsonToolOutput::new(json!(response))) + Ok(Box::new(JsonToolOutput::new(json!(response)))) } } diff --git a/codex-rs/ext/memories/src/tools/search.rs b/codex-rs/ext/memories/src/tools/search.rs index f7cab7de6c..422ce06b7e 100644 --- a/codex-rs/ext/memories/src/tools/search.rs +++ b/codex-rs/ext/memories/src/tools/search.rs @@ -47,8 +47,6 @@ impl ToolExecutor for SearchTool where B: MemoriesBackend, { - type Output = JsonToolOutput; - fn tool_name(&self) -> ToolName { memory_tool_name(SEARCH_TOOL_NAME) } @@ -63,14 +61,15 @@ where async fn handle( &self, call: ToolCall, - ) -> Result { + ) -> Result, codex_extension_api::FunctionCallError> + { let backend = self.backend.clone(); let args: SearchArgs = parse_args(&call)?; let response = backend .search(args.into_request()) .await .map_err(backend_error_to_function_call)?; - Ok(JsonToolOutput::new(json!(response))) + Ok(Box::new(JsonToolOutput::new(json!(response)))) } } diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 8f34f6ba5a..3682a4f7f8 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -81,6 +81,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec Option { #[derive(Debug, Default)] pub(crate) struct LegacyFeatureToggles { - pub include_apply_patch_tool: Option, pub experimental_use_unified_exec_tool: Option, } impl LegacyFeatureToggles { pub fn apply(self, features: &mut Features) { - set_if_some( - features, - Feature::ApplyPatchFreeform, - self.include_apply_patch_tool, - "include_apply_patch_tool", - ); set_if_some( features, Feature::UnifiedExec, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index da74d21e94..59f723683b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -76,31 +76,22 @@ impl Stage { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Feature { // Stable. - /// Removed compatibility flag retained as a no-op so old configs can - /// still parse `undo`. - GhostCommit, /// Enable the default shell tool. ShellTool, /// Enable Claude-style lifecycle hooks loaded from hooks.json files. CodexHooks, // Experimental - /// Removed compatibility flag for the deleted JavaScript REPL feature. - JsRepl, /// Enable JavaScript code mode backed by the in-process V8 runtime. CodeMode, /// Restrict model-visible tools to code mode entrypoints (`exec`, `wait`). CodeModeOnly, - /// Removed compatibility flag for the deleted JavaScript REPL tool-only mode. - JsReplToolsOnly, /// Use the single unified PTY-backed exec tool. UnifiedExec, /// Route shell tool execution through the zsh exec bridge. ShellZshFork, /// Reflow transcript scrollback when the terminal is resized. TerminalResizeReflow, - /// Include the freeform apply_patch tool. - ApplyPatchFreeform, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -112,30 +103,13 @@ pub enum Feature { /// Allow the model to request web searches that fetch cached content. /// Takes precedence over `WebSearchRequest`. WebSearchCached, - /// Legacy search-tool feature flag kept for backward compatibility. - SearchTool, - /// Removed legacy Linux bubblewrap opt-in flag retained as a no-op so old - /// wrappers and config can still parse it. - UseLinuxSandboxBwrap, /// Use the legacy Landlock Linux sandbox fallback instead of the default /// bubblewrap pipeline. UseLegacyLandlock, - /// Allow the model to request approval and propose exec rules. - RequestRule, - /// Enable Windows sandbox (restricted token) on Windows. - WindowsSandbox, - /// Use the elevated Windows sandbox pipeline (setup + runner). - WindowsSandboxElevated, - /// Legacy remote models flag kept for backward compatibility. - RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, - /// Removed legacy git commit attribution guidance flag. - CodexGitCommit, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, - /// Persist rollout metadata to a local SQLite database. - Sqlite, /// Enable startup memory extraction and file-backed memory consolidation. MemoryTool, /// Enable the Chronicle sidecar for passive screen-context memories. @@ -162,8 +136,6 @@ pub enum Feature { ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. ToolSearchAlwaysDeferMcpTools, - /// Removed compatibility flag for the deleted unavailable-tool placeholder backfill. - UnavailableDummyTools, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -200,18 +172,12 @@ pub enum Feature { SkillEnvVarDependencyPrompt, /// Enable the unified mention popup prototype. MentionsV2, - /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. - /// Kept for config backward compatibility; behavior is always steer-enabled. - Steer, /// Allow request_user_input in Default collaboration mode. DefaultModeRequestUserInput, /// Enable automatic review for approval prompts. GuardianApproval, /// Enable persisted thread goals and automatic goal continuation. Goals, - /// Enable collaboration modes (Plan, Default). - /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. - CollaborationModes, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, /// Prompt Codex Apps connector auth failures through MCP URL elicitations. @@ -224,15 +190,57 @@ pub enum Feature { FastMode, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, - /// Connect app-server to the ChatGPT remote control service. + /// Prevent idle system sleep while a turn is actively running. + PreventIdleSleep, + /// Send `response.processed` over Responses API websockets after a turn response is recorded. + ResponsesWebsocketResponseProcessed, + /// Enable remote compaction v2 over the normal Responses API. + RemoteCompactionV2, + /// Enable workspace dependency support. + WorkspaceDependencies, + + // Removed + /// Removed compatibility flag retained as a no-op so old configs can + /// still parse `undo`. + GhostCommit, + /// Removed compatibility flag for the deleted JavaScript REPL feature. + JsRepl, + /// Removed compatibility flag for the deleted JavaScript REPL tool-only mode. + JsReplToolsOnly, + /// Legacy search-tool feature flag kept for backward compatibility. + SearchTool, + /// Removed legacy Linux bubblewrap opt-in flag retained as a no-op so old + /// wrappers and config can still parse it. + UseLinuxSandboxBwrap, + /// Allow the model to request approval and propose exec rules. + RequestRule, + /// Enable Windows sandbox (restricted token) on Windows. + WindowsSandbox, + /// Use the elevated Windows sandbox pipeline (setup + runner). + WindowsSandboxElevated, + /// Legacy remote models flag kept for backward compatibility. + RemoteModels, + /// Removed legacy git commit attribution guidance flag. + CodexGitCommit, + /// Persist rollout metadata to a local SQLite database. + Sqlite, + /// Removed compatibility flag for the deleted apply_patch fallback feature. + ApplyPatchFreeform, + /// Removed compatibility flag for the deleted unavailable-tool placeholder backfill. + UnavailableDummyTools, + /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. + /// Kept for config backward compatibility; behavior is always steer-enabled. + Steer, + /// Enable collaboration modes (Plan, Default). + /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. + CollaborationModes, + /// Removed compatibility flag for the deleted remote control feature. RemoteControl, /// Removed compatibility flag retained as a no-op so old wrappers can /// still pass `--enable image_detail_original`. ImageDetailOriginal, /// Removed compatibility flag. The TUI now always uses the app-server implementation. TuiAppServer, - /// Prevent idle system sleep while a turn is actively running. - PreventIdleSleep, /// Removed compatibility flag retained as a no-op now that workspace owner /// usage nudges are always enabled. WorkspaceOwnerUsageNudge, @@ -240,12 +248,6 @@ pub enum Feature { ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, - /// Send `response.processed` over Responses API websockets after a turn response is recorded. - ResponsesWebsocketResponseProcessed, - /// Enable remote compaction v2 over the normal Responses API. - RemoteCompactionV2, - /// Enable workspace dependency support. - WorkspaceDependencies, } impl Feature { @@ -292,7 +294,6 @@ pub struct FeatureOverrides { #[derive(Debug, Clone, Copy, Default)] pub struct FeatureConfigSource<'a> { pub features: Option<&'a FeaturesToml>, - pub include_apply_patch_tool: Option, pub experimental_use_unified_exec_tool: Option, } @@ -424,6 +425,9 @@ impl Features { "remote_control" => { continue; } + "apply_patch_freeform" => { + continue; + } "image_detail_original" => { continue; } @@ -465,7 +469,6 @@ impl Features { for source in [base, profile] { LegacyFeatureToggles { - include_apply_patch_tool: source.include_apply_patch_tool, experimental_use_unified_exec_tool: source.experimental_use_unified_exec_tool, } .apply(&mut features); @@ -822,8 +825,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", - stage: Stage::Stable, - default_enabled: true, + stage: Stage::Removed, + default_enabled: false, }, FeatureSpec { id: Feature::ApplyPatchStreamingEvents, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 21a6ecf5ba..bd55ef8e7d 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -66,6 +66,16 @@ fn image_detail_original_is_removed_and_disabled_by_default() { assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false); } +#[test] +fn apply_patch_freeform_is_removed_and_disabled_by_default() { + assert_eq!(Feature::ApplyPatchFreeform.stage(), Stage::Removed); + assert_eq!(Feature::ApplyPatchFreeform.default_enabled(), false); + assert_eq!( + feature_for_key("apply_patch_freeform"), + Some(Feature::ApplyPatchFreeform) + ); +} + #[test] fn code_mode_only_requires_code_mode() { let mut features = Features::with_defaults(); @@ -403,7 +413,6 @@ fn from_sources_applies_base_profile_and_overrides() { }, FeatureConfigSource { features: Some(&profile_features), - include_apply_patch_tool: Some(true), ..Default::default() }, FeatureOverrides { @@ -414,7 +423,7 @@ fn from_sources_applies_base_profile_and_overrides() { assert_eq!(features.enabled(Feature::Plugins), true); assert_eq!(features.enabled(Feature::CodeModeOnly), true); assert_eq!(features.enabled(Feature::CodeMode), true); - assert_eq!(features.enabled(Feature::ApplyPatchFreeform), true); + assert_eq!(features.enabled(Feature::ApplyPatchFreeform), false); assert_eq!(features.enabled(Feature::WebSearchRequest), false); } @@ -472,6 +481,23 @@ fn from_sources_ignores_removed_js_repl_feature_keys() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_apply_patch_freeform_feature_key() { + let features_toml = + FeaturesToml::from(BTreeMap::from([("apply_patch_freeform".to_string(), true)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn multi_agent_v2_feature_config_deserializes_boolean_toggle() { let features: FeaturesToml = toml::from_str( @@ -587,14 +613,13 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( proxy_url: Some("http://127.0.0.1:43128".to_string()), ..Default::default() })), - entries: BTreeMap::from([("include_apply_patch_tool".to_string(), true)]), + entries: BTreeMap::new(), ..Default::default() }; features_toml.materialize_resolved_enabled(&features); let entries = features_toml.entries(); - assert_eq!(entries.get("include_apply_patch_tool"), None); for spec in crate::FEATURES { assert_eq!( entries.get(spec.key), @@ -627,7 +652,7 @@ fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config( FeatureConfigSource::default(), FeatureOverrides::default(), ); - assert_eq!(replayed.enabled(Feature::ApplyPatchFreeform), true); + assert_eq!(replayed.enabled(Feature::ApplyPatchFreeform), false); } #[test] diff --git a/codex-rs/git-utils/src/baseline.rs b/codex-rs/git-utils/src/baseline.rs index c63b894049..083dd6010f 100644 --- a/codex-rs/git-utils/src/baseline.rs +++ b/codex-rs/git-utils/src/baseline.rs @@ -584,6 +584,51 @@ mod tests { assert_eq!(git_stdout(&root, &["ls-files"]), "MEMORY.md\n"); } + #[cfg(unix)] + #[tokio::test] + async fn write_index_ignores_configured_hooks_path() { + use std::os::unix::fs::PermissionsExt; + + let home = TempDir::new().expect("tempdir"); + let root = home.path().join("repo"); + let hooks_dir = root.join(".git/hooks-path-test"); + let marker_path = root.join("hook-ran"); + let hook_path = hooks_dir.join("post-index-change"); + + fs::create_dir_all(&root).expect("create root"); + fs::write(root.join("MEMORY.md"), "baseline").expect("write memory"); + reset_git_repository(&root).await.expect("reset repo"); + fs::create_dir_all(&hooks_dir).expect("create hook dir"); + fs::write( + &hook_path, + format!( + "#!/bin/sh\nprintf ran > \"{}\"\n", + marker_path.to_string_lossy() + ), + ) + .expect("write post-index-change hook"); + let mut permissions = fs::metadata(&hook_path) + .expect("read hook metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&hook_path, permissions).expect("mark hook executable"); + git_stdout( + &root, + &[ + "config", + "core.hooksPath", + hooks_dir.to_string_lossy().as_ref(), + ], + ); + + write_index_from_head(&root).expect("rewrite baseline index"); + + assert!( + !marker_path.exists(), + "baseline index writes should not invoke configured hook directories" + ); + } + #[tokio::test] async fn diff_reports_added_modified_and_deleted_files() { let home = TempDir::new().expect("tempdir"); diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index 8c7c4046d4..722d556b37 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -40,6 +40,7 @@ pub fn get_git_repo_root(base_dir: &Path) -> Option { /// Timeout for git commands to prevent freezing on large repositories const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); +const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" }; #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct GitInfo { @@ -375,6 +376,8 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option Result<(), GitToolingError> { match run_git_for_stdout( path, @@ -98,7 +100,12 @@ where { let iterator = args.into_iter(); let (lower, upper) = iterator.size_hint(); - let mut args_vec = Vec::with_capacity(upper.unwrap_or(lower)); + let mut args_vec = Vec::with_capacity(upper.unwrap_or(lower) + 2); + // Keep internal Git helper commands independent of configured hook directories. + args_vec.push(OsString::from("-c")); + args_vec.push(OsString::from(format!( + "core.hooksPath={DISABLED_HOOKS_PATH}" + ))); for arg in iterator { args_vec.push(OsString::from(arg.as_ref())); } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index fe57be06fa..63ab3e0c42 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -22,6 +22,10 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; + #[tokio::test] async fn refresh_without_id_token() { let codex_home = tempdir().unwrap(); @@ -87,7 +91,7 @@ fn login_with_api_key_overwrites_existing_auth_json() { async fn login_with_access_token_writes_only_token() { let dir = tempdir().unwrap(); let auth_path = dir.path().join("auth.json"); - let record = agent_identity_record("account-123"); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); let server = MockServer::start().await; @@ -145,7 +149,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { #[tokio::test] async fn login_with_access_token_rejects_unsigned_jwt() { let dir = tempdir().unwrap(); - let record = agent_identity_record("account-123"); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); let server = MockServer::start().await; Mock::given(method("GET")) @@ -329,7 +333,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), - chatgpt_account_id: Some("org_mine".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) @@ -654,7 +658,7 @@ fn fake_jwt_for_auth_file_params(params: &AuthFileParams) -> std::io::Result, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option>, ) -> AuthConfig { AuthConfig { codex_home: codex_home.to_path_buf(), @@ -712,7 +716,7 @@ fn remove_access_token_env_var() -> EnvVarGuard { #[serial(codex_auth_env)] async fn load_auth_reads_access_token_from_env() { let codex_home = tempdir().unwrap(); - let expected_record = agent_identity_record("account-123"); + let expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) .expect("signed agent identity"); @@ -762,7 +766,7 @@ async fn load_auth_reads_access_token_from_env() { #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { let codex_home = tempdir().unwrap(); - let record = agent_identity_record("account-123"); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); @@ -814,7 +818,7 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), - chatgpt_account_id: Some("org_another_org".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_DISALLOWED.to_string()), }, codex_home.path(), ) @@ -823,14 +827,17 @@ async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { let config = build_config( codex_home.path(), /*forced_login_method*/ None, - Some("org_mine".to_string()), + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; let err = super::enforce_login_restrictions(&config) .await .expect_err("expected workspace mismatch to error"); - assert!(err.to_string().contains("workspace org_mine")); + assert!( + err.to_string() + .contains(&format!("workspace(s) {WORKSPACE_ID_ALLOWED}")) + ); assert!( !codex_home.path().join("auth.json").exists(), "auth.json should be removed on mismatch" @@ -846,7 +853,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() { AuthFileParams { openai_api_key: None, chatgpt_plan_type: Some("pro".to_string()), - chatgpt_account_id: Some("org_mine".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), }, codex_home.path(), ) @@ -855,7 +862,7 @@ async fn enforce_login_restrictions_allows_matching_workspace() { let config = build_config( codex_home.path(), /*forced_login_method*/ None, - Some("org_mine".to_string()), + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; @@ -870,6 +877,94 @@ async fn enforce_login_restrictions_allows_matching_workspace() { #[tokio::test] #[serial(codex_auth_env)] +async fn enforce_login_restrictions_allows_any_matching_workspace_in_list() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: Some(WORKSPACE_ID_ALLOWED.to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config( + codex_home.path(), + /*forced_login_method*/ None, + Some(vec![ + WORKSPACE_ID_SECOND_ALLOWED.to_string(), + WORKSPACE_ID_ALLOWED.to_string(), + ]), + ) + .await; + + super::enforce_login_restrictions(&config) + .await + .expect("any matching workspace in the allowed list should succeed"); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismatch() { + let codex_home = tempdir().unwrap(); + let _access_token_guard = remove_access_token_env_var(); + let record = agent_identity_record(WORKSPACE_ID_DISALLOWED); + let agent_identity = + signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-123", + }))) + .expect(1) + .mount(&server) + .await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let _authapi_guard = + EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + save_auth( + codex_home.path(), + &AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(agent_identity), + }, + AuthCredentialsStoreMode::File, + ) + .expect("seed agent identity auth"); + + let config = AuthConfig { + codex_home: codex_home.path().to_path_buf(), + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_login_method: None, + forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), + chatgpt_base_url: Some(chatgpt_base_url), + }; + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected workspace mismatch to error"); + assert!(err.to_string().contains(&format!( + "current credentials belong to {WORKSPACE_ID_DISALLOWED}" + ))); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); + server.verify().await; +} + +#[tokio::test] async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() { let codex_home = tempdir().unwrap(); @@ -880,7 +975,7 @@ async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_f let config = build_config( codex_home.path(), /*forced_login_method*/ None, - Some("org_mine".to_string()), + Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), ) .await; diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 45bd55302b..a2e4e8e0d8 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -610,8 +610,8 @@ pub struct AuthConfig { pub codex_home: PathBuf, pub auth_credentials_store_mode: AuthCredentialsStoreMode, pub forced_login_method: Option, - pub forced_chatgpt_workspace_id: Option, pub chatgpt_base_url: Option, + pub forced_chatgpt_workspace_id: Option>, } pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { @@ -653,9 +653,8 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< } } - if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { - // workspace is the external identifier for account id. - let chatgpt_account_id = match auth { + if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { + let chatgpt_account_id = match &auth { CodexAuth::ApiKey(_) => return Ok(()), CodexAuth::AgentIdentity(_) => auth.get_account_id(), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { @@ -674,13 +673,21 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< token_data.id_token.chatgpt_account_id } }; - if chatgpt_account_id.as_deref() != Some(expected_account_id) { + + // workspace is the external identifier for account id. + let chatgpt_account_id = chatgpt_account_id.as_deref(); + if !chatgpt_account_id.is_some_and(|actual| { + expected_account_ids + .iter() + .any(|expected| expected == actual) + }) { + let expected_workspaces = expected_account_ids.join(", "); let message = match chatgpt_account_id { Some(actual) => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + "Login is restricted to workspace(s) {expected_workspaces}, but current credentials belong to {actual}. Logging out." ), None => format!( - "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + "Login is restricted to workspace(s) {expected_workspaces}, but current credentials lack a workspace identifier. Logging out." ), }; return logout_with_message( @@ -1247,7 +1254,7 @@ pub struct AuthManager { inner: RwLock, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: RwLock>, + forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, refresh_lock: Semaphore, external_auth: RwLock>>, @@ -1266,8 +1273,8 @@ pub trait AuthManagerConfig { /// Returns the CLI auth credential storage mode for auth loading. fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode; - /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. - fn forced_chatgpt_workspace_id(&self) -> Option; + /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any. + fn forced_chatgpt_workspace_id(&self) -> Option>; /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; @@ -1548,7 +1555,7 @@ impl AuthManager { } } - pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { + pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option>) { if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() && *guard != workspace_id { @@ -1556,7 +1563,7 @@ impl AuthManager { } } - pub fn forced_chatgpt_workspace_id(&self) -> Option { + pub fn forced_chatgpt_workspace_id(&self) -> Option> { self.forced_chatgpt_workspace_id .read() .ok() @@ -1836,13 +1843,13 @@ impl AuthManager { "external auth refresh did not return ChatGPT metadata", ))); }; - if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() - && chatgpt_metadata.account_id != expected_workspace_id + if let Some(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref() + && !expected_workspace_ids.contains(&chatgpt_metadata.account_id) { return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {:?}, expected {expected_workspace_id:?}", - chatgpt_metadata.account_id, + "external auth refresh returned workspace {:?}, expected one of {:?}", + chatgpt_metadata.account_id, expected_workspace_ids, ), ))); } diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index ad0d6b5e5e..b72bc946f2 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -69,7 +69,7 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, - pub forced_chatgpt_workspace_id: Option, + pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } @@ -79,7 +79,7 @@ impl ServerOptions { pub fn new( codex_home: PathBuf, client_id: String, - forced_chatgpt_workspace_id: Option, + forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> Self { Self { @@ -486,7 +486,7 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, - forced_chatgpt_workspace_id: Option<&str>, + forced_chatgpt_workspace_ids: Option<&[String]>, ) -> String { let mut query = vec![ ("response_type".to_string(), "code".to_string()), @@ -507,8 +507,8 @@ fn build_authorize_url( ("state".to_string(), state.to_string()), ("originator".to_string(), originator().value), ]; - if let Some(workspace_id) = forced_chatgpt_workspace_id { - query.push(("allowed_workspace_id".to_string(), workspace_id.to_string())); + if let Some(workspace_ids) = forced_chatgpt_workspace_ids { + query.push(("allowed_workspace_id".to_string(), workspace_ids.join(","))); } let qs = query .into_iter() @@ -928,7 +928,7 @@ fn jwt_auth_claims(jwt: &str) -> serde_json::Map { /// Validates the ID token against an optional workspace restriction. pub(crate) fn ensure_workspace_allowed( - expected: Option<&str>, + expected: Option<&[String]>, id_token: &str, ) -> Result<(), String> { let Some(expected) = expected else { @@ -940,10 +940,13 @@ pub(crate) fn ensure_workspace_allowed( return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); }; - if actual == expected { + if expected.iter().any(|workspace_id| workspace_id == actual) { Ok(()) } else { - Err(format!("Login is restricted to workspace id {expected}.")) + Err(format!( + "Login is restricted to workspace id(s) {}.", + expected.join(", ") + )) } } diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index bed94c7005..e575e6b8c7 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -23,6 +23,9 @@ use core_test_support::skip_if_no_network; // ---------- Small helpers ---------- +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; + fn make_jwt(payload: serde_json::Value) -> String { let header = json!({ "alg": "none", "typ": "JWT" }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); @@ -131,7 +134,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { let jwt = make_jwt(json!({ "https://api.openai.com/auth": { - "chatgpt_account_id": "acct_321" + "chatgpt_account_id": WORKSPACE_ID_ALLOWED } })); @@ -152,7 +155,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { assert_eq!(tokens.access_token, "access-token-123"); assert_eq!(tokens.refresh_token, "refresh-token-123"); assert_eq!(tokens.id_token.raw_jwt, jwt); - assert_eq!(tokens.account_id.as_deref(), Some("acct_321")); + assert_eq!(tokens.account_id.as_deref(), Some(WORKSPACE_ID_ALLOWED)); Ok(()) } @@ -174,8 +177,8 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { let jwt = make_jwt(json!({ "https://api.openai.com/auth": { - "chatgpt_account_id": "acct_321", - "organization_id": "org-actual" + "chatgpt_account_id": WORKSPACE_ID_DISALLOWED, + "organization_id": WORKSPACE_ID_DISALLOWED } })); @@ -183,7 +186,7 @@ async fn device_code_login_rejects_workspace_mismatch() -> anyhow::Result<()> { let issuer = mock_server.uri(); let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); - opts.forced_chatgpt_workspace_id = Some("org-required".to_string()); + opts.forced_chatgpt_workspace_id = Some(vec![WORKSPACE_ID_ALLOWED.to_string()]); let err = run_device_code_login(opts) .await diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index ce58a51358..bc7bb401e8 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -12,10 +12,15 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::ServerOptions; use codex_login::run_login_server; use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; use tempfile::tempdir; +use url::Url; const DEFAULT_LOGIN_PORT: u16 = 1455; const FALLBACK_LOGIN_PORT: u16 = 1457; +const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000"; +const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001"; +const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002"; // See spawn.rs for details @@ -121,7 +126,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { port: 0, open_browser: false, force_state: Some(state), - forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + forced_chatgpt_workspace_id: Some(vec![chatgpt_account_id.to_string()]), codex_streamlined_login: false, }; let server = run_login_server(opts)?; @@ -165,7 +170,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { async fn creates_missing_codex_home_dir() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -204,11 +209,52 @@ async fn creates_missing_codex_home_dir() -> Result<()> { Ok(()) } +#[tokio::test] +async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); + let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); + + let tmp = tempdir()?; + let codex_home = tmp.path().to_path_buf(); + let state = "state-multi".to_string(); + + let opts = ServerOptions { + codex_home, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + client_id: codex_login::CLIENT_ID.to_string(), + issuer, + port: 0, + open_browser: false, + force_state: Some(state), + forced_chatgpt_workspace_id: Some(vec![ + WORKSPACE_ID_ALLOWED.to_string(), + WORKSPACE_ID_SECOND_ALLOWED.to_string(), + ]), + codex_streamlined_login: false, + }; + let server = run_login_server(opts)?; + let auth_url = Url::parse(&server.auth_url)?; + let allowed_workspace_ids = auth_url + .query_pairs() + .filter_map(|(key, value)| (key == "allowed_workspace_id").then(|| value.into_owned())) + .collect::>(); + assert_eq!( + allowed_workspace_ids, + vec![format!( + "{WORKSPACE_ID_ALLOWED},{WORKSPACE_ID_SECOND_ALLOWED}" + )] + ); + + Ok(()) +} + #[tokio::test] async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-actual"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_DISALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -223,14 +269,14 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { port: 0, open_browser: false, force_state: Some(state.clone()), - forced_chatgpt_workspace_id: Some("org-required".to_string()), + forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), codex_streamlined_login: false, }; let server = run_login_server(opts)?; assert!( server .auth_url - .contains("allowed_workspace_id=org-required"), + .contains(&format!("allowed_workspace_id={WORKSPACE_ID_ALLOWED}")), "auth URL should include forced workspace parameter" ); let login_port = server.actual_port; @@ -241,7 +287,9 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { assert!(resp.status().is_success()); let body = resp.text().await?; assert!( - body.contains("Login is restricted to workspace id org-required"), + body.contains(&format!( + "Login is restricted to workspace id(s) {WORKSPACE_ID_ALLOWED}" + )), "error body should mention workspace restriction" ); @@ -266,7 +314,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -334,7 +382,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -442,7 +490,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> }) }; - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let tmp = tempdir()?; @@ -480,7 +528,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { skip_if_no_network!(Ok(())); - let (issuer_addr, _issuer_handle) = start_mock_issuer("org-123"); + let (issuer_addr, _issuer_handle) = start_mock_issuer(WORKSPACE_ID_ALLOWED); let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port()); let first_tmp = tempdir()?; diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 49d8b8eb7e..6394c73ea2 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1141,6 +1141,7 @@ impl SessionTelemetry { ResponseItem::WebSearchCall { .. } => "web_search_call".into(), ResponseItem::ImageGenerationCall { .. } => "image_generation_call".into(), ResponseItem::Compaction { .. } => "compaction".into(), + ResponseItem::CompactionTrigger => "compaction_trigger".into(), ResponseItem::ContextCompaction { .. } => "context_compaction".into(), ResponseItem::Other => "other".into(), } diff --git a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs index ef30fbcd83..aba22a8e80 100644 --- a/codex-rs/otel/tests/suite/otel_export_routing_policy.rs +++ b/codex-rs/otel/tests/suite/otel_export_routing_policy.rs @@ -139,9 +139,11 @@ fn otel_export_routing_policy_routes_user_prompt_log_and_trace_events() { }, UserInput::Image { image_url: "https://example.com/image.png".to_string(), + detail: None, }, UserInput::LocalImage { path: PathBuf::from("/tmp/secret.png"), + detail: None, }, ]); }); diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 499db6fc85..9263d54796 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -1,6 +1,7 @@ use crate::mcp::CallToolResult; use crate::memory_citation::MemoryCitation; use crate::models::ContentItem; +use crate::models::ImageDetail; use crate::models::MessagePhase; use crate::models::ResponseItem; use crate::models::WebSearchAction; @@ -243,7 +244,9 @@ impl UserMessageItem { EventMsg::UserMessage(UserMessageEvent { message: self.message(), images: Some(self.image_urls()), + image_details: self.image_details(), local_images: self.local_image_paths(), + local_image_details: self.local_image_details(), text_elements: self.text_elements(), }) } @@ -290,21 +293,54 @@ impl UserMessageItem { self.content .iter() .filter_map(|c| match c { - UserInput::Image { image_url } => Some(image_url.clone()), + UserInput::Image { image_url, .. } => Some(image_url.clone()), _ => None, }) .collect() } + pub fn image_details(&self) -> Vec> { + trim_trailing_default_image_details( + self.content + .iter() + .filter_map(|c| match c { + UserInput::Image { detail, .. } => Some(*detail), + _ => None, + }) + .collect(), + ) + } + pub fn local_image_paths(&self) -> Vec { self.content .iter() .filter_map(|c| match c { - UserInput::LocalImage { path } => Some(path.clone()), + UserInput::LocalImage { path, .. } => Some(path.clone()), _ => None, }) .collect() } + + pub fn local_image_details(&self) -> Vec> { + trim_trailing_default_image_details( + self.content + .iter() + .filter_map(|c| match c { + UserInput::LocalImage { detail, .. } => Some(*detail), + _ => None, + }) + .collect(), + ) + } +} + +fn trim_trailing_default_image_details( + mut details: Vec>, +) -> Vec> { + while matches!(details.last(), Some(None)) { + details.pop(); + } + details } impl HookPromptItem { diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6919ee43e7..5335f7c46b 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -344,21 +344,6 @@ pub struct ActivePermissionProfile { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub extends: Option, - - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub modifications: Vec, -} - -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "snake_case")] - #[ts(rename_all = "snake_case")] - AdditionalWritableRoot { path: AbsolutePathBuf }, } impl ActivePermissionProfile { @@ -366,16 +351,11 @@ impl ActivePermissionProfile { Self { id: id.into(), extends: None, - modifications: Vec::new(), } } - pub fn with_modifications( - mut self, - modifications: Vec, - ) -> Self { - self.modifications = modifications; - self + pub fn read_only() -> Self { + Self::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) } } @@ -444,6 +424,28 @@ impl PermissionProfile { } } + pub fn materialize_project_roots_with_workspace_roots( + self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + match self { + Self::Managed { + file_system, + network, + } => { + let file_system = file_system + .to_sandbox_policy() + .materialize_project_roots_with_workspace_roots(workspace_roots); + Self::Managed { + file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system), + network, + } + } + Self::Disabled => Self::Disabled, + Self::External { network } => Self::External { network }, + } + } + pub fn from_runtime_permissions( file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, @@ -723,8 +725,6 @@ pub enum ContentItem { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { - Auto, - Low, High, Original, } @@ -889,7 +889,10 @@ pub enum ResponseItem { result: String, }, #[serde(alias = "compaction_summary")] - Compaction { encrypted_content: String }, + Compaction { + encrypted_content: String, + }, + CompactionTrigger, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1063,8 +1066,13 @@ pub fn local_image_content_items_with_label_number( path: &std::path::Path, file_bytes: Vec, label_number: Option, - mode: PromptImageMode, + detail: ImageDetail, ) -> Vec { + let mode = match detail { + ImageDetail::Original => PromptImageMode::Original, + ImageDetail::High => PromptImageMode::ResizeToFit, + }; + match load_for_prompt_bytes(path, file_bytes, mode) { Ok(image) => { let mut items = Vec::with_capacity(3); @@ -1075,7 +1083,7 @@ pub fn local_image_content_items_with_label_number( } items.push(ContentItem::InputImage { image_url: image.into_data_url(), - detail: Some(DEFAULT_IMAGE_DETAIL), + detail: Some(detail), }); if label_number.is_some() { items.push(ContentItem::InputText { @@ -1220,29 +1228,31 @@ impl From> for ResponseInputItem { .into_iter() .flat_map(|c| match c { UserInput::Text { text, .. } => vec![ContentItem::InputText { text }], - UserInput::Image { image_url } => { + UserInput::Image { image_url, detail } => { image_index += 1; + let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); vec![ ContentItem::InputText { text: image_open_tag_text(), }, ContentItem::InputImage { image_url, - detail: Some(DEFAULT_IMAGE_DETAIL), + detail: Some(detail), }, ContentItem::InputText { text: image_close_tag_text(), }, ] } - UserInput::LocalImage { path } => { + UserInput::LocalImage { path, detail } => { image_index += 1; + let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); match std::fs::read(&path) { Ok(file_bytes) => local_image_content_items_with_label_number( &path, file_bytes, Some(image_index), - PromptImageMode::ResizeToFit, + detail, ), Err(err) => vec![local_image_error_placeholder(&path, err)], } @@ -1586,8 +1596,6 @@ fn convert_mcp_content_to_items( .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(serde_json::Value::as_str) .and_then(|detail| match detail { - "auto" => Some(ImageDetail::Auto), - "low" => Some(ImageDetail::Low), "high" => Some(ImageDetail::High), "original" => Some(ImageDetail::Original), _ => None, @@ -1632,6 +1640,14 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + // A tiny valid PNG (1x1) so image conversion tests don't depend on cross-crate + // file paths, which break under Bazel sandboxing. + const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, + 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, + 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]; + #[test] fn response_input_message_conversion_preserves_phase() { let item = ResponseItem::from(ResponseInputItem::Message { @@ -2409,20 +2425,28 @@ mod tests { } #[test] - fn serializes_context_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::ContextCompaction { - encrypted_content: None, - }; + fn serializes_compaction_trigger_without_payload() -> Result<()> { + let item = ResponseItem::CompactionTrigger; assert_eq!( serde_json::to_value(item)?, serde_json::json!({ - "type": "context_compaction", + "type": "compaction_trigger", }) ); Ok(()) } + #[test] + fn deserializes_compaction_trigger_without_payload() -> Result<()> { + let json = r#"{"type":"compaction_trigger"}"#; + + let item: ResponseItem = serde_json::from_str(json)?; + + assert_eq!(item, ResponseItem::CompactionTrigger); + Ok(()) + } + #[test] fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> { let json = r#"{ @@ -2536,6 +2560,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::Image { image_url: image_url.clone(), + detail: None, }]); match item { @@ -2560,6 +2585,31 @@ mod tests { Ok(()) } + #[test] + fn image_user_input_preserves_requested_detail() -> Result<()> { + let image_url = "data:image/png;base64,abc".to_string(); + + let item = ResponseInputItem::from(vec![UserInput::Image { + image_url: image_url.clone(), + detail: Some(ImageDetail::Original), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert_eq!( + content.get(1), + Some(&ContentItem::InputImage { + image_url, + detail: Some(ImageDetail::Original), + }) + ); + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } + #[test] fn tool_search_call_roundtrips() -> Result<()> { let parsed: ResponseItem = serde_json::from_str( @@ -2728,20 +2778,17 @@ mod tests { let image_url = "data:image/png;base64,abc".to_string(); let dir = tempdir()?; let local_path = dir.path().join("local.png"); - // A tiny valid PNG (1x1) so this test doesn't depend on cross-crate file paths, which - // break under Bazel sandboxing. - const TINY_PNG_BYTES: &[u8] = &[ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, - 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, - 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, - ]; std::fs::write(&local_path, TINY_PNG_BYTES)?; let item = ResponseInputItem::from(vec![ UserInput::Image { image_url: image_url.clone(), + detail: None, + }, + UserInput::LocalImage { + path: local_path, + detail: None, }, - UserInput::LocalImage { path: local_path }, ]); match item { @@ -2788,6 +2835,33 @@ mod tests { Ok(()) } + #[test] + fn local_image_user_input_preserves_requested_detail() -> Result<()> { + let dir = tempdir()?; + let local_path = dir.path().join("local.png"); + std::fs::write(&local_path, TINY_PNG_BYTES)?; + + let item = ResponseInputItem::from(vec![UserInput::LocalImage { + path: local_path, + detail: Some(ImageDetail::Original), + }]); + + match item { + ResponseInputItem::Message { content, .. } => { + assert!(matches!( + content.get(1), + Some(ContentItem::InputImage { + detail: Some(ImageDetail::Original), + .. + }) + )); + } + other => panic!("expected message response but got {other:?}"), + } + + Ok(()) + } + #[test] fn local_image_read_error_adds_placeholder() -> Result<()> { let dir = tempdir()?; @@ -2795,6 +2869,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::LocalImage { path: missing_path.clone(), + detail: None, }]); match item { @@ -2829,6 +2904,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::LocalImage { path: json_path.clone(), + detail: None, }]); match item { @@ -2866,6 +2942,7 @@ mod tests { let item = ResponseInputItem::from(vec![UserInput::LocalImage { path: svg_path.clone(), + detail: None, }]); match item { diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index e6d9503ea1..1d8a1e707e 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -350,6 +350,12 @@ pub enum FileSystemPath { }, } +const PROJECT_ROOTS_GLOB_PATTERN_PREFIX: &str = "codex-project-roots://"; + +pub fn project_roots_glob_pattern(subpath: &Path) -> String { + format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display()) +} + impl Default for FileSystemSandboxPolicy { fn default() -> Self { Self { @@ -703,15 +709,100 @@ impl FileSystemSandboxPolicy { pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self { let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok(); for entry in &mut self.entries { - let FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { .. }, - } = &entry.path - else { - continue; - }; + match &entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { .. }, + } => { + if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { + entry.path = FileSystemPath::Path { path }; + } + } + FileSystemPath::GlobPattern { pattern } => { + if let (Some(cwd), Some(subpath)) = + (cwd.as_ref(), parse_project_roots_glob_pattern(pattern)) + { + entry.path = FileSystemPath::GlobPattern { + pattern: resolve_project_roots_glob_pattern(subpath, cwd), + }; + } + } + FileSystemPath::Special { value: _ } => {} + FileSystemPath::Path { .. } => {} + } + } + self + } - if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) { - entry.path = FileSystemPath::Path { path }; + /// Replaces symbolic `:workspace_roots` entries with concrete entries for + /// each workspace root. + pub fn materialize_project_roots_with_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in self.entries { + match entry.path { + FileSystemPath::Special { + value: FileSystemSpecialPath::ProjectRoots { subpath }, + } => { + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: match subpath.as_ref() { + Some(subpath) => AbsolutePathBuf::resolve_path_against_base( + subpath, + root.as_path(), + ), + None => root.clone(), + }, + }, + access: entry.access, + })); + } + FileSystemPath::GlobPattern { pattern } => { + if let Some(subpath) = parse_project_roots_glob_pattern(&pattern) { + entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: resolve_project_roots_glob_pattern(subpath, root), + }, + access: entry.access, + })); + } else { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { pattern }, + access: entry.access, + }); + } + } + FileSystemPath::Path { path } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Path { path }, + access: entry.access, + }); + } + FileSystemPath::Special { value } => { + entries.push(FileSystemSandboxEntry { + path: FileSystemPath::Special { value }, + access: entry.access, + }); + } + } + } + self.entries = entries; + self + } + + /// Preserves symbolic `:workspace_roots` entries while also adding concrete + /// entries for each provided workspace root. + pub fn with_materialized_project_roots_for_workspace_roots( + mut self, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let materialized = self + .clone() + .materialize_project_roots_with_workspace_roots(workspace_roots); + for entry in materialized.entries { + if !self.entries.contains(&entry) { + self.entries.push(entry); } } self @@ -1209,6 +1300,18 @@ fn resolve_entry_path( } } +fn parse_project_roots_glob_pattern(pattern: &str) -> Option<&Path> { + pattern + .strip_prefix(PROJECT_ROOTS_GLOB_PATTERN_PREFIX) + .map(Path::new) +} + +fn resolve_project_roots_glob_pattern(subpath: &Path, root: &AbsolutePathBuf) -> String { + AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path()) + .to_string_lossy() + .into_owned() +} + fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option { if path.is_absolute() { AbsolutePathBuf::from_absolute_path(path).ok() @@ -2750,6 +2853,115 @@ mod tests { ); } + #[test] + fn materialize_project_roots_with_workspace_roots_expands_exact_and_glob_entries() { + let temp_dir = TempDir::new().expect("tempdir"); + let first = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("first")) + .expect("resolve first root"); + let second = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("second")) + .expect("resolve second root"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(".git".into())), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::None, + }, + ]); + + let actual = + policy.materialize_project_roots_with_workspace_roots(&[first.clone(), second.clone()]); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: first.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: second.clone(), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: first.join(".git"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: second.join(".git"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base( + "**/*.env", + first.as_path(), + ) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base( + "**/*.env", + second.as_path(), + ) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }, + ]) + ); + } + + #[test] + fn materialize_project_roots_with_cwd_expands_symbolic_glob_entries() { + let cwd = TempDir::new().expect("tempdir"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("**/*.env")), + }, + access: FileSystemAccessMode::None, + }]); + + let actual = policy.materialize_project_roots_with_cwd(cwd.path()); + + assert_eq!( + actual, + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) + .to_string_lossy() + .into_owned(), + }, + access: FileSystemAccessMode::None, + }]) + ); + } + #[test] fn with_additional_legacy_workspace_writable_roots_protects_metadata() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 196c6d7d61..ace487eefa 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -34,6 +34,7 @@ use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; +use crate::models::ImageDetail; use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; @@ -403,6 +404,16 @@ pub struct TurnContextOverrides { #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, + /// Updated runtime workspace roots used to materialize symbolic + /// `:workspace_roots` filesystem permissions. + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_roots: Option>, + + /// Updated profile-defined workspace roots for status summaries and + /// per-turn config reconstruction. + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_workspace_roots: Option>, + /// Updated command approval policy. #[serde(skip_serializing_if = "Option::is_none")] pub approval_policy: Option, @@ -2199,7 +2210,7 @@ pub struct AgentMessageEvent { pub memory_citation: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] pub struct UserMessageEvent { pub message: String, /// Image URLs sourced from `UserInput::Image`. These are safe @@ -2207,11 +2218,19 @@ pub struct UserMessageEvent { /// the model. #[serde(skip_serializing_if = "Option::is_none")] pub images: Option>, + /// Detail hints for `images`, indexed in parallel. Missing entries imply + /// default image detail behavior. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub image_details: Vec>, /// Local file paths sourced from `UserInput::LocalImage`. These are kept so /// the UI can reattach images when editing history, and should not be sent /// to the model or treated as API-ready URLs. #[serde(default)] pub local_images: Vec, + /// Detail hints for `local_images`, indexed in parallel. Missing entries + /// imply default image detail behavior. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub local_image_details: Vec>, /// UI-defined spans within `message` used to render or persist special elements. #[serde(default)] pub text_elements: Vec, @@ -5107,6 +5126,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }; let json_event = serde_json::to_value(event)?; @@ -5122,6 +5142,62 @@ mod tests { Ok(()) } + #[test] + fn user_message_event_deserializes_without_image_detail_fields() -> Result<()> { + let event: UserMessageEvent = serde_json::from_value(json!({ + "message": "hello", + "images": ["https://example.com/image.png"], + "local_images": ["/tmp/local.png"], + "text_elements": [], + }))?; + + assert_eq!(event.message, "hello"); + assert_eq!( + event.images, + Some(vec!["https://example.com/image.png".to_string()]) + ); + assert_eq!(event.image_details, Vec::>::new()); + assert_eq!(event.local_images, vec![PathBuf::from("/tmp/local.png")]); + assert_eq!(event.local_image_details, Vec::>::new()); + assert_eq!(event.text_elements, Vec::new()); + + Ok(()) + } + + #[test] + fn user_message_item_legacy_event_preserves_image_details() { + let local_path = PathBuf::from("/tmp/local.png"); + let item = UserMessageItem::new(&[ + crate::user_input::UserInput::Image { + image_url: "https://example.com/first.png".to_string(), + detail: Some(ImageDetail::Original), + }, + crate::user_input::UserInput::Image { + image_url: "https://example.com/second.png".to_string(), + detail: None, + }, + crate::user_input::UserInput::LocalImage { + path: local_path.clone(), + detail: Some(ImageDetail::Original), + }, + ]); + + let EventMsg::UserMessage(event) = item.as_legacy_event() else { + panic!("expected user message event"); + }; + + assert_eq!( + event.images, + Some(vec![ + "https://example.com/first.png".to_string(), + "https://example.com/second.png".to_string(), + ]) + ); + assert_eq!(event.image_details, vec![Some(ImageDetail::Original)]); + assert_eq!(event.local_images, vec![local_path]); + assert_eq!(event.local_image_details, vec![Some(ImageDetail::Original)]); + } + #[test] fn turn_aborted_event_deserializes_without_turn_id() -> Result<()> { let event: EventMsg = serde_json::from_value(json!({ diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 4ed112df8d..ce4cf99eba 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -3,6 +3,8 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +use crate::models::ImageDetail; + /// Conservative cap so one user message cannot monopolize a large context window. pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20; @@ -21,11 +23,21 @@ pub enum UserInput { text_elements: Vec, }, /// Pre‑encoded data: URI image. - Image { image_url: String }, + Image { + image_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + detail: Option, + }, /// Local image path provided by the user. This will be converted to an /// `Image` variant (base64 data URL) during request serialization. - LocalImage { path: std::path::PathBuf }, + LocalImage { + path: std::path::PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + detail: Option, + }, /// Skill selected by the user (name + path to SKILL.md). Skill { diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 3b6b05a0fb..e9be01df25 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -79,6 +79,7 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Other => false, } } @@ -99,6 +100,7 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => false, } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index ede7f720a9..8199f290d8 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -114,6 +114,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), }, ]; @@ -404,6 +405,7 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]) .await?; diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 22f9a73a4e..bcd395d820 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1404,6 +1404,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), }; writeln!(file, "{}", serde_json::to_string(&user_event_line)?)?; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index d815d444ce..886d267e4f 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -205,6 +205,7 @@ mod tests { images: Some(vec![]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -225,6 +226,7 @@ mod tests { images: Some(vec!["https://example.com/image.png".to_string()]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -248,6 +250,7 @@ mod tests { images: Some(vec![]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &item, "test-provider"); @@ -287,6 +290,7 @@ mod tests { images: Some(vec![]), local_images: vec![], text_elements: vec![], + ..Default::default() })); apply_rollout_item(&mut metadata, &user_item, "test-provider"); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index fff914eff3..8a168bad13 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -41,7 +41,6 @@ use codex_core_api::RealtimeAudioConfig; use codex_core_api::RealtimeConfig; use codex_core_api::SessionPickerViewMode; use codex_core_api::SessionSource; -use codex_core_api::ShellEnvironmentPolicy; use codex_core_api::TerminalResizeReflowConfig; use codex_core_api::ThreadManager; use codex_core_api::ThreadStoreConfig; @@ -172,16 +171,10 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_provider_id, model_provider, personality: None, - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: None, - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, + permissions: Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::Never), + Constrained::allow_any(PermissionProfile::read_only()), + )?, approvals_reviewer: ApprovalsReviewer::User, enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, @@ -213,7 +206,9 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_keymap: TuiKeymap::default(), tui_session_picker_view: SessionPickerViewMode::Dense, tui_vim_mode_default: false, - cwd, + cwd: cwd.clone(), + workspace_roots: vec![cwd], + workspace_roots_explicit: false, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::File, @@ -251,6 +246,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, @@ -262,7 +258,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R experimental_thread_store: ThreadStoreConfig::Local, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: false, web_search_mode: Constrained::allow_any(WebSearchMode::Disabled), web_search_config: None, use_experimental_unified_exec_tool: false, @@ -273,7 +268,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R suppress_unstable_features_warning: false, active_profile: None, active_project: ProjectConfig { trust_level: None }, - windows_wsl_setup_acknowledged: false, notices: Notice::default(), check_for_update_on_startup: false, disable_paste_burst: false, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index ab53e12bbe..f6a48a400b 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1036,6 +1036,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })) } diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index c09ca80f97..16f9f79e06 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -14,6 +14,7 @@ use codex_rollout::find_archived_thread_path_by_id_str; use codex_rollout::find_thread_path_by_id_str; use codex_rollout::read_session_meta_line; use codex_state::ThreadMetadataBuilder; +use tracing::warn; use super::LocalThreadStore; use super::helpers::git_info_from_parts; @@ -51,8 +52,15 @@ pub(super) async fn update_thread_metadata( } let needs_rollout_compat = needs_rollout_compatibility_update(&patch); - let updated = - apply_metadata_update(store, thread_id, patch.clone(), params.include_archived).await?; + let require_sqlite_write = sqlite_write_failure_should_block(&patch); + let updated = apply_metadata_update( + store, + thread_id, + patch.clone(), + params.include_archived, + require_sqlite_write, + ) + .await?; if !needs_rollout_compat { return Ok(updated); } @@ -169,6 +177,7 @@ async fn apply_metadata_update( thread_id: ThreadId, patch: ThreadMetadataPatch, include_archived: bool, + require_sqlite_write: bool, ) -> ThreadStoreResult { let live_rollout_path = live_writer::rollout_path(store, thread_id).await.ok(); let mut rollout_path = patch.rollout_path.clone().or(live_rollout_path); @@ -316,9 +325,19 @@ async fn apply_metadata_update( }; match (state_db.is_some(), sqlite_write_result) { (true, Ok(())) => {} - (true, Err(err)) => return Err(err), + (true, Err(err)) if require_sqlite_write || !sqlite_write_error_is_best_effort(&err) => { + return Err(err); + } + (true, Err(err)) => { + warn!("state db update_thread_metadata failed for {thread_id}: {err}"); + } (false, Ok(())) => {} - (false, Err(err)) => return Err(err), + (false, Err(err)) if require_sqlite_write || !sqlite_write_error_is_best_effort(&err) => { + return Err(err); + } + (false, Err(err)) => { + warn!("state db update_thread_metadata failed for {thread_id}: {err}"); + } } read_thread::read_thread( @@ -342,6 +361,19 @@ fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool { !has_observed_metadata_facts(patch) } +fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool { + // Before live metadata sync moved above the rollout writer, SQLite sync failures for + // transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that + // failure isolation so a corrupted optional state DB does not make JSONL transcript durability + // look broken. Explicit git-only updates still require SQLite because partial git patches need + // the existing SQLite value to preserve unspecified fields. + patch.git_info.is_some() && !has_observed_metadata_facts(patch) +} + +fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool { + matches!(err, ThreadStoreError::Internal { .. }) +} + fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { patch.rollout_path.is_some() || patch.preview.is_some() @@ -1136,6 +1168,46 @@ mod tests { assert_eq!(memory_mode.as_deref(), Some("disabled")); } + #[test] + fn sqlite_failures_are_best_effort_for_legacy_rollout_compat_updates() { + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + name: Some(Some("User chosen name".to_string())), + ..Default::default() + })); + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + memory_mode: Some(ThreadMemoryMode::Disabled), + ..Default::default() + })); + } + + #[test] + fn sqlite_failures_are_best_effort_for_observed_metadata_updates() { + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + updated_at: Some(Utc::now()), + ..Default::default() + })); + assert!(!sqlite_write_failure_should_block(&ThreadMetadataPatch { + preview: Some("Observed preview".to_string()), + git_info: Some(GitInfoPatch { + branch: Some(Some("main".to_string())), + ..Default::default() + }), + memory_mode: Some(ThreadMemoryMode::Enabled), + ..Default::default() + })); + } + + #[test] + fn sqlite_failures_still_block_for_explicit_git_only_updates() { + assert!(sqlite_write_failure_should_block(&ThreadMetadataPatch { + git_info: Some(GitInfoPatch { + branch: Some(Some("main".to_string())), + ..Default::default() + }), + ..Default::default() + })); + } + #[tokio::test] async fn metadata_patch_applies_title_over_existing_name() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 37afda8bc2..edc78a36c2 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -546,6 +546,7 @@ mod tests { images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() } } diff --git a/codex-rs/tools/src/image_detail.rs b/codex-rs/tools/src/image_detail.rs index 37086f691d..145cda663c 100644 --- a/codex-rs/tools/src/image_detail.rs +++ b/codex-rs/tools/src/image_detail.rs @@ -16,7 +16,7 @@ pub fn normalize_output_image_detail( Some(ImageDetail::Original) } Some(ImageDetail::Original) | None => None, - Some(ImageDetail::Auto | ImageDetail::Low | ImageDetail::High) => detail, + Some(ImageDetail::High) => Some(ImageDetail::High), } } diff --git a/codex-rs/tools/src/image_detail_tests.rs b/codex-rs/tools/src/image_detail_tests.rs index 393a962ac4..919537acf7 100644 --- a/codex-rs/tools/src/image_detail_tests.rs +++ b/codex-rs/tools/src/image_detail_tests.rs @@ -70,10 +70,6 @@ fn explicit_original_is_dropped_without_model_support() { fn explicit_non_original_detail_is_preserved() { let model_info = model_info(); - assert_eq!( - normalize_output_image_detail(&model_info, Some(ImageDetail::Low)), - Some(ImageDetail::Low) - ); assert_eq!( normalize_output_image_detail(&model_info, Some(ImageDetail::High)), Some(ImageDetail::High) @@ -92,7 +88,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::Low), + detail: Some(ImageDetail::High), }, ]; @@ -110,7 +106,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::Low), + detail: Some(ImageDetail::High), }, ] ); diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index ad884e5be0..6e3ce15526 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -175,7 +175,6 @@ impl ToolsConfig { session_source, .. } = params; - let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_code_mode = features.enabled(Feature::CodeMode); let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); let include_goal_tools = features.enabled(Feature::Goals); @@ -225,10 +224,7 @@ impl ToolsConfig { model_shell_type }; - let apply_patch_tool_type = model_info - .apply_patch_tool_type - .clone() - .or_else(|| include_apply_patch_tool.then_some(ApplyPatchToolType::Freeform)); + let apply_patch_tool_type = model_info.apply_patch_tool_type.clone(); let agent_jobs_worker_tools = include_agent_jobs && matches!( diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 252ad7a320..2fa49b03bf 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -4,7 +4,6 @@ use codex_features::Features; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; -use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; @@ -156,7 +155,7 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { } #[test] -fn fallback_apply_patch_models_use_freeform_tool_by_default() { +fn fallback_apply_patch_models_do_not_use_freeform_tool_by_default() { let model_info = model_info(); let features = Features::with_defaults(); @@ -172,10 +171,7 @@ fn fallback_apply_patch_models_use_freeform_tool_by_default() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); - assert_eq!( - tools_config.apply_patch_tool_type, - Some(ApplyPatchToolType::Freeform) - ); + assert_eq!(tools_config.apply_patch_tool_type, None); } #[test] diff --git a/codex-rs/tools/src/tool_executor.rs b/codex-rs/tools/src/tool_executor.rs index 62237740ba..2557dc3490 100644 --- a/codex-rs/tools/src/tool_executor.rs +++ b/codex-rs/tools/src/tool_executor.rs @@ -36,8 +36,6 @@ impl ToolExposure { /// top without reopening the spec/runtime split. #[async_trait::async_trait] pub trait ToolExecutor: Send + Sync { - type Output: ToolOutput + 'static; - /// The concrete tool name handled by this runtime instance. fn tool_name(&self) -> ToolName; @@ -53,5 +51,8 @@ pub trait ToolExecutor: Send + Sync { false } - async fn handle(&self, invocation: Invocation) -> Result; + async fn handle( + &self, + invocation: Invocation, + ) -> Result, FunctionCallError>; } diff --git a/codex-rs/tools/src/tool_output.rs b/codex-rs/tools/src/tool_output.rs index 2044295174..64a8164da3 100644 --- a/codex-rs/tools/src/tool_output.rs +++ b/codex-rs/tools/src/tool_output.rs @@ -20,6 +20,16 @@ pub trait ToolOutput: Send { fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; + /// Returns the tool call id exposed to `PostToolUse` hooks for this output. + fn post_tool_use_id(&self, call_id: &str) -> String { + call_id.to_string() + } + + /// Returns the tool input exposed to `PostToolUse` hooks for this output. + fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option { + None + } + /// Returns the stable value exposed to `PostToolUse` hooks for this tool output. /// /// Tool handlers decide whether a tool participates in `PostToolUse`, but @@ -52,6 +62,14 @@ where (**self).to_response_item(call_id, payload) } + fn post_tool_use_id(&self, call_id: &str) -> String { + (**self).post_tool_use_id(call_id) + } + + fn post_tool_use_input(&self, payload: &ToolPayload) -> Option { + (**self).post_tool_use_input(payload) + } + fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option { (**self).post_tool_use_response(call_id, payload) } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index abd1ccb5c6..c213e92bae 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -52,6 +52,7 @@ codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-realtime-webrtc = { workspace = true } codex-rollout = { workspace = true } +codex-sandboxing = { workspace = true } codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } @@ -60,6 +61,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-elapsed = { workspace = true } codex-utils-fuzzy-match = { workspace = true } +codex-utils-home-dir = { workspace = true } codex-utils-oss = { workspace = true } codex-utils-path = { workspace = true } codex-utils-plugins = { workspace = true } diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index b503b13112..6d94509a4f 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -46,14 +46,13 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; + use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::path::Path; use std::path::PathBuf; @@ -80,10 +79,9 @@ mod tests { #[test] fn returns_none_for_external_sandbox() { - let profile: PermissionProfile = AppServerPermissionProfile::External { - network: PermissionProfileNetworkPermissions { enabled: true }, - } - .into(); + let profile: PermissionProfile = PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, + }; let dirs = vec![PathBuf::from("/tmp/example")]; assert_eq!( add_dir_warning_message(&dirs, &profile, Path::new("/tmp/project")), @@ -105,9 +103,9 @@ mod tests { #[test] fn warns_when_profile_can_write_elsewhere_but_not_cwd() { - let profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -124,8 +122,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; let dirs = vec![PathBuf::from("/tmp/extra")]; assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7a74e0f956..46a7be00dd 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,7 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; +use crate::app_server_session::TurnPermissionsOverride; use crate::app_server_session::app_server_rate_limit_snapshots; use crate::bottom_pane::AppLinkViewParams; use crate::bottom_pane::ApprovalRequest; @@ -46,6 +47,7 @@ use crate::keymap::RuntimeKeymap; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; +use crate::legacy_core::config::PermissionProfileSnapshot; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] @@ -139,6 +141,8 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; #[cfg(target_os = "windows")] use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; @@ -149,6 +153,7 @@ use codex_protocol::permissions::FileSystemSandboxKind; use codex_rollout::StateDbHandle; use codex_terminal_detection::user_agent; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_approval_presets::builtin_permission_profile_for_active_permission_profile; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -326,7 +331,7 @@ fn default_exec_approval_decisions( struct AutoReviewMode { approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, - permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, } /// Enabling the Auto-review experiment in the TUI should also switch the @@ -337,7 +342,17 @@ fn auto_review_mode() -> AutoReviewMode { AutoReviewMode { approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, - permission_profile: PermissionProfile::workspace_write(), + active_permission_profile: ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + ), + } +} + +#[cfg(test)] +impl AutoReviewMode { + fn permission_profile(&self) -> PermissionProfile { + builtin_permission_profile_for_active_permission_profile(&self.active_permission_profile) + .expect("auto-review mode should use a built-in permission profile") } } @@ -397,8 +412,7 @@ fn session_summary( let usage_line = (!token_usage.is_zero()).then(|| token_usage.to_string()); let thread_id = resumable_thread(thread_id, thread_name, rollout_path).map(|thread| thread.thread_id); - let resume_command = - crate::legacy_core::util::resume_command(/*thread_name*/ None, thread_id); + let resume_command = codex_utils_cli::resume_command(/*thread_name*/ None, thread_id); if usage_line.is_none() && resume_command.is_none() { return None; @@ -958,7 +972,7 @@ See the Codex keymap documentation for supported actions and examples." // world-writable dirs on Windows. #[cfg(target_os = "windows")] { - let startup_permission_profile = app.config.permissions.permission_profile(); + let startup_permission_profile = app.config.permissions.effective_permission_profile(); let should_check = WindowsSandboxLevel::from_config(&app.config) != WindowsSandboxLevel::Disabled && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 26cf16a9e0..6f4fda608e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -105,24 +105,41 @@ impl App { true } - pub(super) fn try_set_permission_profile_on_config( + pub(super) fn try_set_builtin_active_permission_profile_on_config( &mut self, config: &mut Config, - permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, user_message_prefix: &str, log_message: &str, - ) -> bool { + ) -> Option { + let Some(permission_profile) = + builtin_permission_profile_for_active_permission_profile(&active_permission_profile) + else { + tracing::warn!( + id = %active_permission_profile.id, + "{log_message}: unsupported active permission profile" + ); + self.chat_widget.add_error_message(format!( + "{user_message_prefix}: unsupported active permission profile `{}`", + active_permission_profile.id + )); + return None; + }; + if let Err(err) = config .permissions - .set_permission_profile(permission_profile) + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + permission_profile.clone(), + active_permission_profile, + )) { tracing::warn!(error = %err, "{log_message}"); self.chat_widget .add_error_message(format!("{user_message_prefix}: {err}")); - return false; + return None; } - true + Some(permission_profile) } pub(super) async fn update_feature_flags(&mut self, updates: Vec<(Feature, bool)>) { @@ -149,6 +166,7 @@ impl App { let mut approval_policy_override = None; let mut approvals_reviewer_override = None; let mut permission_profile_override = None; + let mut active_permission_profile_override = None; let mut feature_updates_to_apply = Vec::with_capacity(updates.len()); // Auto-Review owns `approvals_reviewer`, but disabling the feature // from inside a profile should not silently clear a value configured at @@ -240,14 +258,16 @@ impl App { ) { continue; } - if !self.try_set_permission_profile_on_config( - &mut feature_config, - auto_review_preset.permission_profile.clone(), - "Failed to enable Auto-review", - "failed to set auto-review permission profile on staged config", - ) { + let Some(permission_profile) = self + .try_set_builtin_active_permission_profile_on_config( + &mut feature_config, + auto_review_preset.active_permission_profile.clone(), + "Failed to enable Auto-review", + "failed to set auto-review permission profile on staged config", + ) + else { continue; - } + }; feature_edits.extend([ ConfigEdit::SetPath { segments: scoped_segments("approval_policy"), @@ -259,7 +279,9 @@ impl App { }, ]); approval_policy_override = Some(auto_review_preset.approval_policy); - permission_profile_override = Some(auto_review_preset.permission_profile.clone()); + permission_profile_override = Some(permission_profile); + active_permission_profile_override = + Some(auto_review_preset.active_permission_profile.clone()); } next_config = feature_config; feature_updates_to_apply.push((feature, effective_enabled)); @@ -299,10 +321,18 @@ impl App { self.config.permissions.approval_policy.value(), )); } - if permission_profile_override.is_some() + let permission_profile_override_value = permission_profile_override + .is_some() + .then(|| self.config.permissions.permission_profile().clone()); + if let Some(permission_profile) = permission_profile_override_value.as_ref() && let Err(err) = self .chat_widget - .set_permission_profile(self.config.permissions.permission_profile()) + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::from_session_snapshot( + permission_profile.clone(), + active_permission_profile_override.clone(), + ), + ) { tracing::error!( error = %err, @@ -311,9 +341,8 @@ impl App { self.chat_widget .add_error_message(format!("Failed to enable Auto-review: {err}")); } - if permission_profile_override.is_some() { - self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile()); + if let Some(permission_profile) = permission_profile_override_value { + self.runtime_permission_profile_override = Some(permission_profile); } if approval_policy_override.is_some() @@ -331,7 +360,7 @@ impl App { /*cwd*/ None, approval_policy_override, approvals_reviewer_override, - permission_profile_override, + active_permission_profile_override, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, @@ -358,7 +387,7 @@ impl App { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, @@ -659,6 +688,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: next_cwd.clone().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bff4479f06..15e4f9b6cd 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1111,7 +1111,7 @@ impl App { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, @@ -1136,7 +1136,7 @@ impl App { /*cwd*/ None, Some(AskForApproval::from(preset.approval)), Some(self.config.approvals_reviewer), - Some(preset.permission_profile.clone()), + Some(preset.active_permission_profile.clone()), #[cfg(target_os = "windows")] Some(windows_sandbox_level), /*model*/ None, @@ -1150,9 +1150,10 @@ impl App { self.app_event_tx.send(AppEvent::UpdateAskForApprovalPolicy( AskForApproval::from(preset.approval), )); - self.app_event_tx.send(AppEvent::UpdatePermissionProfile( - preset.permission_profile.clone(), - )); + self.app_event_tx + .send(AppEvent::UpdateActivePermissionProfile( + preset.active_permission_profile.clone(), + )); let _ = mode; self.chat_widget.add_plain_history_lines(vec![ Line::from(vec!["• ".dim(), "Sandbox ready".into()]), @@ -1400,25 +1401,32 @@ impl App { self.sync_active_thread_permission_settings_to_cached_session() .await; } - AppEvent::UpdatePermissionProfile(permission_profile) => { + AppEvent::UpdateActivePermissionProfile(active_permission_profile) => { + let mut config = self.config.clone(); + let Some(permission_profile) = self + .try_set_builtin_active_permission_profile_on_config( + &mut config, + active_permission_profile.clone(), + "Failed to set permission profile", + "failed to set active permission profile on app config", + ) + else { + return Ok(AppRunControl::Continue); + }; #[cfg(target_os = "windows")] let permission_profile_is_managed_restricted = managed_filesystem_sandbox_is_restricted(&permission_profile); let permission_profile_for_chat = permission_profile.clone(); - let mut config = self.config.clone(); - if !self.try_set_permission_profile_on_config( - &mut config, - permission_profile, - "Failed to set permission profile", - "failed to set permission profile on app config", - ) { - return Ok(AppRunControl::Continue); - } self.config = config; if let Err(err) = self .chat_widget - .set_permission_profile(permission_profile_for_chat) + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::active( + permission_profile_for_chat, + active_permission_profile, + ), + ) { tracing::warn!(%err, "failed to set permission profile on chat config"); self.chat_widget @@ -1426,7 +1434,7 @@ impl App { return Ok(AppRunControl::Continue); } self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile()); + Some(self.config.permissions.permission_profile().clone()); self.sync_active_thread_permission_settings_to_cached_session() .await; @@ -1450,7 +1458,8 @@ impl App { std::env::vars().collect(); let tx = self.app_event_tx.clone(); let logs_base_dir = self.config.codex_home.clone(); - let permission_profile = self.config.permissions.permission_profile(); + let permission_profile = + self.config.permissions.effective_permission_profile(); Self::spawn_world_writable_scan( cwd, env_map, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 905f62f86f..f6530cad26 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -5,6 +5,9 @@ use super::*; +const SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE: &str = + "Editing previous prompts is unavailable in side conversations."; + impl App { pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { let editor_cmd = match external_editor::resolve_editor_command() { @@ -197,6 +200,8 @@ impl App { // handles it. if self.should_handle_backtrack_esc(key_event) { self.handle_backtrack_esc_key(tui); + } else if self.should_reject_side_backtrack_esc(key_event) { + self.reject_side_backtrack_esc(); } else { self.chat_widget.handle_key_event(key_event); } @@ -252,11 +257,25 @@ impl App { } pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { - self.chat_widget.is_normal_backtrack_mode() + !self.chat_widget.side_conversation_active() + && self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() && !self.chat_widget.should_handle_vim_insert_escape(key_event) } + pub(super) fn should_reject_side_backtrack_esc(&self, key_event: KeyEvent) -> bool { + self.chat_widget.side_conversation_active() + && self.chat_widget.is_normal_backtrack_mode() + && self.chat_widget.composer_is_empty() + && !self.chat_widget.should_handle_vim_insert_escape(key_event) + } + + pub(super) fn reject_side_backtrack_esc(&mut self) { + self.reset_backtrack_state(); + self.chat_widget + .add_error_message(SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE.to_string()); + } + fn app_keymap_shortcuts_available(&self) -> bool { self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() } diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index f19ed0bb60..11289bc2aa 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -54,24 +54,16 @@ fn send_world_writable_scan_failed(tx: &AppEventSender) { } pub(super) fn side_return_shortcut_matches(key_event: KeyEvent) -> bool { - match key_event { - KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => true, + matches!( + key_event, KeyEvent { code: KeyCode::Char(c), modifiers, kind: KeyEventKind::Press, .. } if modifiers.contains(KeyModifiers::CONTROL) - && (c.eq_ignore_ascii_case(&'c') || c.eq_ignore_ascii_case(&'d')) => - { - true - } - _ => false, - } + && (c.eq_ignore_ascii_case(&'c') || c.eq_ignore_ascii_case(&'d')) + ) } #[cfg(test)] @@ -79,16 +71,7 @@ mod tests { use super::*; #[test] - fn side_return_shortcuts_match_esc_ctrl_c_and_ctrl_d() { - assert!(side_return_shortcut_matches(KeyEvent::new( - KeyCode::Esc, - KeyModifiers::NONE, - ))); - assert!(side_return_shortcut_matches(KeyEvent::new_with_kind( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Repeat, - ))); + fn side_return_shortcuts_match_ctrl_c_and_ctrl_d() { assert!(side_return_shortcut_matches(KeyEvent::new( KeyCode::Char('c'), KeyModifiers::CONTROL, @@ -105,6 +88,11 @@ mod tests { KeyCode::Char('D'), KeyModifiers::CONTROL, ))); + assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + KeyEventKind::Press, + ))); assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind( KeyCode::Esc, KeyModifiers::NONE, diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 6414e61047..044cb9b391 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -20,7 +20,7 @@ const SIDE_NO_STARTED_CONVERSATION_MESSAGE: &str = concat!( "Send a message first, then try /side again." ); const SIDE_ALREADY_OPEN_MESSAGE: &str = - "A side conversation is already open. Press Esc to return before starting another."; + "A side conversation is already open. Press Ctrl+C to return before starting another."; const SIDE_BOUNDARY_PROMPT: &str = r#"Side conversation boundary. Everything before this boundary is inherited history from the parent thread. It is reference context only. It is not your current task. @@ -247,7 +247,7 @@ impl App { if let Some(parent_status) = parent_status { label_parts.push(parent_status.label(parent_is_main).to_string()); } - label_parts.push("Esc to return".to_string()); + label_parts.push("Ctrl+C to return".to_string()); self.chat_widget .set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · ")))); } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index aa281d4533..802cda3f80 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -66,9 +66,9 @@ pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config } pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) { - let Some(message) = crate::legacy_core::config::system_bwrap_warning( - config.permissions.permission_profile.get(), - ) else { + let Some(message) = + codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile()) + else { return; }; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2abecc3585..c42f42c257 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -28,6 +28,7 @@ use crate::app_command::AppCommand as Op; use crate::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; +use crate::legacy_core::config::PermissionProfileSnapshot; use crate::legacy_core::config::TerminalResizeReflowMaxRows; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; @@ -1640,7 +1641,18 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< .config_ref() .permissions .permission_profile(), - auto_review.permission_profile + &auto_review.permission_profile() + ); + assert_eq!( + app.config.permissions.active_permission_profile(), + Some(auto_review.active_permission_profile.clone()) + ); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile(), + Some(auto_review.active_permission_profile.clone()) ); assert_eq!( app.chat_widget.config_ref().approvals_reviewer, @@ -1649,7 +1661,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< assert_eq!(app.runtime_approval_policy_override, None); assert_eq!( app.runtime_permission_profile_override, - Some(auto_review.permission_profile.clone()) + Some(auto_review.permission_profile()) ); assert_eq!( op_rx.try_recv(), @@ -1657,7 +1669,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, effort: None, @@ -1719,7 +1731,9 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget - .set_permission_profile(PermissionProfile::workspace_write())?; + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( + PermissionProfile::workspace_write(), + ))?; app.update_feature_flags(vec![(Feature::GuardianApproval, false)]) .await; @@ -1747,7 +1761,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1817,7 +1831,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review .config_ref() .permissions .permission_profile(), - auto_review.permission_profile + &auto_review.permission_profile() ); assert_eq!( op_rx.try_recv(), @@ -1825,7 +1839,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, effort: None, @@ -1882,7 +1896,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -1941,7 +1955,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, effort: None, @@ -2028,7 +2042,7 @@ guardian_approval = true cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), - permission_profile: None, + active_permission_profile: None, windows_sandbox_level: None, model: None, effort: None, @@ -2796,10 +2810,13 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000202").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); + let shared_root = test_path_buf("/tmp/shared").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + runtime_workspace_roots: vec![primary_cwd.clone(), shared_root.clone()], + ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_thread_id = Some(main_thread_id); @@ -2871,6 +2888,10 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re assert_eq!(session.model_provider_id, "agent-provider"); assert_eq!(session.approval_policy, primary_session.approval_policy); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/agent").as_path()); + assert_eq!( + session.runtime_workspace_roots, + vec![test_path_buf("/tmp/agent").abs(), shared_root] + ); assert_eq!(session.rollout_path, Some(rollout_path)); assert_eq!( app.agent_navigation.get(&agent_thread_id), @@ -2892,10 +2913,12 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ ThreadId::from_string("00000000-0000-0000-0000-000000000301").expect("valid thread"); let agent_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000302").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + runtime_workspace_roots: vec![primary_cwd.clone()], + ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_thread_id = Some(main_thread_id); @@ -2962,10 +2985,12 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { ThreadId::from_string("00000000-0000-0000-0000-000000000401").expect("valid thread"); let read_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000402").expect("valid thread"); + let primary_cwd = test_path_buf("/tmp/main").abs(); let primary_session = ThreadSessionState { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - ..test_thread_session(main_thread_id, test_path_buf("/tmp/main")) + runtime_workspace_roots: vec![primary_cwd.clone()], + ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; app.primary_session_configured = Some(primary_session); @@ -2997,11 +3022,16 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { assert_eq!(session.thread_id, read_thread_id); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path()); + assert_eq!( + session.runtime_workspace_roots, + vec![test_path_buf("/tmp/read").abs()] + ); let expected_permission_profile = app .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .clone(); assert_eq!( session.permission_profile, expected_permission_profile, "thread/read does not return fresh server permissions; the fallback profile must use the \ @@ -3123,7 +3153,9 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { app.chat_widget .set_approval_policy(AskForApproval::OnRequest); app.chat_widget - .set_permission_profile(parent_permission_profile.clone()) + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( + parent_permission_profile.clone(), + )) .expect("test permission profile should be accepted"); app.chat_widget .set_approvals_reviewer(ApprovalsReviewer::AutoReview); @@ -3144,7 +3176,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { Some(ReasoningEffortConfig::High), Some(parent_service_tier), AskForApproval::OnRequest.to_core(), - parent_permission_profile, + &parent_permission_profile, ApprovalsReviewer::AutoReview, ) ); @@ -3168,7 +3200,9 @@ async fn side_start_block_message_tracks_open_side_conversation() { assert_eq!( app.side_start_block_message(), - Some("A side conversation is already open. Press Esc to return before starting another.") + Some( + "A side conversation is already open. Press Ctrl+C to return before starting another." + ) ); app.side_threads.remove(&side_thread_id); @@ -3685,6 +3719,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), message_history: None, @@ -3933,6 +3968,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4508,6 +4544,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4571,6 +4608,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4663,6 +4701,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4704,7 +4743,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { assert!(items.iter().any(|item| { matches!( item, - UserInput::Image { url } if url == &data_image_url + UserInput::Image { url, .. } if url == &data_image_url ) })); } @@ -4898,6 +4937,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { )]; let resumed_session = ThreadSessionState { cwd: test_path_buf("/tmp/refreshed").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), ..initial_session.clone() }; @@ -5062,6 +5102,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -5183,6 +5224,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -5247,3 +5289,50 @@ async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { assert!(!app.chat_widget.should_handle_vim_insert_escape(esc)); assert!(app.should_handle_backtrack_esc(esc)); } + +#[tokio::test] +async fn side_conversations_reject_backtrack_esc_without_stealing_vim_insert_escape() { + let mut app = make_test_app().await; + let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE); + + app.chat_widget + .set_side_conversation_active(/*active*/ true); + assert!(app.chat_widget.composer_is_empty()); + assert!(!app.should_handle_backtrack_esc(esc)); + assert!(app.should_reject_side_backtrack_esc(esc)); + + app.chat_widget.toggle_vim_mode_and_notify(); + app.chat_widget + .handle_key_event(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('i'), + KeyModifiers::NONE, + )); + + assert!(app.chat_widget.should_handle_vim_insert_escape(esc)); + assert!(!app.should_handle_backtrack_esc(esc)); + assert!(!app.should_reject_side_backtrack_esc(esc)); +} + +#[tokio::test] +async fn side_backtrack_rejection_reports_unavailable_message_snapshot() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + app.backtrack.primed = true; + + app.reject_side_backtrack_esc(); + + assert!(!app.backtrack.primed); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert_app_snapshot!( + "side_backtrack_rejection_reports_unavailable_message", + rendered + ); +} diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 431bf5f804..30f68dc640 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -352,6 +352,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f25398b0b8..d270bb53ce 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -510,7 +510,7 @@ impl App { cwd, approval_policy, approvals_reviewer, - permission_profile, + active_permission_profile, model, effort, summary, @@ -588,12 +588,11 @@ impl App { let config = self.chat_widget.config_ref(); let approvals_reviewer = approvals_reviewer.unwrap_or(config.approvals_reviewer); - let active_permission_profile = - if config.permissions.permission_profile() == permission_profile.clone() { - config.permissions.active_permission_profile() - } else { - None - }; + let permissions_override = Self::turn_permissions_override_from_config( + config, + active_permission_profile.as_ref(), + self.runtime_permission_profile_override.as_ref(), + ); app_server .turn_start( thread_id, @@ -601,8 +600,8 @@ impl App { cwd.clone(), *approval_policy, approvals_reviewer, - permission_profile.clone(), - active_permission_profile, + permissions_override, + config.permissions.user_visible_workspace_roots(), model.to_string(), *effort, *summary, @@ -697,6 +696,34 @@ impl App { } } + fn turn_permissions_override_from_config( + config: &Config, + active_permission_profile: Option<&ActivePermissionProfile>, + runtime_permission_profile_override: Option<&PermissionProfile>, + ) -> TurnPermissionsOverride { + if let Some(active_permission_profile) = active_permission_profile { + return TurnPermissionsOverride::ActiveProfile(active_permission_profile.clone()); + } + + let effective_permission_profile = config.permissions.effective_permission_profile(); + let runtime_permission_profile_override = + runtime_permission_profile_override.map(|profile| { + profile + .clone() + .materialize_project_roots_with_workspace_roots( + &config.effective_workspace_roots(), + ) + }); + if runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| profile == &effective_permission_profile) + { + return TurnPermissionsOverride::LegacySandbox(effective_permission_profile); + } + + TurnPermissionsOverride::Preserve + } + pub(super) fn handle_skills_list_result( &mut self, result: Result, @@ -892,7 +919,8 @@ impl App { session.thread_id = thread_id; session.thread_name = notification.thread.name.clone(); session.model_provider_id = notification.thread.model_provider.clone(); - session.cwd = notification.thread.cwd.clone(); + session + .set_cwd_retargeting_implicit_runtime_workspace_root(notification.thread.cwd.clone()); let rollout_path = notification.thread.path.clone(); if let Some(model) = read_session_model(self.state_db.as_deref(), thread_id, rollout_path.as_deref()).await @@ -1453,3 +1481,77 @@ impl App { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ActivePermissionProfile; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; + + async fn config_with_workspace_profile() -> Config { + let temp_dir = tempfile::tempdir().expect("tempdir"); + ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build") + } + + #[tokio::test] + async fn turn_permissions_use_active_profile_when_available() { + let config = config_with_workspace_profile().await; + let active_permission_profile = config.permissions.active_permission_profile(); + + assert_eq!( + App::turn_permissions_override_from_config( + &config, + active_permission_profile.as_ref(), + /*runtime_permission_profile_override*/ None, + ), + TurnPermissionsOverride::ActiveProfile(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + )) + ); + } + + #[tokio::test] + async fn turn_permissions_preserve_server_snapshot_without_local_override() { + let mut config = config_with_workspace_profile().await; + config + .permissions + .set_permission_profile(PermissionProfile::read_only()) + .expect("read-only profile should be allowed"); + + assert_eq!( + App::turn_permissions_override_from_config( + &config, /*active_permission_profile*/ None, + /*runtime_permission_profile_override*/ None, + ), + TurnPermissionsOverride::Preserve + ); + } + + #[tokio::test] + async fn turn_permissions_send_legacy_sandbox_for_local_override() { + let mut config = config_with_workspace_profile().await; + let permission_profile = PermissionProfile::workspace_write(); + config + .permissions + .set_permission_profile(permission_profile.clone()) + .expect("workspace profile should be allowed"); + let effective_permission_profile = config.permissions.effective_permission_profile(); + + assert_eq!( + App::turn_permissions_override_from_config( + &config, + /*active_permission_profile*/ None, + Some(&permission_profile), + ), + TurnPermissionsOverride::LegacySandbox(effective_permission_profile) + ); + } +} diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4f5f48cfc8..4037a085eb 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -19,7 +19,8 @@ impl App { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .clone(); let active_permission_profile = self .chat_widget .config_ref() @@ -71,6 +72,7 @@ impl App { permission_profile: permission_profile.clone(), active_permission_profile: active_permission_profile.clone(), cwd: thread.cwd.clone(), + runtime_workspace_roots: self.config.workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), message_history: None, @@ -80,7 +82,7 @@ impl App { session.thread_id = thread_id; session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); - session.cwd = thread.cwd.clone(); + session.set_cwd_retargeting_implicit_runtime_workspace_root(thread.cwd.clone()); session.permission_profile = permission_profile; session.active_permission_profile = active_permission_profile; session.instruction_source_paths = Vec::new(); @@ -101,6 +103,7 @@ impl App { .config_ref() .permissions .permission_profile() + .clone() } fn current_active_permission_profile(&self) -> Option { @@ -117,18 +120,19 @@ mod tests { use crate::app::side::SideThreadState; use crate::app::test_support::make_test_app; use crate::app::thread_events::ThreadEventChannel; + use crate::legacy_core::config::PermissionProfileSnapshot; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_config::types::ApprovalsReviewer; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; + use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -146,6 +150,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: vec![cwd.abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -193,9 +198,14 @@ mod tests { codex_config::Constrained::allow_any(AskForApproval::OnRequest.to_core()); app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; let expected_permission_profile = PermissionProfile::workspace_write(); + let expected_active_permission_profile = + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); app.chat_widget.handle_thread_session(main_session.clone()); app.chat_widget - .set_permission_profile(expected_permission_profile.clone()) + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + expected_permission_profile.clone(), + expected_active_permission_profile.clone(), + )) .expect("set widget permission profile"); app.config .permissions @@ -209,6 +219,7 @@ mod tests { approval_policy: AskForApproval::OnRequest, approvals_reviewer: ApprovalsReviewer::AutoReview, permission_profile: expected_permission_profile, + active_permission_profile: Some(expected_active_permission_profile), ..main_session }; assert_eq!( @@ -244,9 +255,9 @@ mod tests { let mut app = make_test_app().await; let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000403").expect("valid thread"); - let profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -263,8 +274,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; let session = ThreadSessionState { permission_profile: profile.clone(), ..test_thread_session(thread_id, test_path_buf("/tmp/main")) @@ -350,11 +360,12 @@ mod tests { .chat_widget .config_ref() .permissions - .permission_profile(); + .permission_profile() + .clone(); assert_eq!(session.permission_profile, expected_permission_profile); assert_ne!( session.permission_profile, - app.config.permissions.permission_profile(), + app.config.permissions.permission_profile().clone(), "thread/read fallback must use the active widget permissions rather than stale app \ config defaults" ); diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 89fd2600f8..0b6484d015 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -16,7 +16,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; @@ -41,7 +41,7 @@ pub(crate) enum AppCommand { cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: Option, - permission_profile: PermissionProfile, + active_permission_profile: Option, model: String, effort: Option, summary: Option, @@ -54,7 +54,7 @@ pub(crate) enum AppCommand { cwd: Option, approval_policy: Option, approvals_reviewer: Option, - permission_profile: Option, + active_permission_profile: Option, windows_sandbox_level: Option, model: Option, effort: Option>, @@ -142,7 +142,7 @@ impl AppCommand { items: Vec, cwd: PathBuf, approval_policy: AskForApproval, - permission_profile: PermissionProfile, + active_permission_profile: Option, model: String, effort: Option, summary: Option, @@ -156,7 +156,7 @@ impl AppCommand { cwd, approval_policy, approvals_reviewer: None, - permission_profile, + active_permission_profile, model, effort, summary, @@ -172,7 +172,7 @@ impl AppCommand { cwd: Option, approval_policy: Option, approvals_reviewer: Option, - permission_profile: Option, + active_permission_profile: Option, windows_sandbox_level: Option, model: Option, effort: Option>, @@ -185,7 +185,7 @@ impl AppCommand { cwd, approval_policy, approvals_reviewer, - permission_profile, + active_permission_profile, windows_sandbox_level, model, effort, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ab9055197b..57ec371749 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -43,7 +43,7 @@ use codex_features::Feature; use codex_plugin::PluginCapabilitySummary; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::Personality; -use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_realtime_webrtc::RealtimeWebrtcEvent; use codex_realtime_webrtc::RealtimeWebrtcSessionHandle; @@ -742,8 +742,8 @@ pub(crate) enum AppEvent { /// Update the current approval policy in the running app and widget. UpdateAskForApprovalPolicy(AskForApproval), - /// Update the current permission profile in the running app and widget. - UpdatePermissionProfile(PermissionProfile), + /// Update the current built-in active permission profile in the running app and widget. + UpdateActivePermissionProfile(ActivePermissionProfile), /// Update the current approvals reviewer in the running app and widget. UpdateApprovalsReviewer(ApprovalsReviewer), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 56ad0ccdea..2620f80d53 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; @@ -108,7 +107,6 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -174,6 +172,16 @@ pub(crate) struct AppServerStartedThread { pub(crate) turns: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TurnPermissionsOverride { + /// Leave the app-server thread's sticky permission profile unchanged. + Preserve, + /// Select a named or built-in profile by id. + ActiveProfile(ActivePermissionProfile), + /// Apply a user-selected legacy/custom permission profile. + LegacySandbox(PermissionProfile), +} + impl AppServerSession { pub(crate) fn new(client: AppServerClient) -> Self { Self { @@ -550,8 +558,8 @@ impl AppServerSession { cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, - permission_profile: PermissionProfile, - active_permission_profile: Option, + permissions_override: TurnPermissionsOverride, + workspace_roots: &[AbsolutePathBuf], model: String, effort: Option, summary: Option, @@ -561,12 +569,8 @@ impl AppServerSession { output_schema: Option, ) -> Result { let request_id = self.next_request_id(); - let (sandbox_policy, permissions) = turn_permissions_overrides( - &permission_profile, - active_permission_profile, - cwd.as_path(), - self.thread_params_mode(), - ); + let (sandbox_policy, permissions) = + turn_permissions_overrides(permissions_override, cwd.as_path()); self.client .request_typed(ClientRequest::TurnStart { request_id, @@ -576,6 +580,12 @@ impl AppServerSession { responsesapi_client_metadata: None, environments: None, cwd: Some(cwd), + runtime_workspace_roots: Some( + workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, @@ -1175,47 +1185,36 @@ fn sandbox_mode_from_permission_profile( fn permissions_selection_from_active_profile( active: ActivePermissionProfile, ) -> PermissionProfileSelectionParams { - let modifications = active - .modifications - .into_iter() - .map(|modification| match modification { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - PermissionProfileModificationParams::AdditionalWritableRoot { path } - } - }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::new(active.id) } fn turn_permissions_overrides( - permission_profile: &PermissionProfile, - active_permission_profile: Option, + permissions_override: TurnPermissionsOverride, cwd: &std::path::Path, - thread_params_mode: ThreadParamsMode, ) -> ( Option, Option, ) { - let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile.map(permissions_selection_from_active_profile) - } else { - None - }; - let sandbox_policy = (matches!(thread_params_mode, ThreadParamsMode::Remote) - || permissions.is_none()) - .then(|| { - let legacy_profile = legacy_compatible_permission_profile(permission_profile, cwd); - let policy = legacy_profile - .to_legacy_sandbox_policy(cwd) - .unwrap_or_else(|err| { - unreachable!("legacy-compatible permissions must project to legacy policy: {err}") - }); - policy.into() - }); - (sandbox_policy, permissions) + match permissions_override { + TurnPermissionsOverride::Preserve => (None, None), + TurnPermissionsOverride::ActiveProfile(active_permission_profile) => ( + None, + Some(permissions_selection_from_active_profile( + active_permission_profile, + )), + ), + TurnPermissionsOverride::LegacySandbox(permission_profile) => { + let legacy_profile = legacy_compatible_permission_profile(&permission_profile, cwd); + let policy = legacy_profile + .to_legacy_sandbox_policy(cwd) + .unwrap_or_else(|err| { + unreachable!( + "legacy-compatible permissions must project to legacy policy: {err}" + ) + }); + (Some(policy.into()), None) + } + } } fn permissions_selection_from_config( @@ -1243,7 +1242,7 @@ fn thread_start_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1253,6 +1252,13 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1277,7 +1283,7 @@ fn thread_resume_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1288,6 +1294,13 @@ fn thread_resume_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1309,7 +1322,7 @@ fn thread_fork_params_from_config( .is_none() .then(|| { sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ) }) @@ -1320,6 +1333,13 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1397,9 +1417,8 @@ async fn thread_session_state_from_thread_start_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = permission_profile_from_thread_response( + let permission_profile = display_permission_profile_from_thread_response( &response.sandbox, - response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, @@ -1417,6 +1436,7 @@ async fn thread_session_state_from_thread_start_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1429,13 +1449,21 @@ async fn thread_session_state_from_thread_resume_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = permission_profile_from_thread_response( - &response.sandbox, - response.permission_profile.as_ref(), - response.cwd.as_path(), - config, - thread_params_mode, - ); + let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded) + && response.active_permission_profile.is_none() + { + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ) + } else { + display_permission_profile_from_thread_response( + &response.sandbox, + response.cwd.as_path(), + config, + thread_params_mode, + ) + }; thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), @@ -1449,6 +1477,7 @@ async fn thread_session_state_from_thread_resume_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1461,9 +1490,8 @@ async fn thread_session_state_from_thread_fork_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = permission_profile_from_thread_response( + let permission_profile = display_permission_profile_from_thread_response( &response.sandbox, - response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, @@ -1481,6 +1509,7 @@ async fn thread_session_state_from_thread_fork_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1488,18 +1517,14 @@ async fn thread_session_state_from_thread_fork_response( .await } -fn permission_profile_from_thread_response( +fn display_permission_profile_from_thread_response( sandbox: &codex_app_server_protocol::SandboxPolicy, - permission_profile: Option<&codex_app_server_protocol::PermissionProfile>, cwd: &std::path::Path, config: &Config, thread_params_mode: ThreadParamsMode, ) -> PermissionProfile { - if let Some(permission_profile) = permission_profile { - return permission_profile.clone().into(); - } match thread_params_mode { - ThreadParamsMode::Embedded => config.permissions.permission_profile(), + ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(), ThreadParamsMode::Remote => { PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) } @@ -1523,6 +1548,7 @@ async fn thread_session_state_from_thread_response( permission_profile: PermissionProfile, active_permission_profile: Option, cwd: AbsolutePathBuf, + runtime_workspace_roots: Vec, instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, @@ -1550,6 +1576,7 @@ async fn thread_session_state_from_thread_response( permission_profile, active_permission_profile, cwd, + runtime_workspace_roots, instruction_source_paths, reasoning_effort, message_history: Some(MessageHistoryMetadata { @@ -1577,13 +1604,6 @@ mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; - use codex_app_server_protocol::FileSystemAccessMode; - use codex_app_server_protocol::FileSystemPath; - use codex_app_server_protocol::FileSystemSandboxEntry; - use codex_app_server_protocol::FileSystemSpecialPath; - use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; - use codex_app_server_protocol::PermissionProfileFileSystemPermissions; - use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -1594,7 +1614,13 @@ mod tests { use codex_protocol::config_types::WebSearchMode; 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::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSpecialPath; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -1629,6 +1655,16 @@ mod tests { ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); + assert_eq!( + params.runtime_workspace_roots, + Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + ) + ); assert_eq!(params.sandbox, None); assert_eq!( params.permissions, @@ -1665,10 +1701,8 @@ mod tests { permissions_selection_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::workspace_write(), - Some(active_permission_profile), + TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - ThreadParamsMode::Embedded, ); assert_eq!(sandbox_policy, None); @@ -1676,14 +1710,43 @@ mod tests { } #[test] - fn embedded_turn_permissions_fall_back_to_sandbox_without_active_profile() { + fn embedded_turn_permissions_select_profile_id_only() { + let cwd = test_path_buf("/workspace/project").abs(); + let active_permission_profile = + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); + + let (sandbox_policy, permissions) = turn_permissions_overrides( + TurnPermissionsOverride::ActiveProfile(active_permission_profile), + cwd.as_path(), + ); + + assert_eq!(sandbox_policy, None); + assert_eq!( + permissions, + Some(PermissionProfileSelectionParams::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + )) + ); + } + + #[test] + fn turn_permissions_preserve_thread_permissions_without_override() { + let cwd = test_path_buf("/workspace/project").abs(); + + let (sandbox_policy, permissions) = + turn_permissions_overrides(TurnPermissionsOverride::Preserve, cwd.as_path()); + + assert_eq!(sandbox_policy, None); + assert_eq!(permissions, None); + } + + #[test] + fn legacy_turn_permissions_project_to_sandbox_when_explicitly_overridden() { let cwd = test_path_buf("/workspace/project").abs(); let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::read_only(), - /*active_permission_profile*/ None, + TurnPermissionsOverride::LegacySandbox(PermissionProfile::read_only()), cwd.as_path(), - ThreadParamsMode::Embedded, ); assert_eq!( @@ -1696,25 +1759,19 @@ mod tests { } #[test] - fn remote_turn_permissions_use_sandbox_even_with_active_profile() { + fn remote_turn_permissions_preserve_active_profile_selection() { let cwd = test_path_buf("/workspace/project").abs(); + let active_permission_profile = ActivePermissionProfile::new("strict"); + let expected_permissions = + permissions_selection_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( - &PermissionProfile::read_only(), - Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), + TurnPermissionsOverride::ActiveProfile(active_permission_profile), cwd.as_path(), - ThreadParamsMode::Remote, ); - assert_eq!( - sandbox_policy, - Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - network_access: false - }) - ); - assert_eq!(permissions, None); + assert_eq!(sandbox_policy, None); + assert_eq!(permissions, Some(expected_permissions)); } #[tokio::test] @@ -1723,9 +1780,16 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let expected_sandbox = sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); + let expected_runtime_workspace_roots = Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>(), + ); let start = thread_start_params_from_config( &config, @@ -1749,6 +1813,18 @@ mod tests { assert_eq!(start.cwd, None); assert_eq!(resume.cwd, None); assert_eq!(fork.cwd, None); + assert_eq!( + start.runtime_workspace_roots, + expected_runtime_workspace_roots + ); + assert_eq!( + resume.runtime_workspace_roots, + expected_runtime_workspace_roots + ); + assert_eq!( + fork.runtime_workspace_roots, + expected_runtime_workspace_roots + ); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); @@ -1766,9 +1842,9 @@ mod tests { fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); let extra_root = test_path_buf("/workspace/cache").abs(); - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1783,8 +1859,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1795,9 +1870,9 @@ mod tests { #[test] fn sandbox_mode_projects_cwd_write_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); - let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1814,8 +1889,7 @@ mod tests { ], glob_scan_max_depth: None, }, - } - .into(); + }; assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), @@ -1830,7 +1904,7 @@ mod tests { let thread_id = ThreadId::new(); let remote_cwd = PathBuf::from("repo/on/server"); let expected_sandbox = sandbox_mode_from_permission_profile( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); @@ -2019,6 +2093,10 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: vec![ + test_path_buf("/tmp/project").abs(), + test_path_buf("/tmp/project/extra").abs(), + ], instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, @@ -2026,7 +2104,6 @@ mod tests { .to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path()) .expect("read-only profile must be legacy-compatible") .into(), - permission_profile: Some(read_only_profile.clone().into()), active_permission_profile: None, reasoning_effort: None, }; @@ -2039,6 +2116,10 @@ mod tests { .await .expect("resume response should map"); assert_eq!(started.session.forked_from_id, Some(forked_from_id)); + assert_eq!( + started.session.runtime_workspace_roots, + response.runtime_workspace_roots + ); assert_eq!( started.session.instruction_source_paths, response.instruction_sources @@ -2046,64 +2127,75 @@ mod tests { assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); + + let embedded_config = ConfigBuilder::default() + .codex_home(temp_dir.path().join("embedded-codex-home")) + .harness_overrides(ConfigOverrides { + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build"); + let started = started_thread_from_resume_response( + response.clone(), + &embedded_config, + ThreadParamsMode::Embedded, + ) + .await + .expect("embedded resume response should map"); + assert_eq!(started.session.permission_profile, read_only_profile); + + let mut empty_roots_response = response; + empty_roots_response.runtime_workspace_roots = Vec::new(); + let started = started_thread_from_resume_response( + empty_roots_response, + &config, + ThreadParamsMode::Remote, + ) + .await + .expect("resume response should map"); + assert_eq!(started.session.runtime_workspace_roots, Vec::new()); } #[tokio::test] - async fn remote_thread_response_prefers_permission_profile_over_legacy_sandbox() { + async fn remote_thread_response_uses_legacy_sandbox_fallback() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let cwd = test_path_buf("/tmp/project").abs(); - let fallback_sandbox = PermissionProfile::read_only() + let sandbox = PermissionProfile::read_only() .to_legacy_sandbox_policy(cwd.as_path()) .expect("read-only profile must be legacy-compatible") .into(); - let response_profile = AppServerPermissionProfile::Managed { - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { - subpath: Some(".env".into()), - }, - }, - access: FileSystemAccessMode::None, - }, - ], - glob_scan_max_depth: None, - }, - network: PermissionProfileNetworkPermissions { enabled: false }, - }; - let split_profile: PermissionProfile = response_profile.clone().into(); assert_eq!( - permission_profile_from_thread_response( - &fallback_sandbox, - Some(&response_profile), + display_permission_profile_from_thread_response( + &sandbox, cwd.as_path(), &config, ThreadParamsMode::Remote, ), - split_profile + PermissionProfile::read_only() ); } #[tokio::test] - async fn embedded_thread_response_prefers_permission_profile_when_present() { + async fn embedded_thread_response_uses_local_config_profile() { let temp_dir = tempfile::tempdir().expect("tempdir"); - let config = build_config(&temp_dir).await; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("config should build"); let cwd = test_path_buf("/tmp/project").abs(); - let response_profile = PermissionProfile::read_only().into(); assert_eq!( - permission_profile_from_thread_response( + display_permission_profile_from_thread_response( &codex_app_server_protocol::SandboxPolicy::DangerFullAccess, - Some(&response_profile), cwd.as_path(), &config, ThreadParamsMode::Embedded, @@ -2142,6 +2234,7 @@ mod tests { /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), + Vec::new(), /*reasoning_effort*/ None, &config, ) @@ -2176,6 +2269,7 @@ mod tests { /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), + Vec::new(), /*reasoning_effort*/ None, &config, ) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4bfd5da17c..e76b4c0b96 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -218,10 +218,15 @@ use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; mod attachment_state; +mod draft_state; +mod footer_state; mod history_search; mod popup_state; use self::attachment_state::AttachmentState; +use self::draft_state::ComposerMentionBinding; +use self::draft_state::DraftState; +use self::footer_state::FooterState; use self::history_search::HistorySearchSession; use self::popup_state::ActivePopup; use self::popup_state::PopupState; @@ -231,7 +236,6 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::MentionBinding; use crate::bottom_pane::textarea::TextArea; -use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; @@ -246,7 +250,6 @@ use codex_file_search::FileMatch; #[cfg(test)] use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; -use std::cell::RefCell; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; @@ -342,57 +345,29 @@ impl ChatComposerConfig { } pub(crate) struct ChatComposer { - textarea: TextArea, - textarea_state: RefCell, - is_bash_mode: bool, + draft: DraftState, popups: PopupState, app_event_tx: AppEventSender, history: ChatComposerHistory, - quit_shortcut_expires_at: Option, - quit_shortcut_key: KeyBinding, - esc_backtrack_hint: bool, - use_shift_enter_hint: bool, - pending_pastes: Vec<(String, String)>, + footer: FooterState, has_focus: bool, frame_requester: Option, attachments: AttachmentState, placeholder_text: String, is_task_running: bool, - /// When false, the composer is temporarily read-only (e.g. during sandbox setup). - input_enabled: bool, - input_disabled_placeholder: Option, - /// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`). - paste_burst: PasteBurst, - // When true, disables paste-burst logic and inserts characters immediately. - disable_paste_burst: bool, - footer_mode: FooterMode, - footer_hint_override: Option>, - /// Whether the ambient footer row is currently replaced by the Plan-mode nudge. - /// - /// Eligibility is decided by `ChatWidget`; the composer only owns presentation so enabling - /// the nudge never changes layout height or reimplements mode-selection policy here. - plan_mode_nudge_visible: bool, /// Slash-command draft staged for local recall after application-level dispatch. /// /// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can /// prepare their argument text without also double-recording the full command invocation. pending_slash_command_history: Option, - footer_flash: Option, - context_window_percent: Option, // Monotonically increasing identifier for textarea elements we insert. #[cfg(not(target_os = "linux"))] next_element_id: u64, - context_window_used_tokens: Option, skills: Option>, plugins: Option>, connectors_snapshot: Option, - mention_bindings: HashMap, - recent_submission_mention_bindings: Vec, collaboration_modes_enabled: bool, config: ChatComposerConfig, - collaboration_mode_indicator: Option, - goal_status_indicator: Option, - ide_context_active: bool, connectors_enabled: bool, plugins_command_enabled: bool, service_tier_commands_enabled: bool, @@ -404,12 +379,6 @@ pub(crate) struct ChatComposer { audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, side_conversation_active: bool, - status_line_value: Option>, - status_line_hyperlink_url: Option, - status_line_enabled: bool, - side_conversation_context_label: Option, - // Agent label injected into the footer's contextual row when multi-agent mode is active. - active_agent_label: Option, history_search: Option, submit_keys: Vec, queue_keys: Vec, @@ -418,20 +387,6 @@ pub(crate) struct ChatComposer { history_search_next_keys: Vec, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, - footer_external_editor_key: Option, - footer_show_transcript_key: Option, - footer_insert_newline_key: Option, - footer_queue_key: Option, - footer_toggle_shortcuts_key: Option, - footer_history_search_key: Option, - footer_reasoning_down_key: Option, - footer_reasoning_up_key: Option, -} - -#[derive(Clone, Debug)] -struct FooterFlash { - line: Line<'static>, - expires_at: Instant, } #[derive(Clone, Debug)] @@ -446,9 +401,13 @@ struct ComposerDraft { } #[derive(Clone, Debug)] -struct ComposerMentionBinding { - mention: String, - path: String, +pub(crate) struct ComposerDraftSnapshot { + pub(crate) text: String, + pub(crate) text_elements: Vec, + pub(crate) local_images: Vec, + pub(crate) remote_image_urls: Vec, + pub(crate) mention_bindings: Vec, + pub(crate) pending_pastes: Vec<(String, String)>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -523,45 +482,56 @@ impl ChatComposer { let default_vim_normal_keymap = default_keymap.vim_normal.clone(); let mut this = Self { - textarea: TextArea::new(), - textarea_state: RefCell::new(TextAreaState::default()), - is_bash_mode: false, + draft: DraftState::new(), popups: PopupState::default(), app_event_tx, history: ChatComposerHistory::new(), - quit_shortcut_expires_at: None, - quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - esc_backtrack_hint: false, - use_shift_enter_hint, - pending_pastes: Vec::new(), + footer: FooterState { + quit_shortcut_expires_at: None, + quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), + esc_backtrack_hint: false, + use_shift_enter_hint, + mode: FooterMode::ComposerEmpty, + hint_override: None, + plan_mode_nudge_visible: false, + flash: None, + context_window_percent: None, + context_window_used_tokens: None, + collaboration_mode_indicator: None, + goal_status_indicator: None, + ide_context_active: false, + status_line_value: None, + status_line_hyperlink_url: None, + status_line_enabled: false, + side_conversation_context_label: None, + active_agent_label: None, + external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), + show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), + insert_newline_key: footer_insert_newline_key( + &default_keymap.editor.insert_newline, + use_shift_enter_hint, + ), + queue_key: Some(key_hint::plain(KeyCode::Tab)), + toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), + history_search_key: primary_binding( + &default_keymap.composer.history_search_previous, + ), + reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort), + reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort), + }, has_focus: has_input_focus, frame_requester: None, attachments: AttachmentState::default(), placeholder_text, is_task_running: false, - input_enabled: true, - input_disabled_placeholder: None, - paste_burst: PasteBurst::default(), - disable_paste_burst: false, - footer_mode: FooterMode::ComposerEmpty, - footer_hint_override: None, - plan_mode_nudge_visible: false, pending_slash_command_history: None, - footer_flash: None, - context_window_percent: None, #[cfg(not(target_os = "linux"))] next_element_id: 0, - context_window_used_tokens: None, skills: None, plugins: None, connectors_snapshot: None, - mention_bindings: HashMap::new(), - recent_submission_mention_bindings: Vec::new(), collaboration_modes_enabled: false, config, - collaboration_mode_indicator: None, - goal_status_indicator: None, - ide_context_active: false, connectors_enabled: false, plugins_command_enabled: false, service_tier_commands_enabled: false, @@ -573,11 +543,6 @@ impl ChatComposer { audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, side_conversation_active: false, - status_line_value: None, - status_line_hyperlink_url: None, - status_line_enabled: false, - side_conversation_context_label: None, - active_agent_label: None, history_search: None, submit_keys: vec![key_hint::plain(KeyCode::Enter)], queue_keys: vec![key_hint::plain(KeyCode::Tab)], @@ -589,23 +554,6 @@ impl ChatComposer { history_search_next_keys: default_keymap.composer.history_search_next.clone(), editor_keymap: default_editor_keymap, vim_normal_keymap: default_vim_normal_keymap, - footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))), - footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))), - footer_insert_newline_key: footer_insert_newline_key( - &default_keymap.editor.insert_newline, - use_shift_enter_hint, - ), - footer_queue_key: Some(key_hint::plain(KeyCode::Tab)), - footer_toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))), - footer_history_search_key: primary_binding( - &default_keymap.composer.history_search_previous, - ), - footer_reasoning_down_key: primary_binding( - &default_keymap.chat.decrease_reasoning_effort, - ), - footer_reasoning_up_key: primary_binding( - &default_keymap.chat.increase_reasoning_effort, - ), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -659,7 +607,7 @@ impl ChatComposer { let elements = self.current_mention_elements(); let mut ordered = Vec::new(); for (id, mention) in elements { - if let Some(binding) = self.mention_bindings.remove(&id) + if let Some(binding) = self.draft.mention_bindings.remove(&id) && binding.mention == mention { ordered.push(MentionBinding { @@ -668,7 +616,7 @@ impl ChatComposer { }); } } - self.mention_bindings.clear(); + self.draft.mention_bindings.clear(); ordered } @@ -707,31 +655,33 @@ impl ChatComposer { self.history_search_next_keys = keymap.composer.history_search_next.clone(); self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); - self.textarea.set_keymap_bindings(keymap); - self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor); - self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript); - self.footer_insert_newline_key = - footer_insert_newline_key(&keymap.editor.insert_newline, self.use_shift_enter_hint); - self.footer_queue_key = primary_binding(&keymap.composer.queue); - self.footer_toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); - self.footer_history_search_key = primary_binding(&keymap.composer.history_search_previous); - self.footer_reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); - self.footer_reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); + self.draft.textarea.set_keymap_bindings(keymap); + self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor); + self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript); + self.footer.insert_newline_key = footer_insert_newline_key( + &keymap.editor.insert_newline, + self.footer.use_shift_enter_hint, + ); + self.footer.queue_key = primary_binding(&keymap.composer.queue); + self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts); + self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous); + self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort); + self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort); } pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, ) { - self.collaboration_mode_indicator = indicator; + self.footer.collaboration_mode_indicator = indicator; } pub fn set_goal_status_indicator(&mut self, indicator: Option) { - self.goal_status_indicator = indicator; + self.footer.goal_status_indicator = indicator; } pub fn set_ide_context_active(&mut self, active: bool) { - self.ide_context_active = active; + self.footer.ide_context_active = active; } pub fn set_personality_command_enabled(&mut self, enabled: bool) { @@ -842,7 +792,7 @@ impl ChatComposer { area: Rect, textarea_right_reserve: u16, ) -> Option<(u16, u16)> { - if !self.input_enabled || self.attachments.selected_remote_image_index.is_some() { + if !self.draft.input_enabled || self.attachments.selected_remote_image_index.is_some() { return None; } @@ -852,12 +802,14 @@ impl ChatComposer { let [_, _, textarea_rect, _] = self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) + let state = *self.draft.textarea_state.borrow(); + self.draft + .textarea + .cursor_pos_with_state(textarea_rect, state) } /// Returns true if the composer currently contains no user-entered input. pub(crate) fn is_empty(&self) -> bool { - self.textarea.is_empty() && !self.is_bash_mode && self.attachments.is_empty() + self.draft.textarea.is_empty() && !self.draft.is_bash_mode && self.attachments.is_empty() } /// Record local persistent-history metadata so the composer can navigate @@ -924,17 +876,17 @@ impl ChatComposer { let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { let placeholder = self.next_large_paste_placeholder(char_count); - self.textarea.insert_element(&placeholder); - self.pending_pastes.push((placeholder, pasted)); + self.draft.textarea.insert_element(&placeholder); + self.draft.pending_pastes.push((placeholder, pasted)); } else if char_count > 1 && self.image_paste_enabled() && self.handle_paste_image_path(pasted.clone()) { - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } else { self.insert_str(&pasted); } - self.paste_burst.clear_after_explicit_paste(); + self.draft.paste_burst.clear_after_explicit_paste(); self.sync_popups(); true } @@ -981,13 +933,13 @@ impl ChatComposer { /// without emitting any buffered text, which can leave a non-empty buffer unable to flush /// later (because `flush_if_due()` relies on `last_plain_char_time` to time out). pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { - let was_disabled = self.disable_paste_burst; - self.disable_paste_burst = disabled; + let was_disabled = self.draft.disable_paste_burst; + self.draft.disable_paste_burst = disabled; if disabled && !was_disabled { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.paste_burst.clear_after_explicit_paste(); + self.draft.paste_burst.clear_after_explicit_paste(); } } @@ -997,7 +949,7 @@ impl ChatComposer { /// are renumbered to `[Image #M+1]..[Image #N]` (where `M` is the number of /// remote images). Cursor is placed at the end after rebuilding elements. pub(crate) fn apply_external_edit(&mut self, text: String) { - self.pending_pastes.clear(); + self.draft.pending_pastes.clear(); let (text, _) = self.imported_text_for_textarea(text, Vec::new()); // Count placeholder occurrences in the new text. @@ -1030,7 +982,7 @@ impl ChatComposer { self.attachments.local_images = kept_images; // Rebuild textarea so placeholders become elements again. - self.textarea.set_text_clearing_elements(""); + self.draft.textarea.set_text_clearing_elements(""); let mut remaining: HashMap<&str, usize> = HashMap::new(); for img in &self.attachments.local_images { *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; @@ -1053,20 +1005,23 @@ impl ChatComposer { continue; } if pos > idx { - self.textarea.insert_str(&text[idx..pos]); + self.draft.textarea.insert_str(&text[idx..pos]); } - self.textarea.insert_element(ph); + self.draft.textarea.insert_element(ph); *count -= 1; idx = pos + ph.len(); } if idx < text.len() { - self.textarea.insert_str(&text[idx..]); + self.draft.textarea.insert_str(&text[idx..]); } // Keep local image placeholders normalized in attachment order after the // remote-image prefix. - self.attachments.relabel_local_images(&mut self.textarea); - self.textarea.set_cursor(self.textarea.text().len()); + self.attachments + .relabel_local_images(&mut self.draft.textarea); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); self.sync_popups(); } @@ -1077,9 +1032,9 @@ impl ChatComposer { /// commands, not as candidate literal paste text. It also resets transient /// footer mode so the visible hints match the new editing surface. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { - self.textarea.set_vim_enabled(enabled); - self.paste_burst.clear_after_explicit_paste(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.draft.textarea.set_vim_enabled(enabled); + self.draft.paste_burst.clear_after_explicit_paste(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } /// Toggle Vim editing and return the new enabled state. @@ -1088,7 +1043,7 @@ impl ChatComposer { /// keybinding; callers should use the returned value for status messages /// instead of rereading state after additional composer mutations. pub(crate) fn toggle_vim_enabled(&mut self) -> bool { - let enabled = !self.textarea.is_vim_enabled(); + let enabled = !self.draft.textarea.is_vim_enabled(); self.set_vim_enabled(enabled); enabled } @@ -1096,7 +1051,7 @@ impl ChatComposer { /// Return whether Vim editing is enabled for tests that assert mode transitions. #[cfg(test)] pub(crate) fn is_vim_enabled(&self) -> bool { - self.textarea.is_vim_enabled() + self.draft.textarea.is_vim_enabled() } /// Return whether Escape should be routed to the textarea before popups. @@ -1105,15 +1060,20 @@ impl ChatComposer { /// event layer asks this before running generic Escape behavior so the same /// key does not both leave insert mode and dismiss unrelated UI. pub(crate) fn should_handle_vim_insert_escape(&self, key_event: KeyEvent) -> bool { - self.textarea.should_handle_vim_insert_escape(key_event) + self.draft + .textarea + .should_handle_vim_insert_escape(key_event) } fn vim_mode_indicator_span(&self) -> Option> { - self.textarea.vim_mode_label().map(|label| match label { - "Normal" => "Vim: Normal".magenta(), - "Insert" => "Vim: Insert".green(), - _ => unreachable!(), - }) + self.draft + .textarea + .vim_mode_label() + .map(|label| match label { + "Normal" => "Vim: Normal".magenta(), + "Insert" => "Vim: Insert".green(), + _ => unreachable!(), + }) } fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { @@ -1122,9 +1082,9 @@ impl ChatComposer { spans.push(vim_mode); } if let Some(indicators) = status_line_right_indicator_line( - self.collaboration_mode_indicator, - self.goal_status_indicator.as_ref(), - self.ide_context_active, + self.footer.collaboration_mode_indicator, + self.footer.goal_status_indicator.as_ref(), + self.footer.ide_context_active, show_cycle_hint, ) { if !spans.is_empty() { @@ -1140,8 +1100,10 @@ impl ChatComposer { } fn right_footer_line_with_context(&self) -> Line<'static> { - let mut line = - context_window_line(self.context_window_percent, self.context_window_used_tokens); + let mut line = context_window_line( + self.footer.context_window_percent, + self.footer.context_window_used_tokens, + ); if let Some(vim_mode) = self.vim_mode_indicator_span() { line.spans.push(" | ".dim()); line.spans.push(vim_mode); @@ -1151,27 +1113,30 @@ impl ChatComposer { pub(crate) fn current_text_with_pending(&self) -> String { let text = self.current_text(); - if self.pending_pastes.is_empty() { + if self.draft.pending_pastes.is_empty() { return text; } - let (text, _) = - Self::expand_pending_pastes(&text, self.current_text_elements(), &self.pending_pastes); + let (text, _) = Self::expand_pending_pastes( + &text, + self.current_text_elements(), + &self.draft.pending_pastes, + ); text } /// Returns whether the composer currently accepts interactive draft edits. pub(crate) fn input_enabled(&self) -> bool { - self.input_enabled + self.draft.input_enabled } pub(crate) fn pending_pastes(&self) -> Vec<(String, String)> { - self.pending_pastes.clone() + self.draft.pending_pastes.clone() } pub(crate) fn set_pending_pastes(&mut self, pending_pastes: Vec<(String, String)>) { let text = self.current_text(); - self.pending_pastes = pending_pastes + self.draft.pending_pastes = pending_pastes .into_iter() .filter(|(placeholder, _)| text.contains(placeholder)) .collect(); @@ -1180,7 +1145,7 @@ impl ChatComposer { /// Override the footer hint items displayed beneath the composer. Passing /// `None` restores the default shortcut footer. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { - self.footer_hint_override = items; + self.footer.hint_override = items; } /// Updates whether the Plan-mode nudge replaces the ambient footer row. @@ -1188,21 +1153,21 @@ impl ChatComposer { /// Returns `true` only when the rendered footer can change so callers can avoid scheduling /// redundant redraws while reevaluating nudge policy on routine composer updates. pub(crate) fn set_plan_mode_nudge_visible(&mut self, visible: bool) -> bool { - if self.plan_mode_nudge_visible == visible { + if self.footer.plan_mode_nudge_visible == visible { return false; } - self.plan_mode_nudge_visible = visible; + self.footer.plan_mode_nudge_visible = visible; true } #[cfg(test)] pub(crate) fn plan_mode_nudge_visible(&self) -> bool { - self.plan_mode_nudge_visible + self.footer.plan_mode_nudge_visible } pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { self.attachments - .set_remote_image_urls(urls, &mut self.textarea); + .set_remote_image_urls(urls, &mut self.draft.textarea); self.sync_popups(); } @@ -1211,23 +1176,16 @@ impl ChatComposer { } pub(crate) fn take_remote_image_urls(&mut self) -> Vec { - let urls = self.attachments.take_remote_image_urls(&mut self.textarea); + let urls = self + .attachments + .take_remote_image_urls(&mut self.draft.textarea); self.sync_popups(); urls } #[cfg(test)] pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { - let expires_at = Instant::now() - .checked_add(duration) - .unwrap_or_else(Instant::now); - self.footer_flash = Some(FooterFlash { line, expires_at }); - } - - pub(crate) fn footer_flash_visible(&self) -> bool { - self.footer_flash - .as_ref() - .is_some_and(|flash| Instant::now() < flash.expires_at) + self.footer.show_flash(line, duration); } /// Replace the entire composer content with `text` and reset cursor. @@ -1270,31 +1228,33 @@ impl ChatComposer { mention_bindings: Vec, ) { // Clear any existing content, placeholders, and attachments first. - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; - self.pending_pastes.clear(); - self.mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; + self.draft.pending_pastes.clear(); + self.draft.mention_bindings.clear(); let (text, text_elements) = self.imported_text_for_textarea(text, text_elements); - self.textarea.set_text_with_elements(&text, &text_elements); + self.draft + .textarea + .set_text_with_elements(&text, &text_elements); self.attachments - .reset_local_images(local_image_paths, &mut self.textarea); + .reset_local_images(local_image_paths, &mut self.draft.textarea); self.bind_mentions_from_snapshot(mention_bindings); - self.textarea.set_cursor(/*pos*/ 0); + self.draft.textarea.set_cursor(/*pos*/ 0); self.sync_popups(); } fn current_cursor(&self) -> usize { - self.textarea.cursor() + if self.is_bash_mode { 1 } else { 0 } + self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } fn history_navigation_cursor(&self) -> usize { - if self.is_bash_mode && self.textarea.cursor() == 0 { + if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { 0 - } else if self.textarea.is_vim_normal_mode() - && !self.textarea.text().is_empty() - && self.textarea.cursor() == self.textarea.vim_normal_end_cursor() + } else if self.draft.textarea.is_vim_normal_mode() + && !self.draft.textarea.text().is_empty() + && self.draft.textarea.cursor() == self.draft.textarea.vim_normal_end_cursor() { self.current_text().len() } else { @@ -1303,18 +1263,20 @@ impl ChatComposer { } fn set_current_cursor(&mut self, cursor: usize) { - let visible_cursor = if self.is_bash_mode { + let visible_cursor = if self.draft.is_bash_mode { cursor.saturating_sub(1) } else { cursor }; - self.textarea - .set_cursor(visible_cursor.min(self.textarea.text().len())); + self.draft + .textarea + .set_cursor(visible_cursor.min(self.draft.textarea.text().len())); } fn current_text_elements(&self) -> Vec { - let shift = if self.is_bash_mode { 1 } else { 0 }; - self.textarea + let shift = if self.draft.is_bash_mode { 1 } else { 0 }; + self.draft + .textarea .text_elements() .into_iter() .filter_map(|element| Self::shift_text_element(element, shift)) @@ -1338,7 +1300,7 @@ impl ChatComposer { local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), - pending_pastes: self.pending_pastes.clone(), + pending_pastes: self.draft.pending_pastes.clone(), cursor: self.current_cursor(), } } @@ -1372,17 +1334,19 @@ impl ChatComposer { /// Move the cursor to the end of the current text buffer. pub(crate) fn move_cursor_to_end(&mut self) { - self.textarea.set_cursor(self.textarea.text().len()); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); self.sync_popups(); } fn move_cursor_to_history_entry_end(&mut self) { - let cursor = if self.textarea.is_vim_normal_mode() { - self.textarea.vim_normal_end_cursor() + let cursor = if self.draft.textarea.is_vim_normal_mode() { + self.draft.textarea.vim_normal_end_cursor() } else { - self.textarea.text().len() + self.draft.textarea.text().len() }; - self.textarea.set_cursor(cursor); + self.draft.textarea.set_cursor(cursor); self.sync_popups(); } @@ -1396,7 +1360,7 @@ impl ChatComposer { text_elements: Vec, ) -> (String, Vec) { if let Some(stripped) = text.strip_prefix('!') { - self.is_bash_mode = true; + self.draft.is_bash_mode = true; ( stripped.to_string(), text_elements @@ -1405,7 +1369,7 @@ impl ChatComposer { .collect(), ) } else { - self.is_bash_mode = false; + self.draft.is_bash_mode = false; (text, text_elements) } } @@ -1417,7 +1381,7 @@ impl ChatComposer { let previous = self.current_text(); let text_elements = self.current_text_elements(); let local_image_paths = self.attachments.local_image_paths(); - let pending_pastes = std::mem::take(&mut self.pending_pastes); + let pending_pastes = std::mem::take(&mut self.draft.pending_pastes); let remote_image_urls = self.attachments.remote_image_urls(); let mention_bindings = self.snapshot_mention_bindings(); self.set_text_content(String::new(), Vec::new(), Vec::new()); @@ -1436,10 +1400,10 @@ impl ChatComposer { /// Get the current composer text. pub(crate) fn current_text(&self) -> String { - if self.is_bash_mode { - format!("!{}", self.textarea.text()) + if self.draft.is_bash_mode { + format!("!{}", self.draft.textarea.text()) } else { - self.textarea.text().to_string() + self.draft.textarea.text().to_string() } } @@ -1474,6 +1438,17 @@ impl ChatComposer { self.current_text_elements() } + pub(crate) fn draft_snapshot(&self) -> ComposerDraftSnapshot { + ComposerDraftSnapshot { + text: self.current_text(), + text_elements: self.text_elements(), + local_images: self.local_images(), + remote_image_urls: self.remote_image_urls(), + mention_bindings: self.mention_bindings(), + pending_pastes: self.pending_pastes(), + } + } + #[cfg(test)] pub(crate) fn local_image_paths(&self) -> Vec { self.attachments.local_image_paths() @@ -1481,12 +1456,7 @@ impl ChatComposer { #[cfg(test)] pub(crate) fn status_line_text(&self) -> Option { - self.status_line_value.as_ref().map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + self.footer.status_line_text() } pub(crate) fn local_images(&self) -> Vec { @@ -1498,7 +1468,7 @@ impl ChatComposer { } pub(crate) fn take_recent_submission_mention_bindings(&mut self) -> Vec { - std::mem::take(&mut self.recent_submission_mention_bindings) + std::mem::take(&mut self.draft.recent_submission_mention_bindings) } /// Commit the staged slash-command draft to local Up-arrow recall. @@ -1513,7 +1483,8 @@ impl ChatComposer { /// Insert an attachment placeholder and track it for the next submission. pub fn attach_image(&mut self, path: PathBuf) { - self.attachments.attach_image(&mut self.textarea, path); + self.attachments + .attach_image(&mut self.draft.textarea, path); } #[cfg(test)] @@ -1545,7 +1516,7 @@ impl ChatComposer { /// This includes actively buffering, having a non-empty burst buffer, or holding the first /// ASCII char for flicker suppression. pub(crate) fn is_in_paste_burst(&self) -> bool { - self.paste_burst.is_active() + self.draft.paste_burst.is_active() } /// Returns a delay that reliably exceeds the paste-burst timing threshold. @@ -1561,7 +1532,7 @@ impl ChatComposer { let current_opt = if self.mentions_v2_enabled { self.current_mentions_v2_token() } else { - Self::current_at_token(&self.textarea) + Self::current_at_token(&self.draft.textarea) }; let Some(current_token) = current_opt else { return; @@ -1588,18 +1559,18 @@ impl ChatComposer { /// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear /// even when the UI is otherwise idle. pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) { - self.quit_shortcut_expires_at = Instant::now() + self.footer.quit_shortcut_expires_at = Instant::now() .checked_add(super::QUIT_SHORTCUT_TIMEOUT) .or_else(|| Some(Instant::now())); - self.quit_shortcut_key = key; - self.footer_mode = FooterMode::QuitShortcutReminder; + self.footer.quit_shortcut_key = key; + self.footer.mode = FooterMode::QuitShortcutReminder; self.set_has_focus(has_focus); } /// Clear the "press again to quit" hint immediately. pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) { - self.quit_shortcut_expires_at = None; - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.quit_shortcut_expires_at = None; + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.set_has_focus(has_focus); } @@ -1609,7 +1580,8 @@ impl ChatComposer { /// any additional user input, so the UI schedules a redraw when the hint /// expires. pub(crate) fn quit_shortcut_hint_visible(&self) -> bool { - self.quit_shortcut_expires_at + self.footer + .quit_shortcut_expires_at .is_some_and(|expires_at| Instant::now() < expires_at) } @@ -1618,7 +1590,7 @@ impl ChatComposer { let prefix = format!("{base} #"); let mut max_suffix = 0usize; - for (placeholder, _) in &self.pending_pastes { + for (placeholder, _) in &self.draft.pending_pastes { if placeholder == &base { max_suffix = max_suffix.max(1); continue; @@ -1638,14 +1610,14 @@ impl ChatComposer { } pub(crate) fn insert_str(&mut self, text: &str) { - self.textarea.insert_str(text); + self.draft.textarea.insert_str(text); self.sync_bash_mode_from_text(); self.sync_popups(); } /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if !self.input_enabled { + if !self.draft.input_enabled { return (InputResult::None, false); } @@ -1685,13 +1657,13 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } let ActivePopup::Command(popup) = &mut self.popups.active else { unreachable!(); @@ -1733,7 +1705,7 @@ impl ChatComposer { } => { // Ensure popup filtering/selection reflects the latest composer text // before applying completion. - let first_line = self.textarea.text().lines().next().unwrap_or(""); + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(selected_cmd) = popup.selected_item() { let selected_command_text = format!("/{}", selected_cmd.command()); @@ -1741,18 +1713,21 @@ impl ChatComposer { && cmd == SlashCommand::Skills { self.stage_selected_slash_command_history(&CommandItem::Builtin(cmd)); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; return (InputResult::Command(cmd), true); } let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { - self.textarea + self.draft + .textarea .set_text_clearing_elements(&format!("{selected_command_text} ")); - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); } return (InputResult::None, true); } @@ -1769,19 +1744,22 @@ impl ChatComposer { } => { // Treat "/" as accepting the highlighted command as text completion // while the slash-command popup is active. - let first_line = self.textarea.text().lines().next().unwrap_or(""); + let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); popup.on_composer_text_change(first_line.to_string()); if let Some(selected_cmd) = popup.selected_item() { let selected_command_text = format!("/{}", selected_cmd.command()); let starts_with_cmd = first_line.trim_start().starts_with(&selected_command_text); if !starts_with_cmd { - self.textarea + self.draft + .textarea .set_text_clearing_elements(&format!("{selected_command_text} ")); - self.is_bash_mode = false; + self.draft.is_bash_mode = false; } - if !self.textarea.text().is_empty() { - self.textarea.set_cursor(self.textarea.text().len()); + if !self.draft.textarea.text().is_empty() { + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); } } (InputResult::None, true) @@ -1793,8 +1771,8 @@ impl ChatComposer { } => { if let Some(sel) = popup.selected_item() { self.stage_selected_slash_command_history(&sel); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; return ( match sel { CommandItem::Builtin(cmd) => InputResult::Command(cmd), @@ -1842,12 +1820,13 @@ impl ChatComposer { /// the cursor to a UTF-8 char boundary before slicing `textarea.text()`. #[inline] fn handle_non_ascii_char(&mut self, input: KeyEvent, now: Instant) -> (InputResult, bool) { - if self.disable_paste_burst { + if self.draft.disable_paste_burst { // When burst detection is disabled, treat IME/non-ASCII input as normal typing. // In particular, do not retro-capture or buffer already-inserted prefix text. - self.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes + self.draft.textarea.input(input); + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); return (InputResult::None, true); } @@ -1856,7 +1835,7 @@ impl ChatComposer { .. } = input { - if self.paste_burst.try_append_char_if_active(ch, now) { + if self.draft.paste_burst.try_append_char_if_active(ch, now) { return (InputResult::None, true); } // Non-ASCII input often comes from IMEs and can arrive in quick bursts. @@ -1864,32 +1843,35 @@ impl ChatComposer { // still want to detect paste-like bursts. Before applying any non-ASCII input, flush // any existing burst buffer (including a pending first char from the ASCII path) so // we don't carry that transient state forward. - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - if let Some(decision) = self.paste_burst.on_plain_char_no_hold(now) { + if let Some(decision) = self.draft.paste_burst.on_plain_char_no_hold(now) { match decision { CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { // For non-ASCII we inserted prior chars immediately, so if this turns out // to be paste-like we need to retroactively grab & remove the already- // inserted prefix from the textarea before buffering the burst. - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); + self.draft + .textarea + .replace_range(grab.start_byte..safe_cur, ""); } // seed the paste burst buffer with everything (grabbed + new) - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } // If decide_begin_buffer opted not to start buffering, @@ -1899,13 +1881,14 @@ impl ChatComposer { } } } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.textarea.input(input); + self.draft.textarea.input(input); - let text_after = self.textarea.text(); - self.pending_pastes + let text_after = self.draft.textarea.text(); + self.draft + .pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder)); (InputResult::None, true) } @@ -1916,13 +1899,13 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } let ActivePopup::File(popup) = &mut self.popups.active else { unreachable!(); @@ -1956,7 +1939,7 @@ impl ChatComposer { code: KeyCode::Esc, .. } => { // Hide popup without modifying text, remember token to avoid immediate reopen. - if let Some(tok) = Self::current_at_token(&self.textarea) { + if let Some(tok) = Self::current_at_token(&self.draft.textarea) { self.popups.dismissed_file_token = Some(tok); } self.popups.active = ActivePopup::None; @@ -1990,8 +1973,8 @@ impl ChatComposer { tracing::debug!("selected image dimensions={}x{}", width, height); // Remove the current @token (mirror logic from insert_selected_path without inserting text) // using the flat text and byte-offset cursor API. - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -2010,12 +1993,12 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); // Add a trailing space to keep typing fluid. - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2038,7 +2021,7 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); let ActivePopup::Skill(popup) = &mut self.popups.active else { unreachable!(); @@ -2114,7 +2097,7 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); let ActivePopup::MentionV2(popup) = &mut self.popups.active else { unreachable!(); @@ -2219,8 +2202,8 @@ impl ChatComposer { match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; @@ -2237,10 +2220,10 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); @@ -2511,14 +2494,14 @@ impl ChatComposer { if !self.mentions_v2_enabled { return None; } - Self::current_prefixed_token(&self.textarea, '@', /*allow_empty*/ true) + Self::current_prefixed_token(&self.draft.textarea, '@', /*allow_empty*/ true) } fn current_mention_token(&self) -> Option { if !self.mentions_enabled() { return None; } - Self::current_prefixed_token(&self.textarea, '$', /*allow_empty*/ true) + Self::current_prefixed_token(&self.draft.textarea, '$', /*allow_empty*/ true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -2527,8 +2510,8 @@ impl ChatComposer { /// where the cursor is within the token and regardless of how many /// `@tokens` exist in the line. fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); @@ -2561,15 +2544,16 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. - self.textarea + self.draft + .textarea .replace_range(start_idx..end_idx, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); - self.textarea.set_cursor(new_cursor); + self.draft.textarea.set_cursor(new_cursor); } fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); + let cursor_offset = self.draft.textarea.cursor(); + let text = self.draft.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; @@ -2589,14 +2573,14 @@ impl ChatComposer { let end_idx = safe_cursor + end_rel_idx; // Remove the active token and insert the selected mention as an atomic element. - self.textarea.replace_range(start_idx..end_idx, ""); - self.textarea.set_cursor(start_idx); - let id = self.textarea.insert_element(insert_text); + self.draft.textarea.replace_range(start_idx..end_idx, ""); + self.draft.textarea.set_cursor(start_idx); + let id = self.draft.textarea.insert_element(insert_text); if let (Some(path), Some(mention)) = (path, Self::mention_name_from_insert_text(insert_text)) { - self.mention_bindings.insert( + self.draft.mention_bindings.insert( id, ComposerMentionBinding { mention, @@ -2605,11 +2589,11 @@ impl ChatComposer { ); } - self.textarea.insert_str(" "); + self.draft.textarea.insert_str(" "); let new_cursor = start_idx .saturating_add(insert_text.len()) .saturating_add(1); - self.textarea.set_cursor(new_cursor); + self.draft.textarea.set_cursor(new_cursor); } fn mention_name_from_insert_text(insert_text: &str) -> Option { @@ -2629,7 +2613,8 @@ impl ChatComposer { } fn current_mention_elements(&self) -> Vec<(u64, String)> { - self.textarea + self.draft + .textarea .text_element_snapshots() .into_iter() .filter_map(|snapshot| { @@ -2642,7 +2627,7 @@ impl ChatComposer { fn snapshot_mention_bindings(&self) -> Vec { let mut ordered = Vec::new(); for (id, mention) in self.current_mention_elements() { - if let Some(binding) = self.mention_bindings.get(&id) + if let Some(binding) = self.draft.mention_bindings.get(&id) && binding.mention == mention { ordered.push(MentionBinding { @@ -2655,12 +2640,12 @@ impl ChatComposer { } fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec) { - self.mention_bindings.clear(); + self.draft.mention_bindings.clear(); if mention_bindings.is_empty() { return; } - let text = self.textarea.text().to_string(); + let text = self.draft.textarea.text().to_string(); let mut scan_from = 0usize; for binding in mention_bindings { let token = format!("${}", binding.mention); @@ -2670,14 +2655,16 @@ impl ChatComposer { continue; }; - let id = if let Some(id) = self.textarea.add_element_range(range.clone()) { + let id = if let Some(id) = self.draft.textarea.add_element_range(range.clone()) { Some(id) } else { - self.textarea.element_id_for_exact_range(range.clone()) + self.draft + .textarea + .element_id_for_exact_range(range.clone()) }; if let Some(id) = id { - self.mention_bindings.insert( + self.draft.mention_bindings.insert( id, ComposerMentionBinding { mention: binding.mention, @@ -2710,17 +2697,17 @@ impl ChatComposer { let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); let original_local_image_paths = self.attachments.local_image_paths(); - let original_pending_pastes = self.pending_pastes.clone(); + let original_pending_pastes = self.draft.pending_pastes.clone(); let mut text_elements = original_text_elements.clone(); let input_starts_with_space = original_input.starts_with(' '); - self.recent_submission_mention_bindings.clear(); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.recent_submission_mention_bindings.clear(); + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; - if !self.pending_pastes.is_empty() { + if !self.draft.pending_pastes.is_empty() { // Expand placeholders so element byte ranges stay aligned. let (expanded, expanded_elements) = - Self::expand_pending_pastes(&text, text_elements, &self.pending_pastes); + Self::expand_pending_pastes(&text, text_elements, &self.draft.pending_pastes); text = expanded; text_elements = expanded_elements; } @@ -2756,8 +2743,10 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); return None; } } @@ -2775,8 +2764,10 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes.clone_from(&original_pending_pastes); - self.textarea.set_cursor(original_input.len()); + self.draft + .pending_pastes + .clone_from(&original_pending_pastes); + self.draft.textarea.set_cursor(original_input.len()); return None; } self.attachments @@ -2784,7 +2775,7 @@ impl ChatComposer { if text.is_empty() && self.attachments.is_empty() { return None; } - self.recent_submission_mention_bindings = original_mention_bindings.clone(); + self.draft.recent_submission_mention_bindings = original_mention_bindings.clone(); if record_history && (!text.is_empty() || !self.attachments.is_empty()) { self.history.record_local_submission(HistoryEntry { text: text.clone(), @@ -2795,7 +2786,7 @@ impl ChatComposer { pending_pastes: Vec::new(), }); } - self.pending_pastes.clear(); + self.draft.pending_pastes.clear(); Some((text, text_elements)) } @@ -2816,7 +2807,7 @@ impl ChatComposer { | InputResult::ServiceTierCommand(_) | InputResult::CommandWithArgs(_, _, _) ) { - self.textarea.enter_vim_normal_mode(); + self.draft.textarea.enter_vim_normal_mode(); } } @@ -2826,7 +2817,7 @@ impl ChatComposer { now: Instant, ) -> (InputResult, bool) { if should_queue { - let raw_text = self.textarea.text(); + let raw_text = self.draft.textarea.text(); let defer_slash_validation = self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text); if let Some((text, text_elements)) = self.prepare_submission_text_with_options( @@ -2865,32 +2856,34 @@ impl ChatComposer { // and accumulate it rather than submitting or inserting immediately. // Do not treat as paste inside a slash-command context. let in_slash_context = self.slash_commands_enabled() - && !self.is_bash_mode + && !self.draft.is_bash_mode && (matches!(self.popups.active, ActivePopup::Command(_)) || self + .draft .textarea .text() .lines() .next() .unwrap_or("") .starts_with('/')); - if !self.disable_paste_burst - && self.paste_burst.is_active() + if !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() && !in_slash_context - && self.paste_burst.append_newline_if_active(now) + && self.draft.paste_burst.append_newline_if_active(now) { return (InputResult::None, true); } // During a paste-like burst, treat Enter/Ctrl+Shift+Q as a newline instead of submit. if !in_slash_context - && !self.disable_paste_burst + && !self.draft.disable_paste_burst && self + .draft .paste_burst .newline_should_insert_instead_of_submit(now) { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); + self.draft.textarea.insert_str("\n"); + self.draft.paste_burst.extend_window(now); return (InputResult::None, true); } @@ -2898,7 +2891,7 @@ impl ChatComposer { let original_text_elements = self.current_text_elements(); let original_mention_bindings = self.snapshot_mention_bindings(); let original_local_image_paths = self.attachments.local_image_paths(); - let original_pending_pastes = self.pending_pastes.clone(); + let original_pending_pastes = self.draft.pending_pastes.clone(); if let Some(result) = self.try_dispatch_slash_command_with_args() { return (result, true); } @@ -2934,7 +2927,7 @@ impl ChatComposer { original_local_image_paths, original_mention_bindings, ); - self.pending_pastes = original_pending_pastes; + self.draft.pending_pastes = original_pending_pastes; (InputResult::None, true) } } @@ -2942,10 +2935,10 @@ impl ChatComposer { /// Check if the first line is a bare slash command (no args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_bare_slash_command(&mut self) -> Option { - if !self.slash_commands_enabled() || self.is_bash_mode { + if !self.slash_commands_enabled() || self.draft.is_bash_mode { return None; } - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line = text.lines().next().unwrap_or(""); let (name, rest, _rest_offset) = parse_slash_name(first_line)?; if !rest.is_empty() { @@ -2967,8 +2960,8 @@ impl ChatComposer { return Some(InputResult::None); } self.stage_slash_command_history(&command); - self.textarea.set_text_clearing_elements(""); - self.is_bash_mode = false; + self.draft.textarea.set_text_clearing_elements(""); + self.draft.is_bash_mode = false; Some(match command { SlashCommandItem::Builtin(cmd) => InputResult::Command(cmd), SlashCommandItem::ServiceTier(command) => InputResult::ServiceTierCommand(command), @@ -2978,10 +2971,10 @@ impl ChatComposer { /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. /// Returns Some(InputResult) if a command was dispatched, None otherwise. fn try_dispatch_slash_command_with_args(&mut self) -> Option { - if !self.slash_commands_enabled() || self.is_bash_mode { + if !self.slash_commands_enabled() || self.draft.is_bash_mode { return None; } - let text = self.textarea.text().to_string(); + let text = self.draft.textarea.text().to_string(); if text.starts_with(' ') { return None; } @@ -3008,8 +3001,11 @@ impl ChatComposer { self.stage_slash_command_history(&command); - let mut args_elements = - Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); + let mut args_elements = Self::slash_command_args_elements( + rest, + rest_offset, + &self.draft.textarea.text_elements(), + ); let trimmed_rest = rest.trim(); args_elements = Self::trim_text_elements(rest, trimmed_rest, args_elements); let SlashCommandItem::Builtin(cmd) = command else { @@ -3089,7 +3085,7 @@ impl ChatComposer { if matches!(command, SlashCommandItem::Builtin(SlashCommand::Clear)) { return; } - self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); + self.stage_slash_command_history_text(self.draft.textarea.text().trim().to_string()); } /// Stage a popup-selected command using its canonical command text. @@ -3111,11 +3107,11 @@ impl ChatComposer { fn stage_slash_command_history_text(&mut self, text: String) { self.pending_slash_command_history = Some(HistoryEntry { text, - text_elements: self.textarea.text_elements(), + text_elements: self.draft.textarea.text_elements(), local_image_paths: self.attachments.local_image_paths(), remote_image_urls: self.attachments.remote_image_urls(), mention_bindings: self.snapshot_mention_bindings(), - pending_pastes: self.pending_pastes.clone(), + pending_pastes: self.draft.pending_pastes.clone(), }); } @@ -3152,7 +3148,7 @@ impl ChatComposer { key_event: &KeyEvent, ) -> Option<(InputResult, bool)> { self.attachments - .handle_remote_image_selection_key(key_event, &mut self.textarea) + .handle_remote_image_selection_key(key_event, &mut self.draft.textarea) } /// Handle key event when no popup is visible. @@ -3166,22 +3162,23 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } - if self.is_bash_mode && key_event.code == KeyCode::Esc { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if self.draft.is_bash_mode && key_event.code == KeyCode::Esc { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - if self.textarea.is_empty() { - self.is_bash_mode = false; + if self.draft.textarea.is_empty() { + self.draft.is_bash_mode = false; return (InputResult::None, true); } } if self.should_handle_vim_insert_escape(key_event) { return self.handle_input_basic(key_event); } - if self.textarea.is_vim_normal_mode() && self.textarea.is_vim_operator_pending() { + if self.draft.textarea.is_vim_normal_mode() && self.draft.textarea.is_vim_operator_pending() + { return self.handle_input_basic(key_event); } - if self.textarea.is_vim_normal_mode() + if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( key_event, @@ -3193,13 +3190,15 @@ impl ChatComposer { } ) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.textarea.set_text_clearing_elements("/"); - self.textarea.set_cursor(self.textarea.text().len()); - self.textarea.enter_vim_insert_mode(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.textarea.set_text_clearing_elements("/"); + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } - if self.textarea.is_vim_normal_mode() + if self.draft.textarea.is_vim_normal_mode() && self.is_empty() && matches!( key_event, @@ -3211,21 +3210,21 @@ impl ChatComposer { } ) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.is_bash_mode = true; - self.textarea.enter_vim_insert_mode(); + self.footer.mode = reset_mode_after_activity(self.footer.mode); + self.draft.is_bash_mode = true; + self.draft.textarea.enter_vim_insert_mode(); return (InputResult::None, true); } if key_event.code == KeyCode::Esc { if self.is_empty() { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; + let next_mode = esc_hint_mode(self.footer.mode, self.is_task_running); + if next_mode != self.footer.mode { + self.footer.mode = next_mode; return (InputResult::None, true); } } } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } if self.queue_keys.is_pressed(key_event) && (self.is_task_running || !self.is_bang_shell_command()) @@ -3248,8 +3247,9 @@ impl ChatComposer { return (InputResult::None, false); } - let (history_up_pressed, history_down_pressed) = if self.textarea.is_vim_normal_mode() { - if self.textarea.is_vim_operator_pending() { + let (history_up_pressed, history_down_pressed) = if self.draft.textarea.is_vim_normal_mode() + { + if self.draft.textarea.is_vim_operator_pending() { (false, false) } else { ( @@ -3303,7 +3303,7 @@ impl ChatComposer { /// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render. /// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag. fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { - match self.paste_burst.flush_if_due(now) { + match self.draft.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { self.handle_paste(pasted); true @@ -3350,14 +3350,14 @@ impl ChatComposer { self.handle_paste_burst_flush(now); if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) - && !self.disable_paste_burst - && self.paste_burst.is_active() - && self.paste_burst.append_newline_if_active(now) + && !self.draft.disable_paste_burst + && self.draft.paste_burst.is_active() + && self.draft.paste_burst.append_newline_if_active(now) { return (InputResult::None, true); } @@ -3374,31 +3374,37 @@ impl ChatComposer { } = input { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); - if !has_ctrl_or_alt && !self.disable_paste_burst && self.textarea.allows_paste_burst() { + if !has_ctrl_or_alt + && !self.draft.disable_paste_burst + && self.draft.textarea.allows_paste_burst() + { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts, so avoid // holding the first char while still allowing burst detection for paste input. if !ch.is_ascii() { return self.handle_non_ascii_char(input, now); } - match self.paste_burst.on_plain_char(ch, now) { + match self.draft.paste_burst.on_plain_char(ch, now) { CharDecision::BufferAppend => { - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::BeginBuffer { retro_chars } => { - let cur = self.textarea.cursor(); - let txt = self.textarea.text(); + let cur = self.draft.textarea.cursor(); + let txt = self.draft.textarea.text(); let safe_cur = Self::clamp_to_char_boundary(txt, cur); let before = &txt[..safe_cur]; - if let Some(grab) = - self.paste_burst - .decide_begin_buffer(now, before, retro_chars as usize) - { + if let Some(grab) = self.draft.paste_burst.decide_begin_buffer( + now, + before, + retro_chars as usize, + ) { if !grab.grabbed.is_empty() { - self.textarea.replace_range(grab.start_byte..safe_cur, ""); + self.draft + .textarea + .replace_range(grab.start_byte..safe_cur, ""); } - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } // If decide_begin_buffer opted not to start buffering, @@ -3406,7 +3412,7 @@ impl ChatComposer { } CharDecision::BeginBufferFromPending => { // First char was held; now append the current one. - self.paste_burst.append_char_to_buffer(ch, now); + self.draft.paste_burst.append_char_to_buffer(ch, now); return (InputResult::None, true); } CharDecision::RetainFirstChar => { @@ -3415,7 +3421,7 @@ impl ChatComposer { } } } - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } } @@ -3427,28 +3433,29 @@ impl ChatComposer { // time out against, and the buffered paste could remain stuck until another plain char // arrives. if !matches!(input.code, KeyCode::Char(_) | KeyCode::Enter) - && let Some(pasted) = self.paste_burst.flush_before_modified_input() + && let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } // For non-char inputs (or after flushing), handle normally. // Track element removals so we can drop any corresponding placeholders without scanning // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) - let elements_before = if self.pending_pastes.is_empty() && self.attachments.is_empty() { + let elements_before = if self.draft.pending_pastes.is_empty() && self.attachments.is_empty() + { None } else { - Some(self.textarea.element_payloads()) + Some(self.draft.textarea.element_payloads()) }; - if self.is_bash_mode + if self.draft.is_bash_mode && matches!(input.code, KeyCode::Backspace) - && self.textarea.cursor() == 0 + && self.draft.textarea.cursor() == 0 { - self.is_bash_mode = false; + self.draft.is_bash_mode = false; return (InputResult::None, true); } - self.textarea.input(input); + self.draft.textarea.input(input); self.sync_bash_mode_from_text(); if let Some(elements_before) = elements_before { @@ -3463,7 +3470,7 @@ impl ChatComposer { KeyCode::Char(_) => { let has_ctrl_or_alt = has_ctrl_or_alt(modifiers); if has_ctrl_or_alt { - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); } } KeyCode::Enter => { @@ -3471,7 +3478,7 @@ impl ChatComposer { } _ => { // Other keys: clear burst window (buffer should have been flushed above if needed). - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); } } @@ -3479,25 +3486,25 @@ impl ChatComposer { } fn sync_bash_mode_from_text(&mut self) { - if !self.is_bash_mode && self.textarea.text().starts_with('!') { - self.textarea.replace_range(0..1, ""); - self.is_bash_mode = true; + if !self.draft.is_bash_mode && self.draft.textarea.text().starts_with('!') { + self.draft.textarea.replace_range(0..1, ""); + self.draft.is_bash_mode = true; } } fn reconcile_deleted_elements(&mut self, elements_before: Vec) { let elements_after: HashSet = - self.textarea.element_payloads().into_iter().collect(); + self.draft.textarea.element_payloads().into_iter().collect(); let removed_payloads = elements_before .into_iter() .filter(|payload| !elements_after.contains(payload)) .collect::>(); for removed in &removed_payloads { - self.pending_pastes.retain(|(ph, _)| ph != removed); + self.draft.pending_pastes.retain(|(ph, _)| ph != removed); } self.attachments - .remove_deleted_local_placeholders(&removed_payloads, &mut self.textarea); + .remove_deleted_local_placeholders(&removed_payloads, &mut self.draft.textarea); } /// Handle the dedicated shortcut-overlay toggle key(s). @@ -3520,12 +3527,12 @@ impl ChatComposer { } let next = toggle_shortcut_mode( - self.footer_mode, + self.footer.mode, self.quit_shortcut_hint_visible(), self.is_empty(), ); - let changed = next != self.footer_mode; - self.footer_mode = next; + let changed = next != self.footer.mode; + self.footer.mode = next; changed } @@ -3544,26 +3551,26 @@ impl ChatComposer { FooterProps { mode, - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, + esc_backtrack_hint: self.footer.esc_backtrack_hint, + use_shift_enter_hint: self.footer.use_shift_enter_hint, is_task_running: self.is_task_running, - quit_shortcut_key: self.quit_shortcut_key, + quit_shortcut_key: self.footer.quit_shortcut_key, collaboration_modes_enabled: self.collaboration_modes_enabled, is_wsl, - status_line_value: self.status_line_value.clone(), - status_line_enabled: self.status_line_enabled, + status_line_value: self.footer.status_line_value.clone(), + status_line_enabled: self.footer.status_line_enabled, key_hints: FooterKeyHints { - toggle_shortcuts: self.footer_toggle_shortcuts_key, - queue: self.footer_queue_key, - insert_newline: self.footer_insert_newline_key, - external_editor: self.footer_external_editor_key, + toggle_shortcuts: self.footer.toggle_shortcuts_key, + queue: self.footer.queue_key, + insert_newline: self.footer.insert_newline_key, + external_editor: self.footer.external_editor_key, edit_previous: Some(key_hint::plain(KeyCode::Esc)), - show_transcript: self.footer_show_transcript_key, - history_search: self.footer_history_search_key, - reasoning_down: self.footer_reasoning_down_key, - reasoning_up: self.footer_reasoning_up_key, + show_transcript: self.footer.show_transcript_key, + history_search: self.footer.history_search_key, + reasoning_down: self.footer.reasoning_down_key, + reasoning_up: self.footer.reasoning_up_key, }, - active_agent_label: self.active_agent_label.clone(), + active_agent_label: self.footer.active_agent_label.clone(), } } @@ -3584,7 +3591,7 @@ impl ChatComposer { FooterMode::ComposerHasDraft }; - match self.footer_mode { + match self.footer.mode { FooterMode::HistorySearch => FooterMode::HistorySearch, FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, @@ -3602,10 +3609,11 @@ impl ChatComposer { } fn custom_footer_height(&self) -> Option { - if self.footer_flash_visible() { + if self.footer.flash_visible() { return Some(1); } - self.footer_hint_override + self.footer + .hint_override .as_ref() .map(|items| if items.is_empty() { 0 } else { 1 }) } @@ -3631,7 +3639,7 @@ impl ChatComposer { let file_token = if self.mentions_v2_enabled { None } else { - Self::current_at_token(&self.textarea) + Self::current_at_token(&self.draft.textarea) }; let browsing_history = self .history @@ -3650,7 +3658,7 @@ impl ChatComposer { let mention_token = self.current_mention_token(); let allow_command_popup = self.slash_commands_enabled() - && !self.is_bash_mode + && !self.draft.is_bash_mode && file_token.is_none() && mentions_v2_token.is_none() && mention_token.is_none(); @@ -3707,7 +3715,7 @@ impl ChatComposer { if !self.slash_commands_enabled() { return; } - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; let desired_range = self.slash_command_element_range(first_line); @@ -3715,7 +3723,7 @@ impl ChatComposer { // Any slash-shaped element not matching the current desired prefix is stale. let mut has_desired = false; let mut stale_ranges = Vec::new(); - for elem in self.textarea.text_elements() { + for elem in self.draft.textarea.text_elements() { let Some(payload) = elem.placeholder(text) else { continue; }; @@ -3731,18 +3739,18 @@ impl ChatComposer { } for range in stale_ranges { - self.textarea.remove_element_range(range); + self.draft.textarea.remove_element_range(range); } if let Some(range) = desired_range && !has_desired { - self.textarea.add_element_range(range); + self.draft.textarea.add_element_range(range); } } fn slash_command_element_range(&self, first_line: &str) -> Option> { - if self.is_bash_mode { + if self.draft.is_bash_mode { return None; } let (name, _rest, _rest_offset) = parse_slash_name(first_line)?; @@ -3830,10 +3838,10 @@ impl ChatComposer { return; } // Determine whether the caret is inside the initial '/name' token on the first line. - let text = self.textarea.text(); + let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; - let cursor = self.textarea.cursor(); + let cursor = self.draft.textarea.cursor(); let caret_on_first_line = cursor <= first_line_end; let is_editing_slash_command_name = caret_on_first_line @@ -3843,7 +3851,7 @@ impl ChatComposer { // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path // as an argument to the command (e.g., "/review @docs/..."). - if Self::current_at_token(&self.textarea).is_some() { + if Self::current_at_token(&self.draft.textarea).is_some() { if matches!(self.popups.active, ActivePopup::Command(_)) { self.popups.active = ActivePopup::None; } @@ -4113,8 +4121,8 @@ impl ChatComposer { #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { - self.input_enabled = enabled; - self.input_disabled_placeholder = if enabled { None } else { placeholder }; + self.draft.input_enabled = enabled; + self.draft.input_disabled_placeholder = if enabled { None } else { placeholder }; // Avoid leaving interactive popups open while input is blocked. if !enabled && self.popups.active() { @@ -4127,52 +4135,53 @@ impl ChatComposer { } pub(crate) fn set_context_window(&mut self, percent: Option, used_tokens: Option) { - if self.context_window_percent == percent && self.context_window_used_tokens == used_tokens + if self.footer.context_window_percent == percent + && self.footer.context_window_used_tokens == used_tokens { return; } - self.context_window_percent = percent; - self.context_window_used_tokens = used_tokens; + self.footer.context_window_percent = percent; + self.footer.context_window_used_tokens = used_tokens; } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { - self.esc_backtrack_hint = show; + self.footer.esc_backtrack_hint = show; if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + self.footer.mode = esc_hint_mode(self.footer.mode, self.is_task_running); } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); } } pub(crate) fn set_status_line(&mut self, status_line: Option>) -> bool { - if self.status_line_value == status_line { + if self.footer.status_line_value == status_line { return false; } - self.status_line_value = status_line; + self.footer.status_line_value = status_line; true } pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) -> bool { - if self.status_line_hyperlink_url == url { + if self.footer.status_line_hyperlink_url == url { return false; } - self.status_line_hyperlink_url = url; + self.footer.status_line_hyperlink_url = url; true } pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { - if self.status_line_enabled == enabled { + if self.footer.status_line_enabled == enabled { return false; } - self.status_line_enabled = enabled; + self.footer.status_line_enabled = enabled; true } pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) -> bool { - if self.side_conversation_context_label == label { + if self.footer.side_conversation_context_label == label { return false; } - self.side_conversation_context_label = label; + self.footer.side_conversation_context_label = label; true } @@ -4182,10 +4191,10 @@ impl ChatComposer { /// field is intentionally just cached presentation state; `ChatComposer` does not infer which /// thread is active on its own. pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) -> bool { - if self.active_agent_label == active_agent_label { + if self.footer.active_agent_label == active_agent_label { return false; } - self.active_agent_label = active_agent_label; + self.footer.active_agent_label = active_agent_label; true } } @@ -4210,17 +4219,17 @@ fn footer_insert_newline_key( #[cfg(not(target_os = "linux"))] impl ChatComposer { pub fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { - self.textarea.update_named_element_by_id(id, text) + self.draft.textarea.update_named_element_by_id(id, text) } pub fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { let id = self.next_id(); - self.textarea.insert_named_element(text, id.clone()); + self.draft.textarea.insert_named_element(text, id.clone()); id } pub fn remove_recording_meter_placeholder(&mut self, id: &str) { - let _ = self.textarea.replace_element_by_id(id, ""); + let _ = self.draft.textarea.replace_element_by_id(id, ""); } } @@ -4281,7 +4290,7 @@ impl Renderable for ChatComposer { } fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { - if self.textarea.uses_vim_insert_cursor() { + if self.draft.textarea.uses_vim_insert_cursor() { crossterm::cursor::SetCursorStyle::SteadyBar } else { crossterm::cursor::SetCursorStyle::DefaultUserShape @@ -4319,7 +4328,7 @@ impl ChatComposer { .try_into() .unwrap_or(u16::MAX); let remote_images_separator = u16::from(remote_images_height > 0); - self.textarea.desired_height(inner_width) + self.draft.textarea.desired_height(inner_width) + remote_images_height + remote_images_separator + 2 @@ -4364,8 +4373,8 @@ impl ChatComposer { } ActivePopup::None => { let footer_props = self.footer_props(); - let show_cycle_hint = - !footer_props.is_task_running && self.collaboration_mode_indicator.is_some(); + let show_cycle_hint = !footer_props.is_task_running + && self.footer.collaboration_mode_indicator.is_some(); let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), FooterMode::ComposerHasDraft => false, @@ -4398,7 +4407,7 @@ impl ChatComposer { }; if let Some(line) = self.history_search_footer_line() { render_footer_line(hint_rect, buf, line); - } else if self.plan_mode_nudge_visible { + } else if self.footer.plan_mode_nudge_visible { let available_width = hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; render_footer_line( @@ -4428,11 +4437,12 @@ impl ChatComposer { let left_mode_indicator = if status_line_active { None } else { - self.collaboration_mode_indicator + self.footer.collaboration_mode_indicator }; - let active_footer_hint_override = self.footer_hint_override.as_ref(); - let mut left_width = if self.footer_flash_visible() { - self.footer_flash + let active_footer_hint_override = self.footer.hint_override.as_ref(); + let mut left_width = if self.footer.flash_visible() { + self.footer + .flash .as_ref() .map(|flash| flash.line.width() as u16) .unwrap_or(0) @@ -4453,7 +4463,7 @@ impl ChatComposer { ) }; let right_line = - if let Some(label) = self.side_conversation_context_label.as_ref() { + if let Some(label) = self.footer.side_conversation_context_label.as_ref() { Some(side_conversation_context_line(label)) } else if let Some(line) = self.shell_mode_footer_line() { Some(line) @@ -4483,7 +4493,7 @@ impl ChatComposer { let can_show_left_and_context = can_show_left_with_context(hint_rect, left_width, right_width); let has_override = - self.footer_flash_visible() || active_footer_hint_override.is_some(); + self.footer.flash_visible() || active_footer_hint_override.is_some(); let single_line_layout = if has_override || status_line_active { None } else { @@ -4558,8 +4568,8 @@ impl ChatComposer { } SummaryLeft::None => {} } - } else if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { + } else if self.footer.flash_visible() { + if let Some(flash) = self.footer.flash.as_ref() { flash.line.render(inset_footer_hint_area(hint_rect), buf); } } else if let Some(items) = active_footer_hint_override { @@ -4573,7 +4583,7 @@ impl ChatComposer { hint_rect, buf, &footer_props, - self.collaboration_mode_indicator, + self.footer.collaboration_mode_indicator, show_cycle_hint, show_shortcuts_hint, show_queue_hint, @@ -4583,7 +4593,7 @@ impl ChatComposer { render_context_right(hint_rect, buf, line); } if status_line_active - && let Some(url) = self.status_line_hyperlink_url.as_deref() + && let Some(url) = self.footer.status_line_hyperlink_url.as_deref() { mark_underlined_hyperlink(buf, hint_rect, url); } @@ -4598,8 +4608,8 @@ impl ChatComposer { .render_ref(remote_images_rect, buf); } if !textarea_rect.is_empty() { - let prompt = if self.input_enabled { - if self.is_bash_mode { + let prompt = if self.draft.input_enabled { + if self.draft.is_bash_mode { Span::from("!").light_red().bold() } else { "›".bold() @@ -4615,15 +4625,21 @@ impl ChatComposer { ); } - let mut state = self.textarea_state.borrow_mut(); - let textarea_is_empty = self.textarea.text().is_empty() && !self.is_bash_mode; + let mut state = self.draft.textarea_state.borrow_mut(); + let textarea_is_empty = self.draft.textarea.text().is_empty() && !self.draft.is_bash_mode; if let Some(mask_char) = mask_char { - self.textarea + self.draft + .textarea .render_ref_masked(textarea_rect, buf, &mut state, mask_char); } else { let highlight_ranges = self.history_search_highlight_ranges(); if highlight_ranges.is_empty() { - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + StatefulWidgetRef::render_ref( + &(&self.draft.textarea), + textarea_rect, + buf, + &mut state, + ); } else { let highlight_style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); @@ -4631,7 +4647,7 @@ impl ChatComposer { .into_iter() .map(|range| (range, highlight_style)) .collect::>(); - self.textarea.render_ref_styled_with_highlights( + self.draft.textarea.render_ref_styled_with_highlights( textarea_rect, buf, &mut state, @@ -4641,10 +4657,11 @@ impl ChatComposer { } } if textarea_is_empty { - let text = if self.input_enabled { + let text = if self.draft.input_enabled { self.placeholder_text.as_str().to_string() } else { - self.input_disabled_placeholder + self.draft + .input_disabled_placeholder .as_deref() .unwrap_or("Input disabled.") .to_string() @@ -4788,8 +4805,8 @@ mod tests { let id = composer.insert_recording_meter_placeholder("⠤⠤⠤⠤"); composer.remove_recording_meter_placeholder(&id); - assert_eq!(composer.textarea.text(), ""); - assert!(composer.textarea.named_element_range(&id).is_none()); + assert_eq!(composer.draft.textarea.text(), ""); + assert!(composer.draft.textarea.named_element_range(&id).is_none()); } #[test] @@ -4805,7 +4822,7 @@ mod tests { ); composer.set_footer_hint_override(Some(vec![("K".to_string(), "label".to_string())])); composer.show_footer_flash(Line::from("FLASH"), Duration::from_secs(10)); - composer.footer_flash.as_mut().unwrap().expires_at = + composer.footer.flash.as_mut().unwrap().expires_at = Instant::now() - Duration::from_secs(1); let area = Rect::new(0, 0, 60, 6); @@ -5114,7 +5131,7 @@ mod tests { ); type_chars_humanlike(&mut composer, &['!']); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!"); let (result, needs_redraw) = @@ -5122,7 +5139,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(!composer.is_bash_mode); + assert!(!composer.draft.is_bash_mode); assert_eq!(composer.current_text(), ""); } @@ -5152,7 +5169,7 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!g"); } @@ -5415,13 +5432,13 @@ mod tests { assert!(!composer.is_empty()); assert_eq!(composer.current_text(), "d"); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert!(matches!(composer.popups.active, ActivePopup::None)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); - assert!(!composer.esc_backtrack_hint); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); } #[test] @@ -5458,8 +5475,8 @@ mod tests { composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); - assert!(!composer.esc_backtrack_hint); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); + assert!(!composer.footer.esc_backtrack_hint); } #[test] @@ -5484,8 +5501,8 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "/"); - assert_eq!(composer.textarea.cursor(), "/".len()); + assert_eq!(composer.draft.textarea.text(), "/"); + assert_eq!(composer.draft.textarea.cursor(), "/".len()); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); assert_eq!( composer.vim_mode_indicator_span(), @@ -5513,7 +5530,7 @@ mod tests { for ch in ['/', 'd', 'i', 'f', 'f'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "/diff"); + assert_eq!(composer.draft.textarea.text(), "/diff"); assert!(matches!(composer.popups.active, ActivePopup::Command(_))); let (result, needs_redraw) = @@ -5589,9 +5606,9 @@ mod tests { assert!(matches!(result, InputResult::None)); assert!(needs_redraw); - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!"); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -5619,9 +5636,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.is_bash_mode); + assert!(composer.draft.is_bash_mode); assert_eq!(composer.current_text(), "!echo"); - assert_eq!(composer.textarea.text(), "echo"); + assert_eq!(composer.draft.textarea.text(), "echo"); assert!(matches!(composer.popups.active, ActivePopup::None)); } @@ -5642,7 +5659,7 @@ mod tests { type_chars_humanlike(&mut composer, &['d']); composer .show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), /*has_focus*/ true); - composer.quit_shortcut_expires_at = + composer.footer.quit_shortcut_expires_at = Some(Instant::now() - std::time::Duration::from_secs(1)); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); @@ -5693,9 +5710,9 @@ mod tests { composer.handle_paste(large.clone()); let char_count = large.chars().count(); let placeholder = format!("[Pasted Content {char_count} chars]"); - assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.draft.textarea.text(), placeholder); assert_eq!( - composer.pending_pastes, + composer.draft.pending_pastes, vec![(placeholder.clone(), large.clone())] ); @@ -5721,9 +5738,15 @@ mod tests { ); composer.apply_history_entry(history_entry); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes, vec![(placeholder.clone(), large)]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder]); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!( + composer.draft.pending_pastes, + vec![(placeholder.clone(), large)] + ); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -5755,17 +5778,17 @@ mod tests { let base = format!("[Pasted Content {} chars]", paste.chars().count()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); assert_eq!(composer.clear_for_ctrl_c(), Some(base.clone())); - assert!(composer.textarea.text().is_empty()); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, base); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); } #[test] @@ -5786,7 +5809,7 @@ mod tests { composer.set_steer_enabled(/*enabled*/ true); composer.set_vim_enabled(/*enabled*/ true); - assert!(composer.textarea.is_vim_enabled()); + assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) @@ -5797,7 +5820,7 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(composer.textarea.is_vim_enabled()); + assert!(composer.draft.textarea.is_vim_enabled()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) @@ -5867,7 +5890,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.textarea.text(), "/not-a-command"); + assert_eq!(composer.draft.textarea.text(), "/not-a-command"); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) @@ -5893,19 +5916,22 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Insert".green()) ); - assert_eq!(composer.textarea.cursor(), "hey".len()); + assert_eq!(composer.draft.textarea.cursor(), "hey".len()); composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert_eq!(composer.textarea.cursor(), "he".len()); + assert_eq!(composer.draft.textarea.cursor(), "he".len()); } #[test] @@ -5986,9 +6012,12 @@ mod tests { ); composer.apply_history_entry(history_entry); - assert_eq!(composer.textarea.text(), placeholder); + assert_eq!(composer.draft.textarea.text(), placeholder); assert_eq!(composer.local_image_paths(), vec![path]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder] + ); } #[test] @@ -6092,14 +6121,14 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); // Toggle back to prompt mode so subsequent typing captures characters. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); type_chars_humanlike(&mut composer, &['h']); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); let (result, needs_redraw) = @@ -6107,8 +6136,8 @@ mod tests { assert_eq!(result, InputResult::None); assert!(needs_redraw, "typing should still mark the view dirty"); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ComposerEmpty); + assert_eq!(composer.draft.textarea.text(), "h?"); + assert_eq!(composer.footer.mode, FooterMode::ComposerEmpty); assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft); } @@ -6133,7 +6162,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)); assert_eq!(result, InputResult::None); assert!(needs_redraw, "toggling overlay should request redraw"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); } /// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut @@ -6156,6 +6185,7 @@ mod tests { // Force an active paste burst so this test doesn't depend on tight timing. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6163,12 +6193,12 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "hi?there"); - assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.draft.textarea.text(), "hi?there"); + assert_ne!(composer.footer.mode, FooterMode::ShortcutOverlay); } #[test] @@ -6528,11 +6558,11 @@ mod tests { ); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); composer.set_task_running(/*running*/ true); - assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); + assert_eq!(composer.footer.mode, FooterMode::ShortcutOverlay); assert_eq!(composer.footer_mode(), FooterMode::ShortcutOverlay); } @@ -6778,8 +6808,8 @@ mod tests { ); let input = "npx -y @kaeawc/auto-mobile@latest"; - composer.textarea.insert_str(input); - composer.textarea.set_cursor(input.len()); + composer.draft.textarea.insert_str(input); + composer.draft.textarea.set_cursor(input.len()); composer.sync_popups(); assert!(matches!(composer.popups.active, ActivePopup::File(_))); @@ -6845,7 +6875,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "あ"); + assert_eq!(composer.draft.textarea.text(), "あ"); assert!(!composer.is_in_paste_burst()); } @@ -6868,6 +6898,7 @@ mod tests { ); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6877,9 +6908,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你好\nhi"); + assert_eq!(composer.draft.textarea.text(), "你好\nhi"); } /// Behavior: a paste-like burst may include a full-width/ideographic space (U+3000). It should @@ -6901,6 +6932,7 @@ mod tests { ); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6912,9 +6944,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), "你 好\nhi"); + assert_eq!(composer.draft.textarea.text(), "你 好\nhi"); } /// Behavior: a large multi-line payload containing both non-ASCII and ASCII (e.g. "UTF-8", @@ -6950,6 +6982,7 @@ mod tests { // Force an active burst so the test doesn't depend on timing heuristics. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -6962,9 +6995,9 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let _ = flush_after_paste_burst(&mut composer); - assert_eq!(composer.textarea.text(), LARGE_MIXED_PAYLOAD); + assert_eq!(composer.draft.textarea.text(), LARGE_MIXED_PAYLOAD); } /// Behavior: while a paste-like burst is active, Enter should not submit; it should insert a @@ -7013,11 +7046,11 @@ mod tests { ); } - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected paste burst to flush"); - assert_eq!(composer.textarea.text(), "hi\nthere"); + assert_eq!(composer.draft.textarea.text(), "hi\nthere"); } /// Behavior: even if Enter suppression would normally be active for a burst, Enter should @@ -7039,9 +7072,10 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("/diff"); - composer.textarea.set_cursor("/diff".len()); + composer.draft.textarea.set_text_clearing_elements("/diff"); + composer.draft.textarea.set_cursor("/diff".len()); composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); @@ -7071,17 +7105,18 @@ mod tests { // Force an active burst so we can deterministically buffer characters without relying on // timing. composer + .draft .paste_burst .begin_with_retro_grabbed(String::new(), Instant::now()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); assert!(composer.is_in_paste_burst()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "hi"); - assert_eq!(composer.textarea.cursor(), 1); + assert_eq!(composer.draft.textarea.text(), "hi"); + assert_eq!(composer.draft.textarea.cursor(), 1); assert!(!composer.is_in_paste_burst()); } @@ -7107,14 +7142,14 @@ mod tests { // held char is not dropped. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); composer.set_disable_paste_burst(/*disabled*/ true); - assert_eq!(composer.textarea.text(), "a"); + assert_eq!(composer.draft.textarea.text(), "a"); assert!(!composer.is_in_paste_burst()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "ab"); + assert_eq!(composer.draft.textarea.text(), "ab"); assert!(!composer.is_in_paste_burst()); } @@ -7138,8 +7173,8 @@ mod tests { let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); - assert_eq!(composer.textarea.text(), "hello"); - assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.draft.textarea.text(), "hello"); + assert!(composer.draft.pending_pastes.is_empty()); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7166,7 +7201,7 @@ mod tests { ); // Ensure composer is empty and press Enter. - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7198,10 +7233,10 @@ mod tests { let needs_redraw = composer.handle_paste(large.clone()); assert!(needs_redraw); let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); - assert_eq!(composer.textarea.text(), placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder); - assert_eq!(composer.pending_pastes[0].1, large); + assert_eq!(composer.draft.textarea.text(), placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder); + assert_eq!(composer.draft.pending_pastes[0].1, large); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7209,7 +7244,7 @@ mod tests { InputResult::Submitted { text, .. } => assert_eq!(text, large), _ => panic!("expected Submitted"), } - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -7229,7 +7264,7 @@ mod tests { ); composer.set_steer_enabled(true); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -7257,13 +7292,13 @@ mod tests { ); composer.set_steer_enabled(true); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), input); + assert_eq!(composer.draft.textarea.text(), input); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7299,13 +7334,13 @@ mod tests { ); composer.set_steer_enabled(false); let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - composer.textarea.set_text_clearing_elements(&input); + composer.draft.textarea.set_text_clearing_elements(&input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!(composer.textarea.text(), input); + assert_eq!(composer.draft.textarea.text(), input); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7344,11 +7379,11 @@ mod tests { ); composer.handle_paste(large); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes.len(), 1); // Any edit that removes the placeholder should clear pending_paste composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -7399,7 +7434,10 @@ mod tests { composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4)); composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6)); // Move cursor to end and press backspace - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); } @@ -7458,7 +7496,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); }, ); @@ -7472,7 +7510,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.set_text_content("describe these".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = @@ -7750,7 +7788,10 @@ mod tests { } InputResult::None => panic!("expected Command result for '/init'"), } - assert!(composer.textarea.is_empty(), "composer should be cleared"); + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); } #[test] @@ -7769,22 +7810,22 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_steer_enabled(true); - composer.textarea.insert_str("restore me"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); - composer.textarea.insert_str("hello"); + composer.draft.textarea.insert_str("hello"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::Submitted { .. })); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "restore me"); + assert_eq!(composer.draft.textarea.text(), "restore me"); } #[test] @@ -7802,14 +7843,14 @@ mod tests { "Ask Codex to do anything".to_string(), /*disable_paste_burst*/ false, ); - composer.textarea.insert_str("restore me"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str("restore me"); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); - composer.textarea.insert_str("/diff"); + composer.draft.textarea.insert_str("/diff"); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -7818,11 +7859,11 @@ mod tests { } _ => panic!("expected Command result for '/diff'"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "restore me"); + assert_eq!(composer.draft.textarea.text(), "restore me"); } #[test] @@ -7842,6 +7883,7 @@ mod tests { ); composer.set_task_running(/*running*/ true); composer + .draft .textarea .set_text_clearing_elements("/review these changes"); @@ -7849,7 +7891,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("/review these changes", composer.textarea.text()); + assert_eq!("/review these changes", composer.draft.textarea.text()); let mut found_error = false; while let Ok(event) = rx.try_recv() { @@ -7885,7 +7927,7 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements(input); + composer.draft.textarea.set_text_clearing_elements(input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -7902,7 +7944,7 @@ mod tests { } other => panic!("expected slash-led input to queue, got {other:?}"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert!( rx.try_recv().is_err(), "queueing should not report slash errors" @@ -7933,9 +7975,13 @@ mod tests { /*disable_paste_burst*/ false, ); composer + .draft .textarea .set_text_clearing_elements("explain the change"); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); let mut keymap = RuntimeKeymap::defaults(); keymap.composer.submit = vec![key_hint::ctrl(KeyCode::Char('j'))]; composer.set_keymap_bindings(&keymap); @@ -7944,7 +7990,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("explain the change\n", composer.textarea.text()); + assert_eq!("explain the change\n", composer.draft.textarea.text()); } #[test] @@ -7965,7 +8011,10 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements("queue me"); + composer + .draft + .textarea + .set_text_clearing_elements("queue me"); let mut keymap = RuntimeKeymap::defaults(); keymap.composer.queue = vec![key_hint::ctrl(KeyCode::Char('q'))]; composer.set_keymap_bindings(&keymap); @@ -7974,7 +8023,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(InputResult::None, result); - assert_eq!("queue me", composer.textarea.text()); + assert_eq!("queue me", composer.draft.textarea.text()); } #[test] @@ -8022,6 +8071,7 @@ mod tests { ); composer.set_task_running(/*running*/ true); composer + .draft .textarea .set_text_clearing_elements(" /does-not-exist"); @@ -8054,7 +8104,7 @@ mod tests { /*disable_paste_burst*/ false, ); composer.set_task_running(/*running*/ true); - composer.textarea.set_text_clearing_elements(input); + composer.draft.textarea.set_text_clearing_elements(input); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -8071,7 +8121,7 @@ mod tests { } other => panic!("expected bang shell input to queue, got {other:?}"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert!( rx.try_recv().is_err(), "queueing should not show shell help immediately" @@ -8104,8 +8154,11 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/compact "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/compact "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8131,8 +8184,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "/model "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8157,8 +8213,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "/model "); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -8178,7 +8237,7 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'd', 'i']); let (_res, _redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "/diff "); + assert_eq!(composer.draft.textarea.text(), "/diff "); // Press Enter: should dispatch the command, not submit literal text. let (result, _needs_redraw) = @@ -8199,7 +8258,7 @@ mod tests { } InputResult::None => panic!("expected Command result for '/diff'"), } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -8217,8 +8276,8 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'p', 'l', 'a', 'n', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/plan "); assert_eq!(elements.len(), 1); assert_eq!(elements[0].placeholder(&text), Some("/plan")); @@ -8239,8 +8298,8 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'U', 's', 'e', 'r', 's', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/Users "); assert!(elements.is_empty()); } @@ -8259,16 +8318,16 @@ mod tests { type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "/review "); assert_eq!(elements.len(), 1); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); type_chars_humanlike(&mut composer, &['x']); - let text = composer.textarea.text().to_string(); - let elements = composer.textarea.text_elements(); + let text = composer.draft.textarea.text().to_string(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(text, "x/review "); assert!(elements.is_empty()); } @@ -8298,7 +8357,7 @@ mod tests { result, InputResult::Submitted { ref text, .. } if text == "hi" )); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -8396,9 +8455,12 @@ mod tests { } InputResult::None => panic!("expected Command result for '/mention'"), } - assert!(composer.textarea.is_empty(), "composer should be cleared"); + assert!( + composer.draft.textarea.is_empty(), + "composer should be cleared" + ); composer.insert_str("@"); - assert_eq!(composer.textarea.text(), "@"); + assert_eq!(composer.draft.textarea.text(), "@"); } #[test] @@ -8474,9 +8536,9 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); assert_eq!(text, format!("{placeholder} src/main.rs ")); - let elements = composer.textarea.text_elements(); + let elements = composer.draft.textarea.text_elements(); assert_eq!(elements.len(), 1); assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str())); @@ -8612,8 +8674,8 @@ mod tests { current_pos += content.len(); } ( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), current_pos, ) }) @@ -8623,19 +8685,22 @@ mod tests { let mut deletion_states = vec![]; // First deletion - composer.textarea.set_cursor(states[0].2); + composer.draft.textarea.set_cursor(states[0].2); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), )); // Second deletion - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); deletion_states.push(( - composer.textarea.text().to_string(), - composer.pending_pastes.len(), + composer.draft.textarea.text().to_string(), + composer.draft.pending_pastes.len(), )); // Verify all states @@ -8673,18 +8738,21 @@ mod tests { composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone()); assert_eq!( - composer.textarea.text(), + composer.draft.textarea.text(), format!("{placeholder_base}{placeholder_second}") ); - assert_eq!(composer.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes.len(), 2); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), placeholder_base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, placeholder_base); - assert_eq!(composer.pending_pastes[0].1, paste); + assert_eq!(composer.draft.textarea.text(), placeholder_base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, placeholder_base); + assert_eq!(composer.draft.pending_pastes[0].1, paste); } /// Behavior: large-paste placeholder numbering continues when another placeholder of the @@ -8712,21 +8780,24 @@ mod tests { composer.handle_paste(paste.clone()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), format!("{base}{second}")); + assert_eq!(composer.draft.textarea.text(), format!("{base}{second}")); - composer.textarea.set_cursor(base.len()); + composer.draft.textarea.set_cursor(base.len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), second); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, second); + assert_eq!(composer.draft.textarea.text(), second); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, second); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), format!("{second}{third}")); - assert_eq!(composer.pending_pastes.len(), 2); - assert_eq!(composer.pending_pastes[0].0, second); - assert_eq!(composer.pending_pastes[1].0, third); + assert_eq!(composer.draft.textarea.text(), format!("{second}{third}")); + assert_eq!(composer.draft.pending_pastes.len(), 2); + assert_eq!(composer.draft.pending_pastes[0].0, second); + assert_eq!(composer.draft.pending_pastes[1].0, third); } /// Behavior: if all placeholders of a given length are removed, numbering resets to the @@ -8751,18 +8822,21 @@ mod tests { let base = format!("[Pasted Content {} chars]", paste.chars().count()); composer.handle_paste(paste.clone()); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); - composer.textarea.set_cursor(composer.textarea.text().len()); + composer + .draft + .textarea + .set_cursor(composer.draft.textarea.text().len()); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.textarea.text().is_empty()); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.textarea.text().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); composer.handle_paste(paste); - assert_eq!(composer.textarea.text(), base); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, base); + assert_eq!(composer.draft.textarea.text(), base); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, base); } #[test] @@ -8795,14 +8869,15 @@ mod tests { .map(|pos_from_end| { composer.handle_paste(paste.clone()); composer + .draft .textarea .set_cursor(placeholder.len() - pos_from_end); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let result = ( - composer.textarea.text().contains(&placeholder), - composer.pending_pastes.len(), + composer.draft.textarea.text().contains(&placeholder), + composer.draft.pending_pastes.len(), ); - composer.textarea.set_text_clearing_elements(""); + composer.draft.textarea.set_text_clearing_elements(""); result }) .collect(); @@ -8978,23 +9053,35 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -9023,23 +9110,26 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), "second".len() - 1); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); - assert_eq!(composer.textarea.cursor(), "first".len() - 1); + assert_eq!(composer.draft.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); - assert_eq!(composer.textarea.cursor(), "second".len() - 1); + assert_eq!(composer.draft.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.cursor(), "second".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert!(composer.draft.textarea.is_empty()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -9069,13 +9159,13 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.text(), "first"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); } #[test] @@ -9089,15 +9179,18 @@ mod tests { "Ask Codex to do anything".to_string(), /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("one\ntwo"); - composer.textarea.set_cursor(/*pos*/ 0); + composer + .draft + .textarea + .set_text_clearing_elements("one\ntwo"); + composer.draft.textarea.set_cursor(/*pos*/ 0); composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), "one\n".len()); + assert_eq!(composer.draft.textarea.cursor(), "one\n".len()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), 0); + assert_eq!(composer.draft.textarea.cursor(), 0); } #[test] @@ -9125,11 +9218,11 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "second"); + assert_eq!(composer.draft.textarea.text(), "second"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert_eq!(composer.current_text(), ""); } @@ -9148,18 +9241,18 @@ mod tests { composer.set_vim_enabled(/*enabled*/ true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); - assert!(composer.textarea.is_vim_operator_pending()); + assert!(composer.draft.textarea.is_vim_operator_pending()); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.textarea.text(), "hello"); + assert_eq!(composer.draft.textarea.text(), "hello"); assert_eq!( composer.vim_mode_indicator_span(), Some("Vim: Normal".magenta()) ); - assert!(!composer.textarea.is_vim_operator_pending()); + assert!(!composer.draft.textarea.is_vim_operator_pending()); } #[test] @@ -9187,10 +9280,10 @@ mod tests { composer.set_keymap_bindings(&keymap); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "first"); + assert_eq!(composer.draft.textarea.text(), "first"); } #[test] @@ -9223,7 +9316,7 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(composer.current_text(), "!git"); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(composer.current_text(), "first"); @@ -9256,12 +9349,12 @@ mod tests { let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert_eq!(composer.current_text(), "!git"); - assert_eq!(composer.textarea.cursor(), "git".len() - 1); + assert_eq!(composer.draft.textarea.cursor(), "git".len() - 1); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); assert_eq!(composer.current_text(), "first"); - assert_eq!(composer.textarea.cursor(), "first".len() - 1); + assert_eq!(composer.draft.textarea.cursor(), "first".len() - 1); } #[test] @@ -9430,11 +9523,15 @@ mod tests { /*disable_paste_burst*/ false, ); - composer.textarea.set_text_clearing_elements("/unknown "); - composer.textarea.set_cursor("/unknown ".len()); + composer + .draft + .textarea + .set_text_clearing_elements("/unknown "); + composer.draft.textarea.set_cursor("/unknown ".len()); let large_content = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); composer.handle_paste(large_content.clone()); let placeholder = composer + .draft .pending_pastes .first() .expect("expected pending paste") @@ -9444,11 +9541,14 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(result, InputResult::None)); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.textarea.text(), format!("/unknown {placeholder}")); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!( + composer.draft.textarea.text(), + format!("/unknown {placeholder}") + ); - composer.textarea.set_cursor(/*pos*/ 0); - composer.textarea.insert_str(" "); + composer.draft.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.insert_str(" "); let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { @@ -9461,7 +9561,7 @@ mod tests { } _ => panic!("expected Submitted"), } - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -9519,7 +9619,7 @@ mod tests { composer.handle_paste(" ".into()); composer.attach_image(path); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); assert!(text.contains("[Image #1]")); assert!(text.contains("[Image #2]")); assert_eq!( @@ -9549,21 +9649,22 @@ mod tests { // Case 1: backspace at end composer + .draft .textarea .move_cursor_to_end_of_line(/*move_down_at_eol*/ false); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder)); + assert!(!composer.draft.textarea.text().contains(&placeholder)); assert!(composer.attachments.local_images.is_empty()); // Re-add and ensure backspace at element start does not delete the placeholder. composer.attach_image(path); let placeholder2 = composer.attachments.local_images[0].placeholder.clone(); // Move cursor to roughly middle of placeholder - if let Some(start_pos) = composer.textarea.text().find(&placeholder2) { + if let Some(start_pos) = composer.draft.textarea.text().find(&placeholder2) { let mid_pos = start_pos + (placeholder2.len() / 2); - composer.textarea.set_cursor(mid_pos); + composer.draft.textarea.set_cursor(mid_pos); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(composer.textarea.text().contains(&placeholder2)); + assert!(composer.draft.textarea.text().contains(&placeholder2)); assert_eq!(composer.attachments.local_images.len(), 1); } else { panic!("Placeholder not found in textarea"); @@ -9590,14 +9691,14 @@ mod tests { let path = PathBuf::from("/tmp/image_multibyte.png"); composer.attach_image(path); // Add multibyte text after the placeholder - composer.textarea.insert_str("日本語"); + composer.draft.textarea.insert_str("日本語"); // Cursor is at end; pressing backspace should delete the last character // without panicking and leave the placeholder intact. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!(composer.attachments.local_images.len(), 1); - assert!(composer.textarea.text().starts_with("[Image #1]")); + assert!(composer.draft.textarea.text().starts_with("[Image #1]")); } #[test] @@ -9622,15 +9723,15 @@ mod tests { let placeholder1 = composer.attachments.local_images[0].placeholder.clone(); let placeholder2 = composer.attachments.local_images[1].placeholder.clone(); - let text = composer.textarea.text().to_string(); + let text = composer.draft.textarea.text().to_string(); let start1 = text.find(&placeholder1).expect("first placeholder present"); let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); + composer.draft.textarea.set_cursor(end1); // Backspace should delete the first placeholder and its mapping. composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - let new_text = composer.textarea.text().to_string(); + let new_text = composer.draft.textarea.text().to_string(); assert_eq!( 1, new_text.matches(&placeholder1).count(), @@ -9701,12 +9802,12 @@ mod tests { composer.set_text_content(text, text_elements, vec![path1, path2.clone()]); let end1 = start1 + placeholder1.len(); - composer.textarea.set_cursor(end1); + composer.draft.textarea.set_cursor(end1); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); assert_eq!( - composer.textarea.text(), + composer.draft.textarea.text(), format!("Test {placeholder1} test ") ); assert_eq!( @@ -9741,11 +9842,11 @@ mod tests { // Insert two adjacent atomic elements. composer.attach_image(path1); composer.attach_image(path2.clone()); - assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); + assert_eq!(composer.draft.textarea.text(), "[Image #1][Image #2]"); assert_eq!(composer.attachments.local_images.len(), 2); // Delete the first element using normal textarea editing (forward Delete at cursor start). - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); // Remaining image should be renumbered and the textarea element updated. @@ -9755,7 +9856,7 @@ mod tests { composer.attachments.local_images[0].placeholder, "[Image #1]" ); - assert_eq!(composer.textarea.text(), "[Image #1]"); + assert_eq!(composer.draft.textarea.text(), "[Image #1]"); } #[test] @@ -9778,7 +9879,7 @@ mod tests { let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); assert!(needs_redraw); - assert!(composer.textarea.text().starts_with("[Image #1] ")); + assert!(composer.draft.textarea.text().starts_with("[Image #1] ")); let imgs = composer.take_recent_submission_images(); assert_eq!(imgs, vec![tmp_path]); @@ -9801,6 +9902,7 @@ mod tests { ); composer + .draft .textarea .set_text_clearing_elements("/Users/example/project/src/main.rs"); @@ -9812,7 +9914,7 @@ mod tests { } else { panic!("expected Submitted"); } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); match rx.try_recv() { Ok(event) => panic!("unexpected event: {event:?}"), Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} @@ -9837,6 +9939,7 @@ mod tests { ); composer + .draft .textarea .set_text_clearing_elements(" /this-looks-like-a-command"); @@ -9848,7 +9951,7 @@ mod tests { } else { panic!("expected Submitted"); } - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); match rx.try_recv() { Ok(event) => panic!("unexpected event: {event:?}"), Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} @@ -9876,12 +9979,12 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); let flushed = composer.flush_paste_burst_if_due(); assert!(flushed, "expected pending first char to flush"); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); assert!(!composer.is_in_paste_burst()); } @@ -9916,22 +10019,22 @@ mod tests { "expected active paste burst during fast typing" ); assert!( - composer.textarea.text().is_empty(), + composer.draft.textarea.text().is_empty(), "text should not appear during burst" ); now += step; } assert!( - composer.textarea.text().is_empty(), + composer.draft.textarea.text().is_empty(), "text should remain empty until flush" ); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected buffered text to flush after stop"); - assert_eq!(composer.textarea.text(), "a".repeat(count)); + assert_eq!(composer.draft.textarea.text(), "a".repeat(count)); assert!( - composer.pending_pastes.is_empty(), + composer.draft.pending_pastes.is_empty(), "no placeholder for small burst" ); } @@ -9962,17 +10065,17 @@ mod tests { } // Nothing should appear until we stop and flush - assert!(composer.textarea.text().is_empty()); + assert!(composer.draft.textarea.text().is_empty()); let flush_time = now + PasteBurst::recommended_active_flush_delay() + step; let flushed = composer.handle_paste_burst_flush(flush_time); assert!(flushed, "expected flush after stopping fast input"); let expected_placeholder = format!("[Pasted Content {count} chars]"); - assert_eq!(composer.textarea.text(), expected_placeholder); - assert_eq!(composer.pending_pastes.len(), 1); - assert_eq!(composer.pending_pastes[0].0, expected_placeholder); - assert_eq!(composer.pending_pastes[0].1.len(), count); - assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); + assert_eq!(composer.draft.textarea.text(), expected_placeholder); + assert_eq!(composer.draft.pending_pastes.len(), 1); + assert_eq!(composer.draft.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.draft.pending_pastes[0].1.len(), count); + assert!(composer.draft.pending_pastes[0].1.chars().all(|c| c == 'x')); } /// Behavior: human-like typing (with delays between chars) should not be classified as a paste @@ -9993,8 +10096,8 @@ mod tests { let chars: Vec = vec!['z'; count]; type_chars_humanlike(&mut composer, &chars); - assert_eq!(composer.textarea.text(), "z".repeat(count)); - assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.draft.textarea.text(), "z".repeat(count)); + assert!(composer.draft.pending_pastes.is_empty()); } #[test] @@ -10172,12 +10275,13 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), }); composer + .draft .pending_pastes .push(("[Pasted]".to_string(), "data".to_string())); @@ -10187,13 +10291,16 @@ mod tests { composer.current_text(), format!("Edited {placeholder} text") ); - assert!(composer.pending_pastes.is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); assert_eq!(composer.attachments.local_images.len(), 1); assert_eq!( composer.attachments.local_images[0].placeholder, placeholder ); - assert_eq!(composer.textarea.cursor(), composer.current_text().len()); + assert_eq!( + composer.draft.textarea.cursor(), + composer.current_text().len() + ); } #[test] @@ -10211,8 +10318,8 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -10231,8 +10338,8 @@ mod tests { composer.apply_external_edit("git status".to_string()); - assert!(!composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(!composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "git status"); } @@ -10251,8 +10358,8 @@ mod tests { composer.apply_external_edit("!git status".to_string()); - assert!(composer.is_bash_mode); - assert_eq!(composer.textarea.text(), "git status"); + assert!(composer.draft.is_bash_mode); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.current_text(), "!git status"); } @@ -10269,7 +10376,7 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), @@ -10309,7 +10416,10 @@ mod tests { placeholder1 ); assert_eq!(composer.local_image_paths(), vec![second_path]); - assert_eq!(composer.textarea.element_payloads(), vec![placeholder1]); + assert_eq!( + composer.draft.textarea.element_payloads(), + vec![placeholder1] + ); } #[test] @@ -10325,8 +10435,9 @@ mod tests { ); let placeholder = "[Pasted Content 5 chars]".to_string(); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer + .draft .pending_pastes .push((placeholder.clone(), "hello".to_string())); @@ -10377,7 +10488,7 @@ mod tests { ); let placeholder = local_image_label_text(/*label_number*/ 1); - composer.textarea.insert_element(&placeholder); + composer.draft.textarea.insert_element(&placeholder); composer.attachments.local_images.push(AttachedImage { placeholder: placeholder.clone(), path: PathBuf::from("img.png"), @@ -10512,7 +10623,7 @@ mod tests { "https://example.com/two.png".to_string(), ]); composer.attach_image(PathBuf::from("/tmp/local.png")); - composer.textarea.set_cursor(/*pos*/ 0); + composer.draft.textarea.set_cursor(/*pos*/ 0); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs new file mode 100644 index 0000000000..f739f10ec9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -0,0 +1,45 @@ +//! Editable composer draft state kept separate from composer control flow. + +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::bottom_pane::MentionBinding; +use crate::bottom_pane::paste_burst::PasteBurst; +use crate::bottom_pane::textarea::TextArea; +use crate::bottom_pane::textarea::TextAreaState; + +pub(super) struct DraftState { + pub(super) textarea: TextArea, + pub(super) textarea_state: RefCell, + pub(super) is_bash_mode: bool, + pub(super) pending_pastes: Vec<(String, String)>, + pub(super) input_enabled: bool, + pub(super) input_disabled_placeholder: Option, + pub(super) paste_burst: PasteBurst, + pub(super) disable_paste_burst: bool, + pub(super) mention_bindings: HashMap, + pub(super) recent_submission_mention_bindings: Vec, +} + +impl DraftState { + pub(super) fn new() -> Self { + Self { + textarea: TextArea::new(), + textarea_state: RefCell::new(TextAreaState::default()), + is_bash_mode: false, + pending_pastes: Vec::new(), + input_enabled: true, + input_disabled_placeholder: None, + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + mention_bindings: HashMap::new(), + recent_submission_mention_bindings: Vec::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ComposerMentionBinding { + pub(super) mention: String, + pub(super) path: String, +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs new file mode 100644 index 0000000000..113385ff72 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -0,0 +1,73 @@ +//! Footer and status-row presentation state for the chat composer. + +use std::time::Instant; + +use ratatui::text::Line; + +use crate::bottom_pane::footer::CollaborationModeIndicator; +use crate::bottom_pane::footer::FooterMode; +use crate::bottom_pane::footer::GoalStatusIndicator; +use crate::key_hint::KeyBinding; +#[cfg(test)] +use std::time::Duration; + +pub(super) struct FooterState { + pub(super) quit_shortcut_expires_at: Option, + pub(super) quit_shortcut_key: KeyBinding, + pub(super) esc_backtrack_hint: bool, + pub(super) use_shift_enter_hint: bool, + pub(super) mode: FooterMode, + pub(super) hint_override: Option>, + pub(super) plan_mode_nudge_visible: bool, + pub(super) flash: Option, + pub(super) context_window_percent: Option, + pub(super) context_window_used_tokens: Option, + pub(super) collaboration_mode_indicator: Option, + pub(super) goal_status_indicator: Option, + pub(super) ide_context_active: bool, + pub(super) status_line_value: Option>, + pub(super) status_line_hyperlink_url: Option, + pub(super) status_line_enabled: bool, + pub(super) side_conversation_context_label: Option, + pub(super) active_agent_label: Option, + pub(super) external_editor_key: Option, + pub(super) show_transcript_key: Option, + pub(super) insert_newline_key: Option, + pub(super) queue_key: Option, + pub(super) toggle_shortcuts_key: Option, + pub(super) history_search_key: Option, + pub(super) reasoning_down_key: Option, + pub(super) reasoning_up_key: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct FooterFlash { + pub(super) line: Line<'static>, + pub(super) expires_at: Instant, +} + +impl FooterState { + pub(super) fn flash_visible(&self) -> bool { + self.flash + .as_ref() + .is_some_and(|flash| Instant::now() < flash.expires_at) + } + + #[cfg(test)] + pub(super) fn show_flash(&mut self, line: Line<'static>, duration: Duration) { + let expires_at = Instant::now() + .checked_add(duration) + .unwrap_or_else(Instant::now); + self.flash = Some(FooterFlash { line, expires_at }); + } + + #[cfg(test)] + pub(super) fn status_line_text(&self) -> Option { + self.status_line_value.as_ref().map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index d5c1ade2a5..3c2663a659 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -102,10 +102,10 @@ impl ChatComposer { /// from replacing an empty composer with the latest prompt before the user has searched for /// anything. pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { - if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + if let Some(pasted) = self.draft.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } - self.paste_burst.clear_window_after_non_char(); + self.draft.paste_burst.clear_window_after_non_char(); if self.popups.current_file_query.is_some() { self.app_event_tx @@ -185,7 +185,7 @@ impl ChatComposer { { self.history_search = None; self.history.reset_search(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.move_cursor_to_end(); } (InputResult::None, true) @@ -296,7 +296,7 @@ impl ChatComposer { return false; }; self.history.reset_navigation(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.footer.mode = reset_mode_after_activity(self.footer.mode); self.restore_draft(search.original_draft); true } @@ -385,7 +385,7 @@ impl ChatComposer { if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { return Vec::new(); } - Self::case_insensitive_match_ranges(self.textarea.text(), &search.query) + Self::case_insensitive_match_ranges(self.draft.textarea.text(), &search.query) } fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { @@ -520,7 +520,7 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } @@ -558,18 +558,21 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.text(), "draft"); for ch in ['g', 'i', 't'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.draft.textarea.text(), "git status"); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "git status"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!( + composer.draft.textarea.cursor(), + composer.draft.textarea.text().len() + ); } #[test] @@ -593,8 +596,8 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "git status"); - assert_eq!(composer.textarea.cursor(), "git status".len() - 1); + assert_eq!(composer.draft.textarea.text(), "git status"); + assert_eq!(composer.draft.textarea.cursor(), "git status".len() - 1); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } @@ -618,13 +621,19 @@ mod tests { for ch in ['b', 'u', 'g'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); for _ in 0..3 { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); assert!( composer .history_search @@ -635,7 +644,10 @@ mod tests { for _ in 0..3 { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert_eq!( + composer.draft.textarea.text(), + "Find and fix a bug in @filename" + ); assert!( composer .history_search @@ -776,17 +788,17 @@ mod tests { .history .record_local_submission(HistoryEntry::new("remembered command".to_string())); composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 2); + composer.draft.textarea.set_cursor(/*pos*/ 2); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.text(), "draft"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "remembered command"); + assert_eq!(composer.draft.textarea.text(), "remembered command"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - assert_eq!(composer.textarea.cursor(), 2); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.cursor(), 2); } #[test] @@ -805,13 +817,13 @@ mod tests { .history .record_local_submission(HistoryEntry::new("remembered command".to_string())); composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 2); + composer.draft.textarea.set_cursor(/*pos*/ 2); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "remembered command"); + assert_eq!(composer.draft.textarea.text(), "remembered command"); composer } @@ -824,8 +836,8 @@ mod tests { let _ = composer.handle_key_event(cancel_key); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - assert_eq!(composer.textarea.cursor(), 2); + assert_eq!(composer.draft.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.cursor(), 2); } } @@ -843,18 +855,18 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); assert!(!composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "h"); + assert_eq!(composer.draft.textarea.text(), "h"); } #[test] @@ -881,18 +893,18 @@ mod tests { now += Duration::from_millis(1); } assert!(composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), ""); + assert_eq!(composer.draft.textarea.text(), ""); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); assert!(composer.history_search_active()); assert!(!composer.is_in_paste_burst()); - assert_eq!(composer.textarea.text(), "paste"); + assert_eq!(composer.draft.textarea.text(), "paste"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "paste"); + assert_eq!(composer.draft.textarea.text(), "paste"); } #[test] @@ -918,14 +930,14 @@ mod tests { for ch in ['m', 'a', 't', 'c', 'h'] { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); } - assert_eq!(composer.textarea.text(), "oldest matching entry"); + assert_eq!(composer.draft.textarea.text(), "oldest matching entry"); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!composer.history_search_active()); - assert!(composer.textarea.is_empty()); + assert!(composer.draft.textarea.is_empty()); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "newest entry"); + assert_eq!(composer.draft.textarea.text(), "newest entry"); } #[test] @@ -950,7 +962,7 @@ mod tests { } assert!(composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.draft.textarea.text(), "draft"); assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6d99c0bce6..165169c456 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -808,18 +808,20 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { + self.composer.draft_snapshot() + } + + #[cfg(test)] pub(crate) fn composer_text_elements(&self) -> Vec { self.composer.text_elements() } + #[cfg(test)] pub(crate) fn composer_local_images(&self) -> Vec { self.composer.local_images() } - pub(crate) fn composer_mention_bindings(&self) -> Vec { - self.composer.mention_bindings() - } - #[cfg(test)] pub(crate) fn composer_local_image_paths(&self) -> Vec { self.composer.local_image_paths() @@ -865,6 +867,7 @@ impl BottomPane { self.request_redraw(); } + #[cfg(test)] pub(crate) fn remote_image_urls(&self) -> Vec { self.composer.remote_image_urls() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 57adb065b9..a37371bf73 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -61,6 +61,7 @@ use crate::legacy_core::DEFAULT_AGENTS_MD_FILENAME; use crate::legacy_core::config::Config; use crate::legacy_core::config::Constrained; use crate::legacy_core::config::ConstraintResult; +use crate::legacy_core::config::PermissionProfileSnapshot; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::mention_codec::LinkedMention; @@ -165,6 +166,7 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::resume_command; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -441,6 +443,7 @@ use crate::workspace_command::WorkspaceCommandRunner; use chrono::Local; use codex_app_server_protocol::AskForApproval; use codex_file_search::FileMatch; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelPreset; @@ -1433,8 +1436,8 @@ impl ChatWidget { } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { - let resume_cmd = crate::legacy_core::util::resume_command(Some(name), thread_id) - .unwrap_or_else(|| format!("codex resume {name}")); + let resume_cmd = + resume_command(Some(name), thread_id).unwrap_or_else(|| format!("codex resume {name}")); let name = name.to_string(); let line = vec![ "• ".into(), diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index ca15f1cdf7..b7e8ee72e0 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -152,12 +152,13 @@ impl ChatWidget { return None; } + let composer = self.bottom_pane.composer_draft_snapshot(); let existing_message = UserMessage { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), + text: composer.text, + text_elements: composer.text_elements, + local_images: composer.local_images, + remote_image_urls: composer.remote_image_urls, + mention_bindings: composer.mention_bindings, }; let rejected_messages = self @@ -236,13 +237,14 @@ impl ChatWidget { } pub(crate) fn capture_thread_input_state(&self) -> Option { + let draft = self.bottom_pane.composer_draft_snapshot(); let composer = ThreadComposerState { - text: self.bottom_pane.composer_text(), - text_elements: self.bottom_pane.composer_text_elements(), - local_images: self.bottom_pane.composer_local_images(), - remote_image_urls: self.bottom_pane.remote_image_urls(), - mention_bindings: self.bottom_pane.composer_mention_bindings(), - pending_pastes: self.bottom_pane.composer_pending_pastes(), + text: draft.text, + text_elements: draft.text_elements, + local_images: draft.local_images, + remote_image_urls: draft.remote_image_urls, + mention_bindings: draft.mention_bindings, + pending_pastes: draft.pending_pastes, }; Some(ThreadInputState { composer: composer.has_content().then_some(composer), diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 9043cb3d9d..2fd376f139 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -164,12 +164,14 @@ impl ChatWidget { for image_url in &remote_image_urls { items.push(UserInput::Image { url: image_url.clone(), + detail: None, }); } for image in &local_images { items.push(UserInput::LocalImage { path: image.path.clone(), + detail: None, }); } @@ -334,12 +336,12 @@ impl ChatWidget { None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, }; - let permission_profile = self.config.permissions.permission_profile(); + let active_permission_profile = self.config.permissions.active_permission_profile(); let op = AppCommand::user_turn( items, self.config.cwd.to_path_buf(), AskForApproval::from(self.config.permissions.approval_policy.value()), - permission_profile, + active_permission_profile, effective_mode.model().to_string(), effective_mode.reasoning_effort(), /*summary*/ None, diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index cf499a455b..05d3def329 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -11,6 +11,9 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use super::ChatWidget; +const MCP_STARTUP_SINGLE_HEADER_PREFIX: &str = "Booting MCP server:"; +const MCP_STARTUP_MULTI_HEADER_PREFIX: &str = "Starting MCP servers"; + #[derive(Debug, Clone)] pub(crate) enum McpStartupStatus { Starting, @@ -153,11 +156,11 @@ impl ChatWidget { } let header = if total > 1 { format!( - "Starting MCP servers ({completed}/{total}): {}", + "{MCP_STARTUP_MULTI_HEADER_PREFIX} ({completed}/{total}): {}", to_show.join(", ") ) } else { - format!("Booting MCP server: {first}") + format!("{MCP_STARTUP_SINGLE_HEADER_PREFIX} {first}") }; self.set_status_header(header); } @@ -187,12 +190,16 @@ impl ChatWidget { self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); } + let mcp_startup_owned_status = self.status_header_is_mcp_startup_owned(); self.mcp_startup_status = None; self.mcp_startup_ignore_updates_until_next_start = true; self.mcp_startup_allow_terminal_only_next_round = false; self.mcp_startup_pending_next_round.clear(); self.mcp_startup_pending_next_round_saw_starting = false; self.update_task_running_state(); + if self.bottom_pane.is_task_running() && mcp_startup_owned_status { + self.restore_reasoning_status_header(); + } self.maybe_send_next_queued_input(); self.request_redraw(); } @@ -234,6 +241,18 @@ impl ChatWidget { self.finish_mcp_startup(failed, cancelled); } + pub(super) fn status_header_is_mcp_startup_owned(&self) -> bool { + self.status_state + .current_status + .header + .starts_with(MCP_STARTUP_SINGLE_HEADER_PREFIX) + || self + .status_state + .current_status + .header + .starts_with(MCP_STARTUP_MULTI_HEADER_PREFIX) + } + pub(super) fn on_mcp_server_status_updated( &mut self, notification: McpServerStatusUpdatedNotification, diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index dc428b4092..a82f3a6cdb 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -17,7 +17,7 @@ impl ChatWidget { let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); - let current_permission_profile = self.config.permissions.permission_profile(); + let current_permission_profile = self.config.permissions.permission_profile().clone(); let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); let current_review_policy = self.config.approvals_reviewer; let mut items: Vec = Vec::new(); @@ -124,7 +124,7 @@ impl ChatWidget { } else { Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) @@ -134,7 +134,7 @@ impl ChatWidget { { Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) @@ -142,7 +142,7 @@ impl ChatWidget { } else { Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, ) @@ -180,7 +180,7 @@ impl ChatWidget { ), actions: Self::approval_preset_actions( preset_approval, - preset.permission_profile.clone(), + preset.active_permission_profile.clone(), "Auto-review".to_string(), ApprovalsReviewer::AutoReview, ), @@ -308,17 +308,16 @@ impl ChatWidget { pub(super) fn approval_preset_actions( approval: AskForApproval, - permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, label: String, approvals_reviewer: ApprovalsReviewer, ) -> Vec { vec![Box::new(move |tx| { - let permission_profile_clone = permission_profile.clone(); tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( /*cwd*/ None, Some(approval), Some(approvals_reviewer), - Some(permission_profile_clone.clone()), + Some(active_permission_profile.clone()), /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, @@ -328,7 +327,9 @@ impl ChatWidget { /*personality*/ None, ))); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); + tx.send(AppEvent::UpdateActivePermissionProfile( + active_permission_profile.clone(), + )); tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_info_event( @@ -385,7 +386,6 @@ impl ChatWidget { ) { let selected_name = preset.label.to_string(); let approval = AskForApproval::from(preset.approval); - let permission_profile = preset.permission_profile; let mut header_children: Vec> = Vec::new(); let title_line = Line::from("Enable full access?").bold(); let info_line = Line::from(vec![ @@ -402,7 +402,7 @@ impl ChatWidget { let mut accept_actions = Self::approval_preset_actions( approval, - permission_profile.clone(), + preset.active_permission_profile.clone(), selected_name.clone(), ApprovalsReviewer::User, ); @@ -412,7 +412,7 @@ impl ChatWidget { let mut accept_and_remember_actions = Self::approval_preset_actions( approval, - permission_profile, + preset.active_permission_profile, selected_name, ApprovalsReviewer::User, ); diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 6b5414536d..7eaca908c6 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -285,7 +285,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), Some(Some(default_effort)), diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index 1cdb52f224..a60af0a69e 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -107,7 +107,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 892e43f3ec..2b8beefdf8 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -33,6 +33,11 @@ impl ChatWidget { self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); + let runtime_workspace_roots = session.runtime_workspace_roots.clone(); + self.config.workspace_roots = runtime_workspace_roots.clone(); + self.config + .permissions + .set_workspace_roots(runtime_workspace_roots); self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config @@ -44,19 +49,26 @@ impl ChatWidget { self.config.permissions.approval_policy = Constrained::allow_only(session.approval_policy.to_core()); } + let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot( + session.permission_profile.clone(), + session.active_permission_profile.clone(), + ); let permission_sync = self .config .permissions - .set_permission_profile_with_active_profile( - session.permission_profile.clone(), - session.active_permission_profile.clone(), - ); + .set_permission_profile_from_session_snapshot(permission_snapshot.clone()); if let Err(err) = permission_sync { tracing::warn!(%err, "failed to sync permissions from SessionConfigured"); - self.config.permissions.permission_profile = - Constrained::allow_only(session.permission_profile.clone()); - self.config.permissions.active_permission_profile = - session.active_permission_profile.clone(); + if let Err(replace_err) = self + .config + .permissions + .replace_permission_profile_from_session_snapshot(permission_snapshot) + { + tracing::error!( + %replace_err, + "failed to replace permissions from SessionConfigured after constraint fallback" + ); + } } self.config.approvals_reviewer = session.approvals_reviewer; self.status_line_project_root_name_cache = None; diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 419070ef9e..91abc728db 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -17,13 +17,14 @@ impl ChatWidget { } } - /// Set the permission profile in the widget's config copy. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_permission_profile( + pub(crate) fn set_permission_profile_from_session_snapshot( &mut self, - profile: PermissionProfile, + snapshot: PermissionProfileSnapshot, ) -> ConstraintResult<()> { - self.config.permissions.set_permission_profile(profile)?; + self.config + .permissions + .set_permission_profile_from_session_snapshot(snapshot)?; self.refresh_status_surfaces(); Ok(()) } diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index cdd38ad3a8..2dcfefb73a 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -7,7 +7,7 @@ use super::*; impl ChatWidget { pub(super) fn open_theme_picker(&mut self) { - let codex_home = crate::legacy_core::config::find_codex_home().ok(); + let codex_home = codex_utils_home_dir::find_codex_home().ok(); let terminal_width = self .last_rendered_width .get() @@ -53,7 +53,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, - /*permission_profile*/ None, + /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, /*effort*/ None, diff --git a/codex-rs/tui/src/chatwidget/side.rs b/codex-rs/tui/src/chatwidget/side.rs index 0d6c2f5a8e..e379fe3606 100644 --- a/codex-rs/tui/src/chatwidget/side.rs +++ b/codex-rs/tui/src/chatwidget/side.rs @@ -25,6 +25,10 @@ impl ChatWidget { self.bottom_pane.set_side_conversation_active(active); } + pub(crate) fn side_conversation_active(&self) -> bool { + self.active_side_conversation + } + pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) { self.bottom_pane.set_side_conversation_context_label(label); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 41989c5174..9d8d49a77c 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -32,7 +32,8 @@ struct PreparedSlashCommandArgs { const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str = "'/side' is unavailable while code review is running."; -const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first."; +const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = + "Press Ctrl+C to return to the main thread first."; const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap index 168dca8091..5757bf8dac 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.5 Side from main thread · Esc to return " +" gpt-5.5 Side from main thread · Ctrl+C to return " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap index 1f14076573..a1b8af6052 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.5 default · … Side from main thread · main needs input · Esc to return " +" gpt-5.5 default… Side from main thread · main needs input · Ctrl+C to return " diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 12ed07cf98..e34fe4688a 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -901,8 +901,10 @@ fn permissions_display(config: &Config) -> String { return active_permission_profile.id.clone(); } - let permission_profile = config.permissions.permission_profile(); - let summary = summarize_permission_profile(&permission_profile, config.cwd.as_path()); + let permission_profile = config.permissions.effective_permission_profile(); + let workspace_roots = config.effective_workspace_roots(); + let summary = + summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots.as_slice()); if let Some(details) = summary.strip_prefix("read-only") && !details.contains("(network access enabled)") { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f86b896e5b..e0dda9eb5c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -149,6 +149,8 @@ pub(super) use codex_protocol::config_types::CollaborationMode; pub(super) use codex_protocol::config_types::ModeKind; pub(super) use codex_protocol::config_types::Personality; pub(super) use codex_protocol::config_types::ServiceTier; +pub(super) use codex_protocol::models::ActivePermissionProfile; +pub(super) use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; pub(super) use codex_protocol::models::FileSystemPermissions; pub(super) use codex_protocol::models::MessagePhase; pub(super) use codex_protocol::models::NetworkPermissions; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 95e8317e9c..15f5431dad 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1,11 +1,10 @@ use super::*; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; use std::collections::VecDeque; @@ -28,6 +27,7 @@ async fn submission_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -57,7 +57,8 @@ async fn submission_preserves_text_elements_and_local_images() { assert_eq!( items[0], UserInput::LocalImage { - path: local_images[0].clone() + path: local_images[0].clone(), + detail: None, } ); assert_eq!( @@ -92,14 +93,14 @@ async fn submission_preserves_text_elements_and_local_images() { } #[tokio::test] -async fn submission_includes_configured_permission_profile() { +async fn submission_includes_configured_active_permission_profile() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let expected_permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -116,8 +117,8 @@ async fn submission_includes_configured_permission_profile() { ], glob_scan_max_depth: None, }, - } - .into(); + }; + let expected_active_permission_profile = ActivePermissionProfile::new("custom"); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, @@ -128,9 +129,10 @@ async fn submission_includes_configured_permission_profile() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: expected_permission_profile.clone(), - active_permission_profile: None, + permission_profile: expected_permission_profile, + active_permission_profile: Some(expected_active_permission_profile.clone()), cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -147,26 +149,29 @@ async fn submission_includes_configured_permission_profile() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let permission_profile = match next_submit_op(&mut op_rx) { + let active_permission_profile = match next_submit_op(&mut op_rx) { Op::UserTurn { - permission_profile, .. - } => permission_profile, + active_permission_profile, + .. + } => active_permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; - assert_eq!(permission_profile, expected_permission_profile); + assert_eq!( + active_permission_profile, + Some(expected_active_permission_profile) + ); } #[tokio::test] -async fn submission_keeps_profile_when_legacy_projection_is_external() { +async fn submission_omits_active_permission_profile_for_legacy_snapshot() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); - let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - } - .into(); + let expected_permission_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Unrestricted, + }; let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, @@ -177,9 +182,10 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, - permission_profile: expected_permission_profile.clone(), + permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -193,13 +199,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { .set_composer_text("submit".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let permission_profile = match next_submit_op(&mut op_rx) { + let active_permission_profile = match next_submit_op(&mut op_rx) { Op::UserTurn { - permission_profile, .. - } => permission_profile, + active_permission_profile, + .. + } => active_permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; - assert_eq!(permission_profile, expected_permission_profile); + assert_eq!(active_permission_profile, None); } #[tokio::test] @@ -221,6 +228,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -255,12 +263,14 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi items[0], UserInput::Image { url: remote_url.clone(), + detail: None, } ); assert_eq!( items[1], UserInput::LocalImage { path: local_images[0].clone(), + detail: None, } ); assert_eq!( @@ -314,6 +324,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -337,6 +348,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { items, vec![UserInput::Image { url: remote_url.clone(), + detail: None, }] ); assert_eq!(summary, None); @@ -377,6 +389,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -415,6 +428,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -453,6 +467,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -494,6 +509,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -1148,9 +1164,11 @@ fn user_message_display_from_inputs_matches_flattened_user_message_shape() { }, UserInput::Image { url: "https://example.com/remote.png".to_string(), + detail: None, }, UserInput::LocalImage { path: local_image.clone(), + detail: None, }, UserInput::Skill { name: "demo".to_string(), @@ -1223,6 +1241,7 @@ async fn committed_user_message_with_hidden_prompt_context_renders_local_images( }, UserInput::LocalImage { path: local_image.clone(), + detail: None, }, ], ); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 2655c8cf95..d54783615c 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -957,6 +957,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index d77823c3cd..36b3e0189e 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -1,13 +1,12 @@ use super::*; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::NetworkAccess; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::SandboxPolicy; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; #[tokio::test] @@ -29,6 +28,7 @@ async fn resumed_initial_messages_render_history() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -99,6 +99,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -117,6 +118,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { }, AppServerUserInput::LocalImage { path: local_images[0].clone(), + detail: None, }, ], ReplayKind::ResumeInitialMessages, @@ -167,6 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -185,6 +188,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { }, AppServerUserInput::Image { url: remote_image_urls[0].clone(), + detail: None, }, ], ReplayKind::ResumeInitialMessages, @@ -227,9 +231,9 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { chat.config.cwd = test_path_buf("/home/user/main").abs(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); - let expected_app_server_permission_profile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let expected_app_server_permission_profile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -247,8 +251,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { glob_scan_max_depth: None, }, }; - let expected_permission_profile: PermissionProfile = - expected_app_server_permission_profile.clone().into(); + let expected_permission_profile = expected_app_server_permission_profile.clone(); let expected_core_sandbox = expected_permission_profile .to_legacy_sandbox_policy(expected_cwd.as_path()) .expect("permission profile should project to legacy sandbox policy"); @@ -266,6 +269,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: expected_cwd.clone(), + runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -282,18 +286,78 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + chat.config_ref().permissions.effective_permission_profile(), expected_app_server_permission_profile ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); let updated_profile = PermissionProfile::workspace_write(); - chat.set_permission_profile(updated_profile.clone()) - .expect("set permission profile"); + chat.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( + updated_profile.clone(), + )) + .expect("set permission profile"); assert_eq!( chat.config_ref().permissions.permission_profile(), - updated_profile, - "local permission changes should replace SessionConfigured profile-derived runtime permissions" + &updated_profile, + "local permission changes should replace SessionConfigured canonical permissions" + ); + assert_eq!( + chat.config_ref().permissions.effective_permission_profile(), + updated_profile + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd,)), + "effective permissions should still use the current thread runtime workspace roots" + ); +} + +#[tokio::test] +async fn session_configured_preserves_profile_workspace_roots() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + let previous_cwd = test_path_buf("/home/user/main").abs(); + let profile_root = test_path_buf("/home/user/shared").abs(); + chat.config.cwd = previous_cwd.clone(); + chat.config.workspace_roots = vec![previous_cwd, profile_root.clone()]; + chat.config.workspace_roots_explicit = false; + chat.config + .permissions + .set_workspace_roots(chat.config.workspace_roots.clone()); + + let session_cwd = test_path_buf("/home/user/sub-agent").abs(); + let session_runtime_workspace_roots = vec![session_cwd.clone()]; + let session_effective_workspace_roots = vec![session_cwd.clone(), profile_root]; + let session_permission_profile = PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots); + let configured = crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: session_permission_profile.clone(), + active_permission_profile: None, + cwd: session_cwd.clone(), + runtime_workspace_roots: session_runtime_workspace_roots.clone(), + instruction_source_paths: Vec::new(), + reasoning_effort: Some(ReasoningEffortConfig::default()), + message_history: None, + network_proxy: None, + rollout_path: None, + }; + + chat.handle_thread_session(configured); + + assert_eq!(&chat.config_ref().cwd, &session_cwd); + assert_eq!( + chat.config_ref().permissions.user_visible_workspace_roots(), + session_runtime_workspace_roots.as_slice() + ); + assert_eq!( + chat.config_ref().permissions.effective_permission_profile(), + session_permission_profile ); } @@ -301,11 +365,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let expected_app_server_permission_profile = AppServerPermissionProfile::External { - network: PermissionProfileNetworkPermissions { enabled: false }, + let expected_app_server_permission_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, }; - let expected_permission_profile: PermissionProfile = - expected_app_server_permission_profile.clone().into(); + let expected_permission_profile = expected_app_server_permission_profile.clone(); let expected_sandbox = SandboxPolicy::ExternalSandbox { network_access: NetworkAccess::Restricted, }; @@ -322,6 +385,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/external").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -334,7 +398,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( - AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()), + chat.config_ref().permissions.effective_permission_profile(), expected_app_server_permission_profile ); } @@ -360,6 +424,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -373,6 +438,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { "user-1", vec![AppServerUserInput::Image { url: remote_image_urls[0].clone(), + detail: None, }], ReplayKind::ResumeInitialMessages, ); @@ -414,6 +480,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -427,6 +494,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { "user-1", vec![AppServerUserInput::LocalImage { path: local_images[0].clone(), + detail: None, }], ReplayKind::ResumeInitialMessages, ); @@ -684,6 +752,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -729,6 +798,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index da4777bfb1..ba862a5293 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -57,6 +57,46 @@ async fn mcp_startup_complete_does_not_clear_running_task() { assert!(chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn turn_start_preserves_active_mcp_startup_header() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + handle_turn_started(&mut chat, "turn-1"); + + assert!(chat.bottom_pane.is_task_running()); + assert_eq!( + chat.status_state.current_status.header, + "Booting MCP server: schaltwerk" + ); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); + + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn turn_start_replaces_idle_completed_mcp_startup_header() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); + + assert!(!chat.bottom_pane.is_task_running()); + assert_eq!( + chat.status_state.current_status.header, + "Booting MCP server: schaltwerk" + ); + + handle_turn_started(&mut chat, "turn-1"); + + assert!(chat.bottom_pane.is_task_running()); + assert_eq!(chat.status_state.current_status.header, "Working"); } #[tokio::test] @@ -125,6 +165,82 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { ); } +#[tokio::test] +async fn mcp_startup_failure_restores_running_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + handle_turn_started(&mut chat, "turn-1"); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); + assert!( + chat.status_state + .current_status + .header + .starts_with("Starting MCP servers") + ); + + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", + ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); + let _ = drain_insert_history(&mut rx); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn mcp_startup_complete_preserves_review_status() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string()]); + handle_turn_started(&mut chat, "turn-1"); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + assert!( + chat.status_state + .current_status + .header + .starts_with("Booting MCP server") + ); + + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".to_string(), + target_item_id: Some("guardian-target-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: None, + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf '/tmp/guardian target'".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, + }); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Ready); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!( + chat.status_state.current_status.header, + "Reviewing approval request" + ); + assert_eq!( + chat.status_state.current_status.details, + Some("rm -rf '/tmp/guardian target'".to_string()) + ); +} + #[tokio::test] async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index df3615c0fd..d682d24d66 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,17 +1,16 @@ use super::*; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; use pretty_assertions::assert_eq; fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> PermissionProfile { - AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -45,7 +44,6 @@ fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> Permission glob_scan_max_depth: None, }, } - .into() } #[tokio::test] @@ -127,9 +125,9 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only() .into_iter() .find(|p| p.id == "read-only") .expect("read-only preset exists"); - let current_profile: PermissionProfile = AppServerPermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { + let current_profile: PermissionProfile = PermissionProfile::Managed { + network: NetworkSandboxPolicy::Restricted, + file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -146,8 +144,7 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only() ], glob_scan_max_depth: None, }, - } - .into(); + }; let cwd = test_path_buf("/tmp/project").abs(); assert!( @@ -584,6 +581,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure permission_profile: PermissionProfile::workspace_write(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -631,6 +629,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w permission_profile, active_permission_profile: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -740,7 +739,9 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context cwd: None, approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - permission_profile: Some(PermissionProfile::workspace_write()), + active_permission_profile: Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), windows_sandbox_level: None, model: None, effort: None, @@ -750,6 +751,20 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context personality: None, } ); + + let active_permission_profile_update = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::UpdateActivePermissionProfile(active_permission_profile) => { + Some(active_permission_profile) + } + _ => None, + }) + .expect("expected UpdateActivePermissionProfile event"); + + assert_eq!( + active_permission_profile_update, + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index e9fdb6d987..b97695e800 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1217,6 +1217,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -1403,6 +1404,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index 8c16e0b929..48bc341f6b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -597,6 +597,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() vec![ UserInput::Image { url: "data:image/png;base64,placeholder".to_string(), + detail: None, }, UserInput::Text { text, diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 2f2a6d3356..906deb3b56 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -106,7 +106,7 @@ async fn slash_commands_without_side_flag_are_rejected_for_side_threads() { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains( - "'/review' is unavailable in side conversations. Press Esc to return to the main thread first." + "'/review' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." ), "expected side conversation slash command error, got {rendered:?}" ); @@ -132,7 +132,7 @@ async fn slash_side_is_rejected_for_side_threads() { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains( - "'/side' is unavailable in side conversations. Press Esc to return to the main thread first." + "'/side' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." ), "expected side conversation slash command error, got {rendered:?}" ); @@ -276,7 +276,7 @@ async fn side_context_label_preserves_status_line_snapshot() { chat.refresh_status_line(); chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( - "Side from main thread · Esc to return".to_string(), + "Side from main thread · Ctrl+C to return".to_string(), )); let width = 80; @@ -297,7 +297,7 @@ async fn side_context_label_shows_parent_status_snapshot() { chat.show_welcome_banner = false; chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( - "Side from main thread · main needs input · Esc to return".to_string(), + "Side from main thread · main needs input · Ctrl+C to return".to_string(), )); let width = 80; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 53b60e0d8d..acd6b7111b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2230,6 +2230,7 @@ async fn session_configured_clears_goal_status_footer() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 077c33606d..d82eca338f 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -66,7 +66,9 @@ impl ChatWidget { self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); + if self.mcp_startup_status.is_none() || !self.status_header_is_mcp_startup_owned() { + self.set_status_header(String::from("Working")); + } self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.set_ambient_pet_notification( diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index bf3f9e8fe6..cdcf650cb1 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -593,8 +593,8 @@ impl ChatWidget { ) }), ), - UserInput::Image { url } => remote_image_urls.push(url.clone()), - UserInput::LocalImage { path } => local_images.push(path.clone()), + UserInput::Image { url, .. } => remote_image_urls.push(url.clone()), + UserInput::LocalImage { path, .. } => local_images.push(path.clone()), UserInput::Skill { .. } | UserInput::Mention { .. } => {} } } diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 3f6de0879f..d8ff59da48 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -15,14 +15,7 @@ impl ChatWidget { } let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); - let Ok(policy) = self - .config - .permissions - .permission_profile() - .to_legacy_sandbox_policy(self.config.cwd.as_path()) - else { - return Some((Vec::new(), 0, true)); - }; + let policy = self.config.legacy_sandbox_policy(); match codex_windows_sandbox::apply_world_writable_scan_and_denies( self.config.codex_home.as_path(), cwd.as_path(), @@ -49,10 +42,10 @@ impl ChatWidget { extra_count: usize, failed_scan: bool, ) { - let (approval, permission_profile) = match &preset { + let (approval, active_permission_profile) = match &preset { Some(p) => ( Some(AskForApproval::from(p.approval)), - Some(p.permission_profile.clone()), + Some(p.active_permission_profile.clone()), ), None => (None, None), }; @@ -72,7 +65,9 @@ impl ChatWidget { let mode_label = preset .as_ref() .map(|p| describe_profile(&p.permission_profile)) - .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); + .unwrap_or_else(|| { + describe_profile(&self.config.permissions.effective_permission_profile()) + }); let info_line = if failed_scan { Line::from(vec![ "We couldn't complete the world-writable scan, so protections cannot be verified. " @@ -115,10 +110,12 @@ impl ChatWidget { tx.send(AppEvent::SkipNextWorldWritableScan); })); } - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) { + if let (Some(approval), Some(active_permission_profile)) = + (approval, active_permission_profile.clone()) + { accept_actions.extend(Self::approval_preset_actions( approval, - permission_profile, + active_permission_profile, mode_label.to_string(), ApprovalsReviewer::User, )); @@ -129,10 +126,12 @@ impl ChatWidget { tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) { + if let (Some(approval), Some(active_permission_profile)) = + (approval, active_permission_profile) + { accept_and_remember_actions.extend(Self::approval_preset_actions( approval, - permission_profile, + active_permission_profile, mode_label.to_string(), ApprovalsReviewer::User, )); diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 4ec69bf26f..13ddffb14a 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -5,7 +5,7 @@ use codex_utils_cli::ApprovalModeCliArg; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::SharedCliOptions; -#[derive(Parser, Debug)] +#[derive(Parser, Clone, Debug)] #[command(version)] pub struct Cli { /// Optional user prompt to start the session. @@ -89,7 +89,7 @@ impl std::ops::DerefMut for Cli { } } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct TuiSharedCliOptions(SharedCliOptions); impl TuiSharedCliOptions { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs deleted file mode 100644 index 7b97151edb..0000000000 --- a/codex-rs/tui/src/history_cell.rs +++ /dev/null @@ -1,6135 +0,0 @@ -//! Transcript/history cells for the Codex TUI. -//! -//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed -//! transcript entries and, transiently, an in-flight active cell that can mutate in place while -//! streaming. -//! -//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and -//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on -//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place -//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the -//! rendered transcript output can change. - -use crate::diff_model::FileChange; -use crate::diff_render::create_diff_summary; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::OutputLinesParams; -use crate::exec_cell::TOOL_CALL_MAX_LINES; -use crate::exec_cell::output_lines; -use crate::exec_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::legacy_core::config::Config; -use crate::live_wrap::take_prefix_by_width; -use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; -use crate::motion::MotionMode; -use crate::motion::ReducedMotionIndicator; -use crate::motion::activity_indicator; -use crate::render::line_utils::line_to_static; -use crate::render::line_utils::prefix_lines; -use crate::render::line_utils::push_owned_lines; -use crate::render::renderable::Renderable; -use crate::session_state::ThreadSessionState; -use crate::style::proposed_plan_style; -use crate::style::user_message_style; -#[cfg(test)] -use crate::test_support::PathBufExt; -#[cfg(test)] -use crate::test_support::test_path_buf; -use crate::text_formatting::format_and_truncate_tool_result; -use crate::text_formatting::truncate_text; -use crate::tooltips; -use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; -use crate::wrapping::RtOptions; -use crate::wrapping::adaptive_wrap_line; -use crate::wrapping::adaptive_wrap_lines; -use base64::Engine; -use codex_app_server_protocol::AskForApproval; -use codex_app_server_protocol::McpAuthStatus; -use codex_app_server_protocol::McpServerStatus; -use codex_app_server_protocol::McpServerStatusDetail; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; -use codex_app_server_protocol::ToolRequestUserInputAnswer; -use codex_app_server_protocol::ToolRequestUserInputQuestion; -use codex_app_server_protocol::WebSearchAction; -use codex_config::types::McpServerTransportConfig; -#[cfg(test)] -use codex_mcp::qualified_mcp_tool_name_prefix; -use codex_otel::RuntimeMetricsSummary; -use codex_protocol::account::PlanType; -use codex_protocol::approvals::ExecPolicyAmendment; -use codex_protocol::approvals::NetworkPolicyAmendment; -#[cfg(test)] -use codex_protocol::mcp::Resource; -#[cfg(test)] -use codex_protocol::mcp::ResourceTemplate; -use codex_protocol::models::PermissionProfile; -use codex_protocol::models::local_image_label_text; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use codex_protocol::user_input::TextElement; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_cli::format_env_display; -use image::DynamicImage; -use image::ImageReader; -use ratatui::prelude::*; -use ratatui::style::Color; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Styled; -use ratatui::style::Stylize; -use ratatui::widgets::Clear; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use std::any::Any; -use std::collections::HashMap; -use std::io::Cursor; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; -use tracing::error; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; -use url::Url; - -const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000; -const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000; - -mod hook_cell; - -pub(crate) use hook_cell::HookCell; -pub(crate) use hook_cell::new_active_hook_cell; -pub(crate) use hook_cell::new_completed_hook_cell; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum HistoryRenderMode { - Rich, - Raw, -} - -pub(crate) fn raw_lines_from_source(source: &str) -> Vec> { - if source.is_empty() { - return Vec::new(); - } - - let mut parts = source.split('\n').collect::>(); - if source.ends_with('\n') { - parts.pop(); - } - - parts - .into_iter() - .map(|line| Line::from(line.to_string())) - .collect() -} - -pub(crate) fn plain_lines(lines: impl IntoIterator>) -> Vec> { - lines - .into_iter() - .map(|line| { - let text = line - .spans - .into_iter() - .map(|span| span.content.into_owned()) - .collect::(); - Line::from(text) - }) - .collect() -} - -/// A single renderable unit of conversation history. -/// -/// Each cell produces logical `Line`s and reports how many viewport -/// rows those lines occupy at a given terminal width. The default -/// height implementations use `Paragraph::wrap` to account for lines -/// that overflow the viewport width (e.g. long URLs that are kept -/// intact by adaptive wrapping). Concrete types only need to override -/// heights when they apply additional layout logic beyond what -/// `Paragraph::line_count` captures. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { - /// Returns the logical lines for the main chat viewport. - fn display_lines(&self, width: u16) -> Vec>; - - /// Returns copy-friendly plain logical lines for raw scrollback mode. - fn raw_lines(&self) -> Vec>; - - fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { - match mode { - HistoryRenderMode::Rich => self.display_lines(width), - HistoryRenderMode::Raw => self.raw_lines(), - } - } - - /// Returns the number of viewport rows needed to render this cell. - /// - /// The default delegates to `Paragraph::line_count` with - /// `Wrap { trim: false }`, which measures the actual row count after - /// ratatui's viewport-level character wrapping. This is critical - /// for lines containing URL-like tokens that are wider than the - /// terminal — the logical line count would undercount. - fn desired_height(&self, width: u16) -> u16 { - self.desired_height_for_mode(width, HistoryRenderMode::Rich) - } - - fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 { - Paragraph::new(Text::from(self.display_lines_for_mode(width, mode))) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - /// Returns lines for the transcript overlay (`Ctrl+T`). - /// - /// Defaults to `display_lines`. Override when the transcript - /// representation differs (e.g. `ExecCell` shows all calls with - /// `$`-prefixed commands and exit status). - fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) - } - - /// Returns the number of viewport rows for the transcript overlay. - /// - /// Uses the same `Paragraph::line_count` measurement as - /// `desired_height`. Contains a workaround for a ratatui bug where - /// a single whitespace-only line reports 2 rows instead of 1. - fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); - // Workaround: ratatui's line_count returns 2 for a single - // whitespace-only line. Clamp to 1 in that case. - if let [line] = &lines[..] - && line - .spans - .iter() - .all(|s| s.content.chars().all(char::is_whitespace)) - { - return 1; - } - - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn is_stream_continuation(&self) -> bool { - false - } - - /// Returns a coarse "animation tick" when transcript output is time-dependent. - /// - /// The transcript overlay caches the rendered output of the in-flight active cell, so cells - /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over - /// time to signal that the cached tail should be recomputed. Returning `None` means the - /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation - /// allows the overlay to keep up with the main viewport. - /// - /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on - /// the first rendered frame even though the main viewport is animating. - fn transcript_animation_tick(&self) -> Option { - None - } -} - -impl Renderable for Box { - fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); - let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - let y = if area.height == 0 { - 0 - } else { - let overflow = paragraph - .line_count(area.width) - .saturating_sub(usize::from(area.height)); - u16::try_from(overflow).unwrap_or(u16::MAX) - }; - // Active-cell content can reflow dramatically during resize/stream updates. Clear the - // entire draw area first so stale glyphs from previous frames never linger. - Clear.render(area, buf); - paragraph.scroll((y, 0)).render(area, buf); - } - fn desired_height(&self, width: u16) -> u16 { - HistoryCell::desired_height(self.as_ref(), width) - } -} - -impl dyn HistoryCell { - pub(crate) fn as_any(&self) -> &dyn Any { - self - } - - pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -#[derive(Debug)] -pub(crate) struct UserHistoryCell { - pub message: String, - pub text_elements: Vec, - #[allow(dead_code)] - pub local_image_paths: Vec, - pub remote_image_urls: Vec, -} - -/// Build logical lines for a user message with styled text elements. -/// -/// This preserves explicit newlines while interleaving element spans and skips -/// malformed byte ranges instead of panicking during history rendering. -fn build_user_message_lines_with_elements( - message: &str, - elements: &[TextElement], - style: Style, - element_style: Style, -) -> Vec> { - let mut elements = elements.to_vec(); - elements.sort_by_key(|e| e.byte_range.start); - let mut offset = 0usize; - let mut raw_lines: Vec> = Vec::new(); - for line_text in message.split('\n') { - let line_start = offset; - let line_end = line_start + line_text.len(); - let mut spans: Vec> = Vec::new(); - // Track how much of the line we've emitted to interleave plain and styled spans. - let mut cursor = line_start; - for elem in &elements { - let start = elem.byte_range.start.max(line_start); - let end = elem.byte_range.end.min(line_end); - if start >= end { - continue; - } - let rel_start = start - line_start; - let rel_end = end - line_start; - // Guard against malformed UTF-8 byte ranges from upstream data; skip - // invalid elements rather than panicking while rendering history. - if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { - continue; - } - let rel_cursor = cursor - line_start; - if cursor < start - && line_text.is_char_boundary(rel_cursor) - && let Some(segment) = line_text.get(rel_cursor..rel_start) - { - spans.push(Span::from(segment.to_string())); - } - if let Some(segment) = line_text.get(rel_start..rel_end) { - spans.push(Span::styled(segment.to_string(), element_style)); - cursor = end; - } - } - let rel_cursor = cursor - line_start; - if cursor < line_end - && line_text.is_char_boundary(rel_cursor) - && let Some(segment) = line_text.get(rel_cursor..) - { - spans.push(Span::from(segment.to_string())); - } - let line = if spans.is_empty() { - Line::from(line_text.to_string()).style(style) - } else { - Line::from(spans).style(style) - }; - raw_lines.push(line); - // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts - // for the separator byte. - offset = line_end + 1; - } - - raw_lines -} - -fn remote_image_display_line(style: Style, index: usize) -> Line<'static> { - Line::from(local_image_label_text(index)).style(style) -} - -fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec> { - while lines - .last() - .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty())) - { - lines.pop(); - } - lines -} - -impl HistoryCell for UserHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let wrap_width = width - .saturating_sub( - LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ - ) - .max(1); - - let style = user_message_style(); - let element_style = style.fg(Color::Cyan); - - let wrapped_remote_images = if self.remote_image_urls.is_empty() { - None - } else { - Some(adaptive_wrap_lines( - self.remote_image_urls - .iter() - .enumerate() - .map(|(idx, _url)| { - remote_image_display_line(element_style, idx.saturating_add(1)) - }), - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - )) - }; - - let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { - None - } else if self.text_elements.is_empty() { - let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); - let wrapped = adaptive_wrap_lines( - message_without_trailing_newlines - .split('\n') - .map(|line| Line::from(line).style(style)), - // Wrap algorithm matches textarea.rs. - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - let wrapped = trim_trailing_blank_lines(wrapped); - (!wrapped.is_empty()).then_some(wrapped) - } else { - let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, - style, - element_style, - ); - let wrapped = adaptive_wrap_lines( - raw_lines, - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ); - let wrapped = trim_trailing_blank_lines(wrapped); - (!wrapped.is_empty()).then_some(wrapped) - }; - - if wrapped_remote_images.is_none() && wrapped_message.is_none() { - return Vec::new(); - } - - let mut lines: Vec> = vec![Line::from("").style(style)]; - - if let Some(wrapped_remote_images) = wrapped_remote_images { - lines.extend(prefix_lines( - wrapped_remote_images, - " ".into(), - " ".into(), - )); - if wrapped_message.is_some() { - lines.push(Line::from("").style(style)); - } - } - - if let Some(wrapped_message) = wrapped_message { - lines.extend(prefix_lines( - wrapped_message, - "› ".bold().dim(), - " ".into(), - )); - } - - lines.push(Line::from("").style(style)); - lines - } - - fn raw_lines(&self) -> Vec> { - let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); - if !self.remote_image_urls.is_empty() { - if !lines.is_empty() { - lines.push(Line::from("")); - } - lines.extend( - self.remote_image_urls - .iter() - .enumerate() - .map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))), - ); - } - lines - } -} - -#[derive(Debug)] -pub(crate) struct ReasoningSummaryCell { - _header: String, - content: String, - /// Session cwd used to render local file links inside the reasoning body. - cwd: PathBuf, - transcript_only: bool, -} - -impl ReasoningSummaryCell { - /// Create a reasoning summary cell that will render local file links relative to the session - /// cwd active when the summary was recorded. - pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self { - Self { - _header: header, - content, - cwd: cwd.to_path_buf(), - transcript_only, - } - } - - fn lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - append_markdown( - &self.content, - crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2), - Some(self.cwd.as_path()), - &mut lines, - ); - let summary_style = Style::default().dim().italic(); - let summary_lines = lines - .into_iter() - .map(|mut line| { - line.spans = line - .spans - .into_iter() - .map(|span| span.patch_style(summary_style)) - .collect(); - line - }) - .collect::>(); - - adaptive_wrap_lines( - &summary_lines, - RtOptions::new(width as usize) - .initial_indent("• ".dim().into()) - .subsequent_indent(" ".into()), - ) - } -} - -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - self.lines(width) - } - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.lines(width) - } - - fn raw_lines(&self) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - raw_lines_from_source(self.content.trim()) - } - } -} - -#[derive(Debug)] -pub(crate) struct AgentMessageCell { - lines: Vec>, - is_first_line: bool, -} - -impl AgentMessageCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - lines, - is_first_line, - } - } -} - -impl HistoryCell for AgentMessageCell { - fn display_lines(&self, width: u16) -> Vec> { - adaptive_wrap_lines( - &self.lines, - RtOptions::new(width as usize) - .initial_indent(if self.is_first_line { - "• ".dim().into() - } else { - " ".into() - }) - .subsequent_indent(" ".into()), - ) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -/// A consolidated agent message cell that stores raw markdown source and re-renders from it. -/// -/// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App` -/// replaces the contiguous run of `AgentMessageCell`s with a single -/// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders -/// from source via `append_markdown_agent`, producing correctly-sized tables -/// with box-drawing borders. -/// -/// The cell snapshots `cwd` at construction so local file-link display remains aligned with the -/// session that produced the message. Reusing the current process cwd during reflow would make old -/// transcript content change meaning after a later `/cd` or resumed session. -#[derive(Debug)] -pub(crate) struct AgentMarkdownCell { - markdown_source: String, - cwd: PathBuf, -} - -impl AgentMarkdownCell { - /// Create a finalized source-backed assistant message cell. - /// - /// `markdown_source` must be the raw source accumulated by the stream controller, not already - /// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve - /// stale wrapping instead of repairing it. - pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self { - Self { - markdown_source, - cwd: cwd.to_path_buf(), - } - } -} - -impl HistoryCell for AgentMarkdownCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(wrap_width) = - crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) - else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); - }; - - let mut lines: Vec> = Vec::new(); - // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / - // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( - &self.markdown_source, - Some(wrap_width), - Some(self.cwd.as_path()), - &mut lines, - ); - prefix_lines(lines, "• ".dim(), " ".into()) - } - - fn raw_lines(&self) -> Vec> { - raw_lines_from_source(&self.markdown_source) - } -} - -/// Transient active-cell representation of the mutable tail of an agent stream. -/// -/// During streaming, lines that have not yet been committed to scrollback because they belong to -/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on -/// every delta and cleared when the stream finalizes. -#[derive(Debug)] -pub(crate) struct StreamingAgentTailCell { - lines: Vec>, - is_first_line: bool, -} - -impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - lines, - is_first_line, - } - } -} - -impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { - // Tail lines are already rendered at the controller's current stream width. - // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( - self.lines.clone(), - if self.is_first_line { - "• ".dim() - } else { - " ".into() - }, - " ".into(), - ) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -/// Transient active-cell representation of the mutable tail of a proposed-plan stream. -/// -/// The controller prepares the full styled plan lines because plan tails need the same header, -/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining -/// preview-only during streaming. -#[derive(Debug)] -pub(crate) struct StreamingPlanTailCell { - lines: Vec>, - is_stream_continuation: bool, -} - -impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { - Self { - lines, - is_stream_continuation, - } - } -} - -impl HistoryCell for StreamingPlanTailCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } - - fn is_stream_continuation(&self) -> bool { - self.is_stream_continuation - } -} - -#[derive(Debug)] -pub(crate) struct PlainHistoryCell { - lines: Vec>, -} - -impl PlainHistoryCell { - pub(crate) fn new(lines: Vec>) -> Self { - Self { lines } - } -} - -impl HistoryCell for PlainHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) - } -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -#[derive(Debug)] -pub(crate) struct UpdateAvailableHistoryCell { - latest_version: String, - update_action: Option, -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { - Self { - latest_version, - update_action, - } - } -} - -impl HistoryCell for UpdateAvailableHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - use ratatui_macros::line; - use ratatui_macros::text; - let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", update_action.command_str().cyan(), " to update."] - } else { - line![ - "See ", - "https://github.com/openai/codex".cyan().underlined(), - " for installation options." - ] - }; - - let content = text![ - line![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), - ], - update_instruction, - "", - "See full release notes:", - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ]; - - let inner_width = content - .width() - .min(usize::from(width.saturating_sub(4))) - .max(1); - with_border_with_inner_width(content.lines, inner_width) - } - - fn raw_lines(&self) -> Vec> { - let update_instruction = if let Some(update_action) = self.update_action { - format!("Run {} to update.", update_action.command_str()) - } else { - "See https://github.com/openai/codex for installation options.".to_string() - }; - vec![ - Line::from("Update available!"), - Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)), - Line::from(update_instruction), - Line::from(""), - Line::from("See full release notes:"), - Line::from("https://github.com/openai/codex/releases/latest"), - ] - } -} - -#[derive(Debug)] -pub(crate) struct PrefixedWrappedHistoryCell { - text: Text<'static>, - initial_prefix: Line<'static>, - subsequent_prefix: Line<'static>, -} - -impl PrefixedWrappedHistoryCell { - pub(crate) fn new( - text: impl Into>, - initial_prefix: impl Into>, - subsequent_prefix: impl Into>, - ) -> Self { - Self { - text: text.into(), - initial_prefix: initial_prefix.into(), - subsequent_prefix: subsequent_prefix.into(), - } - } -} - -impl HistoryCell for PrefixedWrappedHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - let opts = RtOptions::new(width.max(1) as usize) - .initial_indent(self.initial_prefix.clone()) - .subsequent_indent(self.subsequent_prefix.clone()); - adaptive_wrap_lines(&self.text, opts) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.text.clone().lines) - } -} - -#[derive(Debug)] -pub(crate) struct UnifiedExecInteractionCell { - command_display: Option, - stdin: String, -} - -impl UnifiedExecInteractionCell { - pub(crate) fn new(command_display: Option, stdin: String) -> Self { - Self { - command_display, - stdin, - } - } -} - -impl HistoryCell for UnifiedExecInteractionCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - let wrap_width = width as usize; - let waited_only = self.stdin.is_empty(); - - let mut header_spans = if waited_only { - vec!["• Waited for background terminal".bold()] - } else { - vec!["↳ ".dim(), "Interacted with background terminal".bold()] - }; - if let Some(command) = &self.command_display - && !command.is_empty() - { - header_spans.push(" · ".dim()); - header_spans.push(command.clone().dim()); - } - let header = Line::from(header_spans); - - let mut out: Vec> = Vec::new(); - let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width)); - push_owned_lines(&header_wrapped, &mut out); - - if waited_only { - return out; - } - - let input_lines: Vec> = self - .stdin - .lines() - .map(|line| Line::from(line.to_string())) - .collect(); - - let input_wrapped = adaptive_wrap_lines( - input_lines, - RtOptions::new(wrap_width) - .initial_indent(Line::from(" └ ".dim())) - .subsequent_indent(Line::from(" ".dim())), - ); - out.extend(input_wrapped); - out - } - - fn raw_lines(&self) -> Vec> { - let mut out = Vec::new(); - if self.stdin.is_empty() { - if let Some(command) = self - .command_display - .as_ref() - .filter(|command| !command.is_empty()) - { - out.push(Line::from(format!( - "Waited for background terminal: {command}" - ))); - } else { - out.push(Line::from("Waited for background terminal")); - } - return out; - } - - if let Some(command) = self - .command_display - .as_ref() - .filter(|command| !command.is_empty()) - { - out.push(Line::from(format!( - "Interacted with background terminal: {command}" - ))); - } else { - out.push(Line::from("Interacted with background terminal")); - } - out.extend(raw_lines_from_source(&self.stdin)); - out - } -} - -pub(crate) fn new_unified_exec_interaction( - command_display: Option, - stdin: String, -) -> UnifiedExecInteractionCell { - UnifiedExecInteractionCell::new(command_display, stdin) -} - -#[derive(Debug)] -struct UnifiedExecProcessesCell { - processes: Vec, -} - -impl UnifiedExecProcessesCell { - fn new(processes: Vec) -> Self { - Self { processes } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct UnifiedExecProcessDetails { - pub(crate) command_display: String, - pub(crate) recent_chunks: Vec, -} - -impl HistoryCell for UnifiedExecProcessesCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - - let wrap_width = width as usize; - let max_processes = 16usize; - let mut out: Vec> = Vec::new(); - out.push(vec!["Background terminals".bold()].into()); - out.push("".into()); - - if self.processes.is_empty() { - out.push(" • No background terminals running.".italic().into()); - return out; - } - - let prefix = " • "; - let prefix_width = UnicodeWidthStr::width(prefix); - let truncation_suffix = " [...]"; - let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); - let mut shown = 0usize; - for process in &self.processes { - if shown >= max_processes { - break; - } - let command = &process.command_display; - let (snippet, snippet_truncated) = { - let (first_line, has_more_lines) = match command.split_once('\n') { - Some((first, _)) => (first, true), - None => (command.as_str(), false), - }; - let max_graphemes = 80; - let mut graphemes = first_line.grapheme_indices(true); - if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { - (first_line[..byte_index].to_string(), true) - } else { - (first_line.to_string(), has_more_lines) - } - }; - if wrap_width <= prefix_width { - out.push(Line::from(prefix.dim())); - shown += 1; - continue; - } - let budget = wrap_width.saturating_sub(prefix_width); - let mut needs_suffix = snippet_truncated; - if !needs_suffix { - let (_, remainder, _) = take_prefix_by_width(&snippet, budget); - if !remainder.is_empty() { - needs_suffix = true; - } - } - if needs_suffix && budget > truncation_suffix_width { - let available = budget.saturating_sub(truncation_suffix_width); - let (truncated, _, _) = take_prefix_by_width(&snippet, available); - out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into()); - } else { - let (truncated, _, _) = take_prefix_by_width(&snippet, budget); - out.push(vec![prefix.dim(), truncated.cyan()].into()); - } - - let chunk_prefix_first = " ↳ "; - let chunk_prefix_next = " "; - for (idx, chunk) in process.recent_chunks.iter().enumerate() { - let chunk_prefix = if idx == 0 { - chunk_prefix_first - } else { - chunk_prefix_next - }; - let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix); - if wrap_width <= chunk_prefix_width { - out.push(Line::from(chunk_prefix.dim())); - continue; - } - let budget = wrap_width.saturating_sub(chunk_prefix_width); - let (truncated, remainder, _) = take_prefix_by_width(chunk, budget); - if !remainder.is_empty() && budget > truncation_suffix_width { - let available = budget.saturating_sub(truncation_suffix_width); - let (shorter, _, _) = take_prefix_by_width(chunk, available); - out.push( - vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(), - ); - } else { - out.push(vec![chunk_prefix.dim(), truncated.dim()].into()); - } - } - shown += 1; - } - - let remaining = self.processes.len().saturating_sub(shown); - if remaining > 0 { - let more_text = format!("... and {remaining} more running"); - if wrap_width <= prefix_width { - out.push(Line::from(prefix.dim())); - } else { - let budget = wrap_width.saturating_sub(prefix_width); - let (truncated, _, _) = take_prefix_by_width(&more_text, budget); - out.push(vec![prefix.dim(), truncated.dim()].into()); - } - } - - out - } - - fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } -} - -pub(crate) fn new_unified_exec_processes_output( - processes: Vec, -) -> CompositeHistoryCell { - let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); - let summary = UnifiedExecProcessesCell::new(processes); - CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) -} - -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, /*max_graphemes*/ 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -fn non_empty_exec_snippet(command: &[String]) -> Option { - let snippet = exec_snippet(command); - (!snippet.is_empty()).then_some(snippet) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ReviewDecision { - Approved, - ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: ExecPolicyAmendment, - }, - ApprovedForSession, - NetworkPolicyAmendment { - network_policy_amendment: NetworkPolicyAmendment, - }, - Denied, - TimedOut, - Abort, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ApprovalDecisionSubject { - Command(Vec), - NetworkAccess { target: String }, -} - -pub fn new_approval_decision_cell( - subject: ApprovalDecisionSubject, - decision: ReviewDecision, - actor: ApprovalDecisionActor, -) -> Box { - use ReviewDecision::*; - use codex_protocol::approvals::NetworkPolicyRuleAction; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "approved".bold(), - " codex to run ".into(), - Span::from(snippet).dim(), - " this time".bold(), - ] - } else { - vec![ - actor.subject().into(), - "approved".bold(), - " this request".into(), - " this time".bold(), - ] - }; - ("✔ ".green(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " this time".bold(), - ], - ), - }, - ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, - } => { - let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim(); - ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex to always run commands that start with ".into(), - snippet, - ], - ) - } - ApprovedForSession => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "approved".bold(), - " codex to run ".into(), - Span::from(snippet).dim(), - " every time this session".bold(), - ] - } else { - vec![ - actor.subject().into(), - "approved".bold(), - " this request".into(), - " every time this session".bold(), - ] - }; - ("✔ ".green(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "approved".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " every time this session".bold(), - ], - ), - }, - NetworkPolicyAmendment { - network_policy_amendment, - } => { - let target = match subject { - ApprovalDecisionSubject::NetworkAccess { target } => target, - ApprovalDecisionSubject::Command(_) => network_policy_amendment.host, - }; - match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => ( - "✔ ".green(), - vec![ - actor.subject().into(), - "persisted".bold(), - " Codex network access to ".into(), - Span::from(target).dim(), - ], - ), - NetworkPolicyRuleAction::Deny => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "denied".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - " and saved that rule".into(), - ], - ), - } - } - Denied => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - let snippet = Span::from(snippet).dim(); - match actor { - ApprovalDecisionActor::User => vec![ - actor.subject().into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ApprovalDecisionActor::Guardian => vec![ - "Request ".into(), - "denied".bold(), - " for codex to run ".into(), - snippet, - ], - } - } else { - match actor { - ApprovalDecisionActor::User => vec![ - actor.subject().into(), - "did not approve".bold(), - " this request".into(), - ], - ApprovalDecisionActor::Guardian => { - vec!["Request ".into(), "denied".bold()] - } - } - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "did not approve".bold(), - " codex network access to ".into(), - Span::from(target).dim(), - ], - ), - }, - TimedOut => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - "Review ".into(), - "timed out".bold(), - " before codex could run ".into(), - Span::from(snippet).dim(), - ] - } else { - vec![ - "Review ".into(), - "timed out".bold(), - " before this request could be approved".into(), - ] - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - "Review ".into(), - "timed out".bold(), - " before codex could access ".into(), - Span::from(target).dim(), - ], - ), - }, - Abort => match subject { - ApprovalDecisionSubject::Command(command) => { - let summary = if let Some(snippet) = non_empty_exec_snippet(&command) { - vec![ - actor.subject().into(), - "canceled".bold(), - " the request to run ".into(), - Span::from(snippet).dim(), - ] - } else { - vec![ - actor.subject().into(), - "canceled".bold(), - " this request".into(), - ] - }; - ("✗ ".red(), summary) - } - ApprovalDecisionSubject::NetworkAccess { target } => ( - "✗ ".red(), - vec![ - actor.subject().into(), - "canceled".bold(), - " the request for codex network access to ".into(), - Span::from(target).dim(), - ], - ), - }, - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApprovalDecisionActor { - User, - Guardian, -} - -impl ApprovalDecisionActor { - fn subject(self) -> &'static str { - match self { - Self::User => "You ", - Self::Guardian => "Auto-reviewer ", - } - } -} - -pub fn new_guardian_denied_patch_request(files: Vec) -> Box { - let mut summary = vec![ - "Request ".into(), - "denied".bold(), - " for codex to apply ".into(), - ]; - if files.len() == 1 { - summary.push("a patch touching ".into()); - summary.push(Span::from(files[0].clone()).dim()); - } else { - summary.push("a patch touching ".into()); - summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); - } - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - "✗ ".red(), - " ", - )) -} - -pub fn new_guardian_denied_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Request ".into(), - "denied".bold(), - " for ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " ")) -} - -pub fn new_guardian_approved_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Request ".into(), - "approved".bold(), - " for ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✔ ".green(), " ")) -} - -pub fn new_guardian_timed_out_patch_request(files: Vec) -> Box { - let mut summary = vec![ - "Review ".into(), - "timed out".bold(), - " before codex could apply ".into(), - ]; - if files.len() == 1 { - summary.push("a patch touching ".into()); - summary.push(Span::from(files[0].clone()).dim()); - } else { - summary.push("a patch touching ".into()); - summary.push(Span::from(files.len().to_string()).dim()); - summary.push(" files".into()); - } - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - "✗ ".red(), - " ", - )) -} - -pub fn new_guardian_timed_out_action_request(summary: String) -> Box { - let line = Line::from(vec![ - "Review ".into(), - "timed out".bold(), - " before ".into(), - Span::from(summary).dim(), - ]); - Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " ")) -} - -/// Cyan history cell line showing the current review status. -pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { - PlainHistoryCell { - lines: vec![Line::from(message.cyan())], - } -} - -#[derive(Debug)] -pub(crate) struct PatchHistoryCell { - changes: HashMap, - cwd: PathBuf, -} - -impl HistoryCell for PatchHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - create_diff_summary(&self.changes, &self.cwd, width as usize) - } - - fn raw_lines(&self) -> Vec> { - plain_lines(create_diff_summary( - &self.changes, - &self.cwd, - RAW_DIFF_SUMMARY_WIDTH, - )) - } -} - -#[derive(Debug)] -struct CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, -} -impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self, _width: u16) -> Vec> { - vec!["tool result (image output)".into()] - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from("tool result (image output)")] - } -} - -pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value - -pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { - if width < 4 { - return None; - } - let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); - Some(inner_width) -} - -/// Render `lines` inside a border sized to the widest span in the content. -pub(crate) fn with_border(lines: Vec>) -> Vec> { - with_border_internal(lines, /*forced_inner_width*/ None) -} - -/// Render `lines` inside a border whose inner width is at least `inner_width`. -/// -/// This is useful when callers have already clamped their content to a -/// specific width and want the border math centralized here instead of -/// duplicating padding logic in the TUI widgets themselves. -pub(crate) fn with_border_with_inner_width( - lines: Vec>, - inner_width: usize, -) -> Vec> { - with_border_internal(lines, Some(inner_width)) -} - -fn with_border_internal( - lines: Vec>, - forced_inner_width: Option, -) -> Vec> { - let max_line_width = lines - .iter() - .map(|line| { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum::() - }) - .max() - .unwrap_or(0); - let content_width = forced_inner_width - .unwrap_or(max_line_width) - .max(max_line_width); - - let mut out = Vec::with_capacity(lines.len() + 2); - let border_inner_width = content_width + 2; - out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into()); - - for line in lines.into_iter() { - let used_width: usize = line - .iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum(); - let span_count = line.spans.len(); - let mut spans: Vec> = Vec::with_capacity(span_count + 4); - spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); - if used_width < content_width { - spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); - } - spans.push(Span::from(" │").dim()); - out.push(Line::from(spans)); - } - - out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); - - out -} - -/// Return the emoji followed by a hair space (U+200A). -/// Using only the hair space avoids excessive padding after the emoji while -/// still providing a small visual gap across terminals. -pub(crate) fn padded_emoji(emoji: &str) -> String { - format!("{emoji}\u{200A}") -} - -#[derive(Debug)] -struct TooltipHistoryCell { - tip: String, - cwd: PathBuf, -} - -impl TooltipHistoryCell { - fn new(tip: String, cwd: &Path) -> Self { - Self { - tip, - cwd: cwd.to_path_buf(), - } - } -} - -impl HistoryCell for TooltipHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let indent = " "; - let indent_width = UnicodeWidthStr::width(indent); - let wrap_width = usize::from(width.max(1)) - .saturating_sub(indent_width) - .max(1); - let mut lines: Vec> = Vec::new(); - append_markdown( - &format!("**Tip:** {}", self.tip), - Some(wrap_width), - Some(self.cwd.as_path()), - &mut lines, - ); - - prefix_lines(lines, indent.into(), indent.into()) - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from(format!("Tip: {}", self.tip))] - } -} - -#[derive(Debug)] -pub struct SessionInfoCell(CompositeHistoryCell); - -impl HistoryCell for SessionInfoCell { - fn display_lines(&self, width: u16) -> Vec> { - self.0.display_lines(width) - } - - fn desired_height(&self, width: u16) -> u16 { - self.0.desired_height(width) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.0.transcript_lines(width) - } - - fn raw_lines(&self) -> Vec> { - self.0.raw_lines() - } -} - -pub(crate) fn new_session_info( - config: &Config, - requested_model: &str, - session: &ThreadSessionState, - is_first_event: bool, - tooltip_override: Option, - auth_plan: Option, - show_fast_status: bool, -) -> SessionInfoCell { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - session.model.clone(), - session.reasoning_effort, - show_fast_status, - config.cwd.to_path_buf(), - CODEX_CLI_VERSION, - ) - .with_yolo_mode(has_yolo_permissions( - session.approval_policy, - &session.permission_profile, - )); - let mut parts: Vec> = vec![Box::new(header)]; - - if is_first_event { - // Help lines below the header (new copy and list) - let help_lines: Vec> = vec![ - " To get started, describe a task or try one of these commands:" - .dim() - .into(), - Line::from(""), - Line::from(vec![ - " ".into(), - "/init".into(), - " - create an AGENTS.md file with instructions for Codex".dim(), - ]), - Line::from(vec![ - " ".into(), - "/status".into(), - " - show current session configuration".dim(), - ]), - Line::from(vec![ - " ".into(), - "/permissions".into(), - " - choose what Codex is allowed to do".dim(), - ]), - Line::from(vec![ - " ".into(), - "/model".into(), - " - choose what model and reasoning effort to use".dim(), - ]), - Line::from(vec![ - " ".into(), - "/review".into(), - " - review any changes and find issues".dim(), - ]), - ]; - - parts.push(Box::new(PlainHistoryCell { lines: help_lines })); - } else { - if config.show_tooltips - && let Some(tooltips) = tooltip_override - .or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status)) - .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) - { - parts.push(Box::new(tooltips)); - } - if requested_model != session.model.as_str() { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {requested_model}").into(), - format!("used: {}", session.model).into(), - ]; - parts.push(Box::new(PlainHistoryCell { lines })); - } - } - - SessionInfoCell(CompositeHistoryCell { parts }) -} - -pub(crate) fn is_yolo_mode(config: &Config) -> bool { - has_yolo_permissions( - AskForApproval::from(config.permissions.approval_policy.value()), - &config.permissions.permission_profile(), - ) -} - -fn has_yolo_permissions( - approval_policy: AskForApproval, - permission_profile: &PermissionProfile, -) -> bool { - let permission_profile = AppServerPermissionProfile::from(permission_profile.clone()); - approval_policy == AskForApproval::Never - && matches!( - permission_profile, - AppServerPermissionProfile::Disabled - | AppServerPermissionProfile::Managed { - file_system: PermissionProfileFileSystemPermissions::Unrestricted, - network: PermissionProfileNetworkPermissions { enabled: true }, - } - ) -} - -fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str { - match status { - McpAuthStatus::Unsupported => "Unsupported", - McpAuthStatus::NotLoggedIn => "Not logged in", - McpAuthStatus::BearerToken => "Bearer token", - McpAuthStatus::OAuth => "OAuth", - } -} - -pub(crate) fn new_user_prompt( - message: String, - text_elements: Vec, - local_image_paths: Vec, - remote_image_urls: Vec, -) -> UserHistoryCell { - UserHistoryCell { - message, - text_elements, - local_image_paths, - remote_image_urls, - } -} - -#[derive(Debug)] -pub(crate) struct SessionHeaderHistoryCell { - version: &'static str, - model: String, - model_style: Style, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - yolo_mode: bool, -} - -impl SessionHeaderHistoryCell { - pub(crate) fn new( - model: String, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self::new_with_style( - model, - Style::default(), - reasoning_effort, - show_fast_status, - directory, - version, - ) - } - - pub(crate) fn new_with_style( - model: String, - model_style: Style, - reasoning_effort: Option, - show_fast_status: bool, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self { - version, - model, - model_style, - reasoning_effort, - show_fast_status, - directory, - yolo_mode: false, - } - } - - pub(crate) fn with_yolo_mode(mut self, yolo_mode: bool) -> Self { - self.yolo_mode = yolo_mode; - self - } - - fn format_directory(&self, max_width: Option) -> String { - Self::format_directory_inner(&self.directory, max_width) - } - - fn format_directory_inner(directory: &Path, max_width: Option) -> String { - let formatted = if let Some(rel) = relativize_to_home(directory) { - if rel.as_os_str().is_empty() { - "~".to_string() - } else { - format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) - } - } else { - directory.display().to_string() - }; - - if let Some(max_width) = max_width { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(formatted.as_str()) > max_width { - return crate::text_formatting::center_truncate_path(&formatted, max_width); - } - } - - formatted - } - - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) - } -} - -impl HistoryCell for SessionHeaderHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { - return Vec::new(); - }; - - let make_row = |spans: Vec>| Line::from(spans); - - // Title line rendered inside the box: ">_ OpenAI Codex (vX)" - let title_spans: Vec> = vec![ - Span::from(">_ ").dim(), - Span::from("OpenAI Codex").bold(), - Span::from(" ").dim(), - Span::from(format!("(v{})", self.version)).dim(), - ]; - - const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; - const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; - const DIR_LABEL: &str = "directory:"; - const PERMISSIONS_LABEL: &str = "permissions:"; - let label_width = if self.yolo_mode { - DIR_LABEL.len().max(PERMISSIONS_LABEL.len()) - } else { - DIR_LABEL.len() - }; - - let model_label = format!( - "{model_label:> = { - let mut spans = vec![ - Span::from(format!("{model_label} ")).dim(), - Span::styled(self.model.clone(), self.model_style), - ]; - if let Some(reasoning) = reasoning_label { - spans.push(Span::from(" ")); - spans.push(Span::from(reasoning)); - } - if self.show_fast_status { - spans.push(" ".into()); - spans.push(Span::styled("fast", self.model_style.magenta())); - } - spans.push(" ".dim()); - spans.push(CHANGE_MODEL_HINT_COMMAND.cyan()); - spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim()); - spans - }; - - let dir_label = format!("{DIR_LABEL: Vec> { - let mut lines = vec![ - Line::from(format!("OpenAI Codex (v{})", self.version)), - Line::from(format!( - "model: {}{}", - self.model, - self.reasoning_label() - .map(|reasoning| format!(" {reasoning}")) - .unwrap_or_default() - )), - Line::from(format!( - "directory: {}", - self.format_directory(/*max_width*/ None) - )), - ]; - if self.yolo_mode { - lines.push(Line::from("permissions: YOLO mode")); - } - lines - } -} - -#[derive(Debug)] -pub(crate) struct CompositeHistoryCell { - parts: Vec>, -} - -impl CompositeHistoryCell { - pub(crate) fn new(parts: Vec>) -> Self { - Self { parts } - } -} - -impl HistoryCell for CompositeHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.display_lines(width); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } - - fn raw_lines(&self) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.raw_lines(); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } -} - -#[derive(Debug)] -pub(crate) struct McpToolCallCell { - call_id: String, - invocation: McpInvocation, - start_time: Instant, - duration: Option, - result: Option>, - animations_enabled: bool, -} - -#[derive(Debug, Clone)] -pub(crate) struct McpInvocation { - pub(crate) server: String, - pub(crate) tool: String, - pub(crate) arguments: Option, -} - -impl McpToolCallCell { - pub(crate) fn new( - call_id: String, - invocation: McpInvocation, - animations_enabled: bool, - ) -> Self { - Self { - call_id, - invocation, - start_time: Instant::now(), - duration: None, - result: None, - animations_enabled, - } - } - - pub(crate) fn call_id(&self) -> &str { - &self.call_id - } - - pub(crate) fn complete( - &mut self, - duration: Duration, - result: Result, - ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); - self.duration = Some(duration); - self.result = Some(result); - image_cell - } - - fn success(&self) -> Option { - match self.result.as_ref() { - Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), - Some(Err(_)) => Some(false), - None => None, - } - } - - pub(crate) fn mark_failed(&mut self) { - let elapsed = self.start_time.elapsed(); - self.duration = Some(elapsed); - self.result = Some(Err("interrupted".to_string())); - } - - fn render_content_block(block: &serde_json::Value, width: usize) -> String { - let content = match serde_json::from_value::(block.clone()) { - Ok(content) => content, - Err(_) => { - return format_and_truncate_tool_result( - &block.to_string(), - TOOL_CALL_MAX_LINES, - width, - ); - } - }; - - match content.raw { - rmcp::model::RawContent::Text(text) => { - format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) - } - rmcp::model::RawContent::Image(_) => "".to_string(), - rmcp::model::RawContent::Audio(_) => "