Merge branch 'etraut/next-turn-state-app-server' into etraut/next-turn-state-tui

# Conflicts:
#	codex-rs/tui/src/chatwidget.rs
#	codex-rs/tui/src/chatwidget/tests/plan_mode.rs
This commit is contained in:
Eric Traut
2026-05-14 10:44:52 -07:00
379 changed files with 22646 additions and 10873 deletions
+12
View File
@@ -11,6 +11,8 @@ body:
Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed.
If your version supports it, please run `codex doctor --json` and paste the output in the "Codex doctor report" field below. This helps us diagnose install, config, auth, terminal, MCP, network, and local state issues.
- type: input
id: version
attributes:
@@ -43,6 +45,16 @@ body:
description: |
Also note any multiplexer in use (screen / tmux / zellij).
E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell)
- type: textarea
id: doctor
attributes:
label: Codex doctor report
description: |
If available, run `codex doctor --json` and paste the full output here.
The report is designed to redact secrets, but please review it before submitting.
If your Codex version does not support `doctor`, write `not available`.
render: json
- type: textarea
id: actual
attributes:
+124
View File
@@ -0,0 +1,124 @@
{
"outputs": {
"codex-unsigned": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-aarch64-apple-darwin-unsigned\\.zst$",
"path": "codex"
},
"macos-x86_64": {
"regex": "^codex-x86_64-apple-darwin-unsigned\\.zst$",
"path": "codex"
},
"linux-x86_64": {
"regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
},
"linux-aarch64": {
"regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$",
"path": "codex"
},
"windows-x86_64": {
"regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
},
"windows-aarch64": {
"regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex.exe"
}
}
},
"codex-app-server-unsigned": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-app-server-aarch64-apple-darwin-unsigned\\.zst$",
"path": "codex-app-server"
},
"macos-x86_64": {
"regex": "^codex-app-server-x86_64-apple-darwin-unsigned\\.zst$",
"path": "codex-app-server"
},
"linux-x86_64": {
"regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$",
"path": "codex-app-server"
},
"linux-aarch64": {
"regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$",
"path": "codex-app-server"
},
"windows-x86_64": {
"regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-app-server.exe"
},
"windows-aarch64": {
"regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-app-server.exe"
}
}
},
"codex-responses-api-proxy-unsigned": {
"platforms": {
"macos-aarch64": {
"regex": "^codex-responses-api-proxy-aarch64-apple-darwin-unsigned\\.zst$",
"path": "codex-responses-api-proxy"
},
"macos-x86_64": {
"regex": "^codex-responses-api-proxy-x86_64-apple-darwin-unsigned\\.zst$",
"path": "codex-responses-api-proxy"
},
"linux-x86_64": {
"regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$",
"path": "codex-responses-api-proxy"
},
"linux-aarch64": {
"regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$",
"path": "codex-responses-api-proxy"
},
"windows-x86_64": {
"regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-responses-api-proxy.exe"
},
"windows-aarch64": {
"regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-responses-api-proxy.exe"
}
}
},
"bwrap": {
"platforms": {
"linux-x86_64": {
"regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$",
"path": "bwrap"
},
"linux-aarch64": {
"regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$",
"path": "bwrap"
}
}
},
"codex-command-runner": {
"platforms": {
"windows-x86_64": {
"regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-command-runner.exe"
},
"windows-aarch64": {
"regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-command-runner.exe"
}
}
},
"codex-windows-sandbox-setup": {
"platforms": {
"windows-x86_64": {
"regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-windows-sandbox-setup.exe"
},
"windows-aarch64": {
"regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-windows-sandbox-setup.exe"
}
}
}
}
}
+116 -17
View File
@@ -17,10 +17,10 @@ concurrency:
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
test:
# PRs use a fast Windows cross-compiled test leg for pre-merge signal.
# Post-merge pushes to main also run the native Windows test job below for
# broader Windows signal without putting PR latency back on the critical
# path. Cargo CI owns V8/code-mode test coverage for now.
# PRs use the sharded Windows cross-compiled test jobs below. Post-merge
# pushes to main also run the native Windows test job for broader Windows
# signal without putting PR latency back on the critical path. Cargo CI
# owns V8/code-mode test coverage for now.
timeout-minutes: 30
strategy:
fail-fast: false
@@ -44,12 +44,6 @@ jobs:
# - os: ubuntu-24.04-arm
# target: aarch64-unknown-linux-gnu
# Windows fast path: build the windows-gnullvm binaries with Linux
# RBE, then run the resulting Windows tests on the Windows runner.
# Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad
# non-code-mode signal.
- os: windows-latest
target: x86_64-pc-windows-gnullvm
runs-on: ${{ matrix.os }}
# Configure a human readable name for each job
@@ -108,13 +102,6 @@ jobs:
--test_verbose_timeout_warnings
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
)
if [[ "${RUNNER_OS}" == "Windows" ]]; then
bazel_wrapper_args+=(
--windows-cross-compile
--remote-download-toplevel
)
fi
./.github/scripts/run-bazel-ci.sh \
"${bazel_wrapper_args[@]}" \
-- \
@@ -141,6 +128,118 @@ jobs:
path: ${{ steps.prepare_bazel.outputs.repository-cache-path }}
key: ${{ steps.prepare_bazel.outputs.repository-cache-key }}
test-windows-shard:
# Split the Windows Bazel test leg across separate Windows
# hosts. Each shard still uses Linux RBE for build actions, but the test
# execution itself happens on its own Windows runner.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard:
- 1
- 2
- 3
- 4
runs-on: windows-latest
name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Prepare Bazel CI
id: prepare_bazel
uses: ./.github/actions/prepare-bazel-ci
with:
target: x86_64-pc-windows-gnullvm
# Reuse the former monolithic Windows test cache for restores. Do
# not save it from every shard below; duplicate uploads would sit on
# the PR-blocking critical path after the useful test work is done.
cache-scope: bazel-test
install-test-prereqs: "true"
- name: bazel test shard
env:
BAZEL_TEST_SHARD: ${{ matrix.shard }}
BAZEL_TEST_SHARD_COUNT: 4
BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }}
shell: bash
run: |
set -euo pipefail
bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))'
mapfile -t bazel_targets < <(
MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \
| LC_ALL=C sort
)
selected_targets=()
for bazel_target in "${bazel_targets[@]}"; do
target_bucket="$(
printf '%s\n' "${bazel_target}" \
| cksum \
| awk -v shard_count="${BAZEL_TEST_SHARD_COUNT}" '{ print ($1 % shard_count) + 1 }'
)"
if [[ "${target_bucket}" == "${BAZEL_TEST_SHARD}" ]]; then
selected_targets+=("${bazel_target}")
fi
done
if [[ ${#selected_targets[@]} -eq 0 ]]; then
echo "No Bazel test targets selected for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." >&2
exit 1
fi
echo "Selected ${#selected_targets[@]} of ${#bazel_targets[@]} Bazel test targets for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}."
bazel_test_args=(
test
--skip_incompatible_explicit_targets
--test_tag_filters=-argument-comment-lint
--test_verbose_timeout_warnings
--build_metadata=COMMIT_SHA=${GITHUB_SHA}
--build_metadata=TAG_windows_test_shard=${BAZEL_TEST_SHARD}
)
./.github/scripts/run-bazel-ci.sh \
--print-failed-action-summary \
--print-failed-test-logs \
--windows-cross-compile \
--remote-download-toplevel \
-- \
"${bazel_test_args[@]}" \
-- \
"${selected_targets[@]}"
- name: Upload Bazel execution logs
if: always() && !cancelled()
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: bazel-execution-logs-test-x86_64-pc-windows-gnullvm-shard-${{ matrix.shard }}
path: ${{ runner.temp }}/bazel-execution-logs
if-no-files-found: ignore
test-windows:
# Preserve the existing required-check surface while the real work happens
# in the sharded Windows jobs above.
if: always()
needs: test-windows-shard
runs-on: ubuntu-24.04
name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm
steps:
- name: Confirm Windows Bazel test shards passed
shell: bash
run: |
if [[ "${{ needs.test-windows-shard.result }}" != "success" ]]; then
echo "Windows Bazel test shards finished with result: ${{ needs.test-windows-shard.result }}" >&2
exit 1
fi
test-windows-native-main:
# Native Windows Bazel tests are slower and frequently approach the
# 30-minute PR budget. Run this only for post-merge commits to main and give
-153
View File
@@ -1,153 +0,0 @@
name: Issue Labeler
on:
issues:
types:
- opened
- labeled
jobs:
gather-labels:
name: Generate label suggestions
# Prevent runs on forks (requires OpenAI API key, wastes Actions minutes)
if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label'))
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
codex_output: ${{ steps.codex.outputs.final-message }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- id: codex
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
allow-users: "*"
prompt: |
You are an assistant that reviews GitHub issues for the repository.
Your job is to choose the most appropriate labels for the issue described later in this prompt.
Follow these rules:
- Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure.
1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth).
2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks.
3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
- If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to.
1. CLI — the Codex command line interface.
2. extension — VS Code (or other IDE) extension-specific issues.
3. app - Issues related to the Codex desktop application.
4. codex-web — Issues targeting the Codex web UI/Cloud experience.
5. github-action — Issues with the Codex GitHub action.
6. iOS — Issues with the Codex iOS app.
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
- For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks.
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
2. mcp — Topics involving Model Context Protocol servers/clients.
3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server.
4. azure — Problems or requests tied to Azure OpenAI deployments.
5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
6. code-review — Issues related to the code review feature or functionality.
7. safety-check - Issues related to cyber risk detection or trusted access verification.
8. auth - Problems related to authentication, login, or access tokens.
9. exec - Problems related to the "codex exec" command or functionality.
10. hooks - Problems related to event hooks
11. context - Problems related to compaction, context windows, or available context reporting.
12. skills - Problems related to skills or plugins
13. custom-model - Problems that involve using custom model providers, local models, or OSS models.
14. rate-limits - Problems related to token limits, rate limits, or token usage reporting.
15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions.
16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs.
17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues.
18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior.
19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior.
20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration.
21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state.
22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior.
23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior.
24. computer-use - Issues involving agentic computer use or SkyComputerUseService.
25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app.
26. memory - Issues involving agentic memory storage and retrieval.
27. imagen - Issues involving image generation.
28. remote - Issues involving remote access, remote control, or SSH.
29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks.
30. automations - Issues involving scheduled automation tasks or heartbeats.
31. pets - Issues involving pets avatars and animations.
32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets.
Issue number: ${{ github.event.issue.number }}
Issue title:
${{ github.event.issue.title }}
Issue body:
${{ github.event.issue.body }}
Repository full name:
${{ github.repository }}
output-schema: |
{
"type": "object",
"properties": {
"labels": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["labels"],
"additionalProperties": false
}
apply-labels:
name: Apply labels from Codex output
needs: gather-labels
if: ${{ needs.gather-labels.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }}
steps:
- name: Apply labels
run: |
json=${CODEX_OUTPUT//$'\r'/}
if [ -z "$json" ]; then
echo "Codex produced no output. Skipping label application."
exit 0
fi
if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then
echo "Codex output did not include a labels array. Raw output: $json"
exit 0
fi
labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring')
if [ -z "$labels" ]; then
echo "Codex returned an empty array. Nothing to do."
exit 0
fi
cmd=(gh issue edit "$ISSUE_NUMBER")
while IFS= read -r label; do
cmd+=(--add-label "$label")
done <<< "$labels"
"${cmd[@]}" || true
- name: Remove codex-label trigger
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }}
run: |
gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true
echo "Attempted to remove label: codex-label"
+141 -61
View File
@@ -10,6 +10,13 @@ on:
push:
tags:
- "rust-v*.*.*"
workflow_dispatch:
inputs:
sign_macos:
description: "Sign and notarize macOS release artifacts."
required: false
type: boolean
default: true
concurrency:
group: ${{ github.workflow }}
@@ -25,10 +32,17 @@ jobs:
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
- name: Validate tag matches Cargo.toml version
shell: bash
env:
SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }}
run: |
set -euo pipefail
echo "::group::Tag validation"
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${SIGN_MACOS}" == "true" ]]; then
echo "❌ Manual rust-release runs must set sign_macos=false"
exit 1
fi
# 1. Must be a tag and match the regex
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|| { echo "❌ Not a tag push"; exit 1; }
@@ -64,11 +78,36 @@ jobs:
# 2026-03-04: temporarily change releases to use thin LTO because
# Ubuntu ARM is timing out at 60 minutes.
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-responses-api-proxy"
build_dmg: "true"
- runner: macos-15-xlarge
target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server"
build_dmg: "false"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-responses-api-proxy"
build_dmg: "true"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server"
build_dmg: "false"
# Release artifacts intentionally ship MUSL-linked Linux binaries.
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
@@ -95,7 +134,7 @@ jobs:
binaries: "codex-app-server"
build_dmg: "false"
steps: &rust_release_build_steps
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -271,6 +310,39 @@ jobs:
path: codex-rs/target/**/cargo-timings/cargo-timing.html
if-no-files-found: warn
- if: ${{ runner.os == 'macOS' }}
name: Stage unsigned macOS artifacts
shell: bash
run: |
set -euo pipefail
target="${{ matrix.target }}"
release_dir="target/${target}/release"
dest="unsigned-dist/${target}"
mkdir -p "$dest"
for binary in ${{ matrix.binaries }}; do
binary_path="${release_dir}/${binary}"
unsigned_name="${binary}-${target}-unsigned"
unsigned_path="${dest}/${unsigned_name}"
if [[ ! -f "${binary_path}" ]]; then
echo "Binary ${binary_path} not found"
exit 1
fi
cp "${binary_path}" "${unsigned_path}"
tar -C "$dest" -czf "${unsigned_path}.tar.gz" "${unsigned_name}"
zstd -T0 -19 --rm "${unsigned_path}"
done
- if: ${{ runner.os == 'macOS' }}
name: Upload unsigned macOS artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.artifact_name }}-unsigned
path: codex-rs/unsigned-dist/${{ matrix.target }}/*
if-no-files-found: error
- if: ${{ contains(matrix.target, 'linux') }}
name: Cosign Linux artifacts
uses: ./.github/actions/linux-code-sign
@@ -279,7 +351,7 @@ jobs:
artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release
binaries: ${{ matrix.binaries }}
- if: ${{ runner.os == 'macOS' }}
- if: ${{ runner.os == 'macOS' && env.SIGN_MACOS == 'true' }}
name: MacOS code signing (binaries)
uses: ./.github/actions/macos-code-sign
with:
@@ -293,7 +365,7 @@ jobs:
apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }}
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }}
name: Build macOS dmg
shell: bash
run: |
@@ -333,7 +405,7 @@ jobs:
exit 1
fi
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }}
- if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' && env.SIGN_MACOS == 'true' }}
name: MacOS code signing (dmg)
uses: ./.github/actions/macos-code-sign
with:
@@ -347,6 +419,7 @@ jobs:
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- name: Stage artifacts
if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }}
shell: bash
run: |
dest="dist/${{ matrix.target }}"
@@ -376,7 +449,7 @@ jobs:
fi
- name: Build Python runtime wheel
if: ${{ matrix.bundle == 'primary' }}
if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }}
shell: bash
run: |
set -euo pipefail
@@ -427,7 +500,7 @@ jobs:
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
- name: Upload Python runtime wheel
if: ${{ matrix.bundle == 'primary' }}
if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: python-runtime-wheel-${{ matrix.target }}
@@ -435,6 +508,7 @@ jobs:
if-no-files-found: error
- name: Compress artifacts
if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }}
shell: bash
run: |
# Path that contains the uncompressed binaries for the current
@@ -471,6 +545,7 @@ jobs:
done
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }}
with:
name: ${{ matrix.artifact_name }}
# Upload the per-binary .zst files, .tar.gz equivalents, and any
@@ -478,55 +553,6 @@ jobs:
path: |
codex-rs/dist/${{ matrix.target }}/*
build-macos:
needs: tag-check
name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
# Release builds can take a long time, so leave some headroom to avoid
# having to restart the full workflow due to a timeout.
timeout-minutes: 90
environment: macos-signing
permissions:
contents: read
defaults:
run:
working-directory: codex-rs
env:
# Preserve the LTO setting from the original combined release build matrix.
# Linux ARM is why this currently resolves to thin for every target.
CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
bundle: primary
artifact_name: aarch64-apple-darwin
binaries: "codex codex-responses-api-proxy"
build_dmg: "true"
- runner: macos-15-xlarge
target: aarch64-apple-darwin
bundle: app-server
artifact_name: aarch64-apple-darwin-app-server
binaries: "codex-app-server"
build_dmg: "false"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: primary
artifact_name: x86_64-apple-darwin
binaries: "codex codex-responses-api-proxy"
build_dmg: "true"
- runner: macos-15-xlarge
target: x86_64-apple-darwin
bundle: app-server
artifact_name: x86_64-apple-darwin-app-server
binaries: "codex-app-server"
build_dmg: "false"
steps: *rust_release_build_steps
build-windows:
needs: tag-check
uses: ./.github/workflows/rust-release-windows.yml
@@ -549,7 +575,6 @@ jobs:
release:
needs:
- build
- build-macos
- build-windows
- argument-comment-lint-release-assets
- zsh-release-assets
@@ -558,9 +583,12 @@ jobs:
permissions:
contents: write
actions: read
env:
SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }}
outputs:
version: ${{ steps.release_name.outputs.name }}
tag: ${{ github.ref_name }}
sign_macos: ${{ steps.release_mode.outputs.sign_macos }}
should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }}
npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }}
should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }}
@@ -571,6 +599,11 @@ jobs:
with:
persist-credentials: false
- name: Define release mode
id: release_mode
run: |
echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT"
- name: Generate release notes from tag commit message
id: release_notes
shell: bash
@@ -598,6 +631,24 @@ jobs:
- name: List
run: ls -R dist/
- name: Prune artifacts excluded from unsigned macOS release
if: ${{ env.SIGN_MACOS == 'false' }}
run: |
find dist -mindepth 1 -maxdepth 1 -type d \
! -name '*-apple-darwin*-unsigned' \
! -name 'aarch64-unknown-linux-musl' \
! -name 'aarch64-unknown-linux-musl-app-server' \
! -name 'x86_64-unknown-linux-musl' \
! -name 'x86_64-unknown-linux-musl-app-server' \
! -name 'aarch64-pc-windows-msvc' \
! -name 'x86_64-pc-windows-msvc' \
-exec rm -rf {} +
if ! find dist -type f -name '*-apple-darwin*-unsigned*' | grep -q .; then
echo "No unsigned macOS artifacts found in downloaded workflow artifacts."
exit 1
fi
- name: Delete entries from dist/ that should not go in the release
run: |
rm -rf dist/windows-binaries*
@@ -629,6 +680,12 @@ jobs:
set -euo pipefail
version="${VERSION}"
if [[ "${SIGN_MACOS}" != "true" ]]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "npm_tag=" >> "$GITHUB_OUTPUT"
@@ -648,6 +705,11 @@ jobs:
set -euo pipefail
version="${VERSION}"
if [[ "${SIGN_MACOS}" != "true" ]]; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
@@ -657,21 +719,25 @@ jobs:
fi
- name: Setup pnpm
if: ${{ env.SIGN_MACOS == 'true' }}
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
run_install: false
- name: Setup Node.js for npm packaging
if: ${{ env.SIGN_MACOS == 'true' }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
- name: Install dependencies
if: ${{ env.SIGN_MACOS == 'true' }}
run: pnpm install --frozen-lockfile
# stage_npm_packages.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Stage npm packages
if: ${{ env.SIGN_MACOS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ steps.release_name.outputs.name }}
@@ -683,6 +749,7 @@ jobs:
--package codex-sdk
- name: Stage installer scripts
if: ${{ env.SIGN_MACOS == 'true' }}
run: |
cp scripts/install/install.sh dist/install.sh
cp scripts/install/install.ps1 dist/install.ps1
@@ -694,25 +761,37 @@ jobs:
tag_name: ${{ github.ref_name }}
body_path: ${{ steps.release_notes.outputs.path }}
files: dist/**
make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }}
# Mark as prerelease only when the version has a suffix after x.y.z
# (e.g. -alpha, -beta). Otherwise publish a normal release.
prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- if: ${{ env.SIGN_MACOS == 'true' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-config.json
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- if: ${{ env.SIGN_MACOS == 'false' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-unsigned-config.json
- if: ${{ env.SIGN_MACOS == 'true' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ github.ref_name }}
config: .github/dotslash-zsh-config.json
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
- if: ${{ env.SIGN_MACOS == 'true' }}
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -722,7 +801,7 @@ jobs:
- name: Trigger developers.openai.com deploy
# Only trigger the deploy if the release is not a pre-release.
# The deploy is used to update the developers.openai.com website with the new config schema json file.
if: ${{ !contains(steps.release_name.outputs.name, '-') }}
if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }}
continue-on-error: true
env:
DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }}
@@ -936,7 +1015,7 @@ jobs:
needs: release
# Only publish stable/mainline releases to WinGet; pre-releases include a
# '-' in the semver string (e.g., 1.2.3-alpha.1).
if: ${{ !contains(needs.release.outputs.version, '-') }}
if: ${{ needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }}
# This job only invokes a GitHub Action to open/update the winget-pkgs PR;
# it does not execute Windows-only tooling, so Linux is sufficient.
runs-on: ubuntu-latest
@@ -956,6 +1035,7 @@ jobs:
update-branch:
name: Update latest-alpha-cli branch
if: ${{ needs.release.outputs.sign_macos == 'true' }}
permissions:
contents: write
needs: release
+1
View File
@@ -1482,6 +1482,7 @@
"serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}",
"serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}",
"serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}",
"serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}",
"serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}",
"serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}",
"serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}",
+2 -1
View File
@@ -2,7 +2,7 @@
// Unified entry point for the Codex CLI.
import { spawn } from "node:child_process";
import { existsSync } from "fs";
import { existsSync, realpathSync } from "fs";
import { createRequire } from "node:module";
import path from "path";
import { fileURLToPath } from "url";
@@ -171,6 +171,7 @@ const packageManagerEnvVar =
? "CODEX_MANAGED_BY_BUN"
: "CODEX_MANAGED_BY_NPM";
env[packageManagerEnvVar] = "1";
env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, ".."));
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: "inherit",
+22 -13
View File
@@ -1902,7 +1902,6 @@ dependencies = [
"codex-feedback",
"codex-file-search",
"codex-file-watcher",
"codex-git-attribution",
"codex-git-utils",
"codex-guardian",
"codex-hooks",
@@ -2221,6 +2220,7 @@ dependencies = [
"assert_matches",
"clap",
"clap_complete",
"codex-api",
"codex-app-server",
"codex-app-server-daemon",
"codex-app-server-protocol",
@@ -2235,11 +2235,14 @@ dependencies = [
"codex-exec-server",
"codex-execpolicy",
"codex-features",
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-mcp-server",
"codex-memories-write",
"codex-model-provider",
"codex-models-manager",
"codex-plugin",
"codex-protocol",
"codex-responses-api-proxy",
"codex-rmcp-client",
@@ -2254,11 +2257,14 @@ dependencies = [
"codex-utils-cli",
"codex-utils-path",
"codex-windows-sandbox",
"crossterm",
"http 1.4.0",
"libc",
"owo-colors",
"predicates",
"pretty_assertions",
"regex-lite",
"serde",
"serde_json",
"sqlx",
"supports-color 3.0.2",
@@ -2432,6 +2438,7 @@ dependencies = [
"prost 0.14.3",
"schemars 0.8.22",
"serde",
"serde_ignored",
"serde_json",
"serde_path_to_error",
"sha2",
@@ -2911,16 +2918,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-git-attribution"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
"pretty_assertions",
]
[[package]]
name = "codex-git-utils"
version = "0.0.0"
@@ -3141,6 +3138,7 @@ dependencies = [
name = "codex-memories-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-core",
"codex-extension-api",
"codex-features",
@@ -3481,6 +3479,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"bytes",
"codex-api",
"codex-client",
@@ -3732,6 +3731,7 @@ dependencies = [
name = "codex-tools"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-app-server-protocol",
"codex-code-mode",
"codex-features",
@@ -3797,7 +3797,6 @@ dependencies = [
"codex-utils-oss",
"codex-utils-path",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor",
"codex-utils-string",
@@ -5454,7 +5453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11641,6 +11640,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
+1 -2
View File
@@ -46,7 +46,6 @@ members = [
"execpolicy-legacy",
"ext/extension-api",
"ext/guardian",
"ext/git-attribution",
"ext/memories",
"external-agent-migration",
"external-agent-sessions",
@@ -164,7 +163,6 @@ codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-guardian = { path = "ext/guardian" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
@@ -352,6 +350,7 @@ seccompiler = "0.5.0"
semver = "1.0"
sentry = "0.46.0"
serde = "1"
serde_ignored = "0.1.14"
serde_json = "1"
serde_path_to_error = "0.1.20"
serde_with = "3.17"
+6
View File
@@ -337,6 +337,8 @@ pub struct InProcessClientStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Feedback sink used by app-server/core telemetry and logs.
@@ -403,6 +405,7 @@ impl InProcessClientStartArgs {
config: self.config,
cli_overrides: self.cli_overrides,
loader_overrides: self.loader_overrides,
strict_config: self.strict_config,
cloud_requirements: self.cloud_requirements,
thread_config_loader,
feedback: self.feedback,
@@ -1031,6 +1034,7 @@ mod tests {
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -2189,6 +2193,7 @@ mod tests {
config: config.clone(),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -2229,6 +2234,7 @@ mod tests {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -156,3 +156,17 @@ fn update_loop_uses_hidden_app_server_subcommand() {
vec!["app-server", "daemon", "pid-update-loop"]
);
}
#[test]
fn app_server_remote_control_uses_runtime_flag() {
let backend = PidBackend::new(
"codex".into(),
"app-server.pid".into(),
/*remote_control_enabled*/ true,
);
assert_eq!(
backend.command_args(),
vec!["app-server", "--remote-control", "--listen", "unix://"]
);
}
+6 -2
View File
@@ -567,9 +567,13 @@ impl Daemon {
return Ok(());
}
let managed_codex_path = self.managed_codex_bin.display();
Err(anyhow!(
"managed standalone Codex install not found at {}; install Codex first",
self.managed_codex_bin.display()
"managed standalone Codex install not found at {managed_codex_path}\n\n\
This command requires the standalone install managed by the Codex installer, because \
the daemon starts and updates app-server from that fixed path.\n\n\
Install it with:\n curl -fsSL https://chatgpt.com/codex/install.sh | sh\n\n\
Then rerun the command you just tried."
))
}
@@ -7467,6 +7467,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -17981,12 +17988,6 @@
},
"ToolsV2": {
"properties": {
"view_image": {
"type": [
"boolean",
"null"
]
},
"web_search": {
"anyOf": [
{
@@ -3836,6 +3836,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -15805,12 +15812,6 @@
},
"ToolsV2": {
"properties": {
"view_image": {
"type": [
"boolean",
"null"
]
},
"web_search": {
"anyOf": [
{
@@ -482,6 +482,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -748,12 +755,6 @@
},
"ToolsV2": {
"properties": {
"view_image": {
"type": [
"boolean",
"null"
]
},
"web_search": {
"anyOf": [
{
@@ -84,6 +84,13 @@
],
"description": "This is the path to the user's config.toml file, though it is not guaranteed to exist."
},
"profile": {
"description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"user"
@@ -13,4 +13,9 @@ file: AbsolutePathBuf, } | { "type": "user",
* This is the path to the user's config.toml file, though it is not
* guaranteed to exist.
*/
file: AbsolutePathBuf, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
file: AbsolutePathBuf,
/**
* Name of the selected profile-v2 config layered on top of the base
* user config, when this layer represents one.
*/
profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" };
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WebSearchToolConfig } from "../WebSearchToolConfig";
export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, };
export type ToolsV2 = { web_search: WebSearchToolConfig | null, };
@@ -805,6 +805,18 @@ client_request_definitions! {
serialization: global("config"),
response: v2::ExperimentalFeatureEnablementSetResponse,
},
#[experimental("remoteControl/enable")]
RemoteControlEnable => "remoteControl/enable" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("remote-control"),
response: v2::RemoteControlEnableResponse,
},
#[experimental("remoteControl/disable")]
RemoteControlDisable => "remoteControl/disable" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("remote-control"),
response: v2::RemoteControlDisableResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -228,7 +228,6 @@ pub struct Profile {
#[serde(rename_all = "camelCase")]
pub struct Tools {
pub web_search: Option<bool>,
pub view_image: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)]
@@ -51,6 +51,10 @@ pub enum ConfigLayerSource {
/// This is the path to the user's config.toml file, though it is not
/// guaranteed to exist.
file: AbsolutePathBuf,
/// Name of the selected profile-v2 config layered on top of the base
/// user config, when this layer represents one.
profile: Option<String>,
},
/// Path to a .codex/ folder within a project. There could be multiple of
@@ -84,7 +88,13 @@ impl ConfigLayerSource {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::User { profile, .. } => {
if profile.is_some() {
21
} else {
20
}
}
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
@@ -120,7 +130,6 @@ pub struct SandboxWorkspaceWrite {
#[ts(export_to = "v2/")]
pub struct ToolsV2 {
pub web_search: Option<WebSearchToolConfig>,
pub view_image: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
@@ -13,6 +13,24 @@ pub struct RemoteControlStatusChangedNotification {
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlEnableResponse {
pub status: RemoteControlConnectionStatus,
pub installation_id: String,
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlDisableResponse {
pub status: RemoteControlConnectionStatus,
pub installation_id: String,
pub environment_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -22,3 +40,33 @@ pub enum RemoteControlConnectionStatus {
Connected,
Errored,
}
impl From<RemoteControlStatusChangedNotification> for RemoteControlEnableResponse {
fn from(notification: RemoteControlStatusChangedNotification) -> Self {
let RemoteControlStatusChangedNotification {
status,
installation_id,
environment_id,
} = notification;
Self {
status,
installation_id,
environment_id,
}
}
}
impl From<RemoteControlStatusChangedNotification> for RemoteControlDisableResponse {
fn from(notification: RemoteControlStatusChangedNotification) -> Self {
let RemoteControlStatusChangedNotification {
status,
installation_id,
environment_id,
} = notification;
Self {
status,
installation_id,
environment_id,
}
}
}
+1
View File
@@ -12,6 +12,7 @@ pub use transport::CHANNEL_CAPACITY;
pub use transport::ConnectionOrigin;
pub use transport::RemoteControlHandle;
pub use transport::RemoteControlStartConfig;
pub use transport::RemoteControlUnavailable;
pub use transport::TransportEvent;
pub use transport::app_server_control_socket_path;
pub use transport::auth;
@@ -32,6 +32,7 @@ mod websocket;
pub use remote_control::RemoteControlHandle;
pub use remote_control::RemoteControlStartConfig;
pub use remote_control::RemoteControlUnavailable;
pub use remote_control::start_remote_control;
pub use stdio::start_stdio_connection;
pub use unix_socket::start_control_socket_acceptor;
@@ -19,6 +19,8 @@ use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_login::AuthManager;
use codex_state::StateRuntime;
use std::error::Error;
use std::fmt;
use std::io;
use std::sync::Arc;
use tokio::sync::mpsc;
@@ -47,23 +49,87 @@ pub struct RemoteControlHandle {
state_db_available: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemoteControlUnavailable;
impl fmt::Display for RemoteControlUnavailable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"remote control cannot be enabled because sqlite state db is unavailable"
)
}
}
impl Error for RemoteControlUnavailable {}
impl RemoteControlHandle {
pub fn set_enabled(&self, enabled: bool) {
let requested_enabled = enabled;
let enabled = enabled && self.state_db_available;
if requested_enabled && !self.state_db_available {
pub fn enable(
&self,
) -> Result<RemoteControlStatusChangedNotification, RemoteControlUnavailable> {
if !self.state_db_available {
warn!("remote control cannot be enabled because sqlite state db is unavailable");
return Err(RemoteControlUnavailable);
}
self.enabled_tx.send_if_modified(|state| {
let changed = *state != enabled;
*state = enabled;
let changed = !*state;
*state = true;
changed
});
let status = self.status();
if matches!(
status.status,
RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting
) {
return Ok(status);
}
Ok(self.publish_status(RemoteControlConnectionStatus::Connecting))
}
pub fn disable(&self) -> RemoteControlStatusChangedNotification {
self.enabled_tx.send_if_modified(|state| {
let changed = *state;
*state = false;
changed
});
self.publish_status(RemoteControlConnectionStatus::Disabled)
}
pub fn status(&self) -> RemoteControlStatusChangedNotification {
self.status_tx.borrow().clone()
}
pub fn status_receiver(&self) -> watch::Receiver<RemoteControlStatusChangedNotification> {
self.status_tx.subscribe()
}
fn publish_status(
&self,
connection_status: RemoteControlConnectionStatus,
) -> RemoteControlStatusChangedNotification {
self.status_tx.send_if_modified(|status| {
let next_status = RemoteControlStatusChangedNotification {
status: connection_status,
installation_id: status.installation_id.clone(),
environment_id: if connection_status == RemoteControlConnectionStatus::Disabled {
None
} else {
status.environment_id.clone()
},
};
if *status == next_status {
return false;
}
*status = next_status;
true
});
self.status()
}
}
pub async fn start_remote_control(
@@ -639,7 +639,10 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled(
.await
.expect_err("remote control should not connect without sqlite state db");
remote_handle.set_enabled(/*enabled*/ true);
assert_eq!(
remote_handle.enable().expect_err("enable should fail"),
super::RemoteControlUnavailable
);
timeout(Duration::from_millis(100), listener.accept())
.await
.expect_err("remote control should remain disabled without sqlite state db");
@@ -655,7 +658,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled(
}
#[tokio::test]
async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
async fn remote_control_handle_enable_disable_stops_and_restarts_connections() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
@@ -701,7 +704,14 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
)
.await;
remote_handle.set_enabled(/*enabled*/ false);
assert_eq!(
remote_handle.disable(),
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Disabled,
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: None,
}
);
expect_remote_control_status_snapshot(
&mut status_rx,
RemoteControlStatusChangedNotification {
@@ -718,13 +728,20 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
.await
.expect_err("disabled remote control should not reconnect");
remote_handle.set_enabled(/*enabled*/ true);
assert_eq!(
remote_handle.enable().expect("enable should succeed"),
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Connecting,
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: None,
}
);
expect_remote_control_status_snapshot(
&mut status_rx,
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Connecting,
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: Some("env_test".to_string()),
environment_id: None,
},
)
.await;
-1
View File
@@ -42,7 +42,6 @@ codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true }
codex-guardian = { workspace = true }
codex-git-attribution = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
codex-hooks = { workspace = true }
+3 -1
View File
@@ -190,7 +190,7 @@ Example with notification opt-out:
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
@@ -203,6 +203,8 @@ Example with notification opt-out:
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server.
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
- `skills/config/write` — write user-level skill config by name or absolute path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
@@ -507,7 +507,7 @@ impl ExternalAgentConfigService {
Ok(config) => {
let configured_plugin_ids = config
.config_layer_stack
.get_user_layer()
.get_active_user_layer()
.and_then(|user_layer| user_layer.config.get("plugins"))
.and_then(|plugins| {
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {
+23 -1
View File
@@ -30,6 +30,7 @@ pub(crate) struct ConfigManager {
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
@@ -40,6 +41,7 @@ impl ConfigManager {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
@@ -49,6 +51,7 @@ impl ConfigManager {
cli_overrides: Arc::new(RwLock::new(cli_overrides)),
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
loader_overrides,
strict_config,
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
arg0_paths,
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
@@ -59,6 +62,10 @@ impl ConfigManager {
self.codex_home.as_path()
}
pub(crate) fn user_config_path(&self) -> std::io::Result<AbsolutePathBuf> {
self.loader_overrides.user_config_path(self.codex_home())
}
pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
@@ -161,6 +168,16 @@ impl ConfigManager {
self.current_cli_overrides(),
)
.await?;
if self.loader_overrides.user_config_path.is_some()
|| self.loader_overrides.user_config_profile.is_some()
{
let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?;
config.config_layer_stack = config.config_layer_stack.with_user_config_profile(
&user_config_path,
self.loader_overrides.user_config_profile.as_ref(),
TomlValue::Table(toml::map::Map::new()),
);
}
self.apply_runtime_feature_enablement(&mut config);
self.apply_arg0_paths(&mut config);
Ok(config)
@@ -217,6 +234,7 @@ impl ConfigManager {
.codex_home(self.codex_home.clone())
.cli_overrides(merged_cli_overrides)
.loader_overrides(self.loader_overrides.clone())
.strict_config(self.strict_config)
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
@@ -245,7 +263,10 @@ impl ConfigManager {
&self.codex_home,
cwd,
&self.current_cli_overrides(),
self.loader_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: self.loader_overrides.clone(),
strict_config: self.strict_config,
},
self.current_cloud_requirements(),
thread_config_loader.as_ref(),
)
@@ -280,6 +301,7 @@ impl ConfigManager {
codex_home,
cli_overrides,
loader_overrides,
/*strict_config*/ false,
cloud_requirements,
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
@@ -196,8 +196,9 @@ impl ConfigManager {
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home());
let allowed_path = self
.user_config_path()
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
@@ -215,7 +216,7 @@ impl ConfigManager {
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
let user_layer = match layers.get_user_layer() {
let user_layer = match layers.get_active_user_layer() {
Some(layer) => Cow::Borrowed(layer),
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
};
@@ -305,7 +306,7 @@ impl ConfigManager {
})?;
if !config_edits.is_empty() {
ConfigEditsBuilder::new(self.codex_home())
ConfigEditsBuilder::for_config_path(provided_path.as_path())
.with_edits(config_edits)
.apply()
.await
@@ -321,7 +322,7 @@ impl ConfigManager {
Ok(ConfigWriteResponse {
status,
version: updated_layers
.get_user_layer()
.get_active_user_layer()
.ok_or_else(|| {
ConfigManagerError::write(
ConfigWriteErrorCode::UserLayerNotFound,
@@ -375,6 +376,7 @@ async fn create_empty_user_layer(
Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
profile: None,
},
toml_value,
))
@@ -574,7 +576,7 @@ fn override_message(layer: &ConfigLayerSource) -> String {
dot_codex_folder.display(),
),
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file } => {
ConfigLayerSource::User { file, .. } => {
format!("Overridden by user config: {}", file.display())
}
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
@@ -594,7 +596,7 @@ fn compute_override_metadata(
effective: &TomlValue,
segments: &[String],
) -> Option<OverriddenMetadata> {
let user_value = match layers.get_user_layer() {
let user_value = match layers.get_active_user_layer() {
Some(user_layer) => value_at_path(&user_layer.config, segments),
None => return None,
};
@@ -293,7 +293,8 @@ async fn read_includes_origins_and_layers() {
assert_eq!(
layers.get(1).unwrap().name,
ConfigLayerSource::User {
file: user_file.clone()
file: user_file.clone(),
profile: None,
}
);
assert!(matches!(
@@ -454,6 +455,80 @@ async fn write_value_defaults_to_user_config_path() {
);
}
#[tokio::test]
async fn write_value_defaults_to_selected_user_config_path() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "").unwrap();
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.user_config_path =
Some(AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path"));
loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name"));
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
);
service
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: serde_json::json!("gpt-work"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write succeeds");
assert_eq!(
std::fs::read_to_string(&selected_path).expect("read selected config"),
"model = \"gpt-work\"\n"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read main config"),
"model = \"gpt-main\""
);
}
#[tokio::test]
async fn load_default_config_preserves_selected_user_config_path_after_load_error() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "not valid toml").unwrap();
let selected_file =
AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path");
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.user_config_path = Some(selected_file.clone());
loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name"));
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
);
service
.load_latest_config(/*fallback_cwd*/ None)
.await
.expect_err("selected config should fail to load");
let config = service
.load_default_config()
.await
.expect("default config loads after selected config error");
assert_eq!(
config.config_layer_stack.get_user_config_file(),
Some(&selected_file)
);
}
#[tokio::test]
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
let tmp = tempdir().expect("tempdir");
@@ -665,7 +740,10 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags);
assert_eq!(
layers.get(2).unwrap().name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
}
-1
View File
@@ -17,7 +17,6 @@ where
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
{
let mut builder = ExtensionRegistryBuilder::<Config>::new();
codex_git_attribution::install(&mut builder);
codex_guardian::install(&mut builder, guardian_agent_spawner);
Arc::new(builder.build())
}
+5
View File
@@ -122,6 +122,8 @@ pub struct InProcessStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Loader used to fetch typed thread config sources before a thread starts.
@@ -412,6 +414,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
args.config.codex_home.to_path_buf(),
args.cli_overrides,
args.loader_overrides,
args.strict_config,
args.cloud_requirements,
args.arg0_paths.clone(),
args.thread_config_loader,
@@ -433,6 +436,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
auth_manager,
installation_id,
rpc_transport: AppServerRpcTransport::InProcess,
remote_control_handle: None,
plugin_startup_tasks: crate::PluginStartupTasks::Start,
}));
let mut thread_created_rx = processor.thread_created_receiver();
@@ -775,6 +779,7 @@ mod tests {
config,
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+22 -31
View File
@@ -127,7 +127,7 @@ fn configured_thread_config_loader(config: &Config) -> Arc<dyn ThreadConfigLoade
/// Control-plane messages from the processor/transport side to the outbound router task.
///
/// `run_main_with_transport` now uses two loops/tasks:
/// `run_main_with_transport_options` uses two loops/tasks:
/// - processor loop: handles incoming JSON-RPC and request dispatch
/// - outbound loop: performs potentially slow writes to per-connection writers
///
@@ -374,16 +374,19 @@ pub async fn run_main(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
default_analytics_enabled: bool,
) -> IoResult<()> {
run_main_with_transport(
run_main_with_transport_options(
arg0_paths,
cli_config_overrides,
loader_overrides,
strict_config,
default_analytics_enabled,
AppServerTransport::Stdio,
SessionSource::VSCode,
AppServerWebsocketAuthSettings::default(),
AppServerRuntimeOptions::default(),
)
.await
}
@@ -409,33 +412,12 @@ impl Default for AppServerRuntimeOptions {
}
}
pub async fn run_main_with_transport(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
auth: AppServerWebsocketAuthSettings,
) -> IoResult<()> {
run_main_with_transport_options(
arg0_paths,
cli_config_overrides,
loader_overrides,
default_analytics_enabled,
transport,
session_source,
auth,
AppServerRuntimeOptions::default(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn run_main_with_transport_options(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
strict_config: bool,
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
@@ -472,6 +454,7 @@ pub async fn run_main_with_transport_options(
codex_home.to_path_buf(),
cli_kv_overrides.clone(),
loader_overrides,
strict_config,
Default::default(),
arg0_paths.clone(),
Arc::new(NoopThreadConfigLoader),
@@ -500,6 +483,10 @@ pub async fn run_main_with_transport_options(
{
Ok(config) => (config, true),
Err(err) => {
if strict_config {
return Err(err);
}
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
(
@@ -528,9 +515,16 @@ pub async fn run_main_with_transport_options(
})?;
codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME);
codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME);
let state_db_result = rollout_state_db::try_init(&config).await;
let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string);
let state_db = state_db_result.ok();
let state_db = match rollout_state_db::try_init(&config).await {
Ok(state_db) => Some(state_db),
Err(err) => {
let state_db_path = codex_state::state_db_path(config.sqlite_home.as_path());
return Err(std::io::Error::other(format!(
"failed to initialize sqlite state db at {}: {err}",
state_db_path.display()
)));
}
};
if should_run_personality_migration {
let effective_toml = config.config_layer_stack.effective_config();
@@ -647,10 +641,6 @@ pub async fn run_main_with_transport_options(
}
}
let installation_id = resolve_installation_id(&config.codex_home).await?;
if let Some(err) = &state_db_init_error {
error!("failed to initialize sqlite state db: {err}");
}
let transport_shutdown_token = CancellationToken::new();
let mut transport_accept_handles = Vec::<JoinHandle<()>>::new();
@@ -807,6 +797,7 @@ pub async fn run_main_with_transport_options(
auth_manager,
installation_id,
rpc_transport: analytics_rpc_transport(&transport),
remote_control_handle: Some(remote_control_handle.clone()),
plugin_startup_tasks: runtime_options.plugin_startup_tasks,
}));
let mut thread_created_rx = processor.thread_created_receiver();
+18 -6
View File
@@ -39,6 +39,10 @@ struct AppServerArgs {
#[command(flatten)]
auth: AppServerWebsocketAuthArgs,
/// Fail if config.toml contains unknown configuration fields.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
/// Hidden debug-only test hook used by integration tests that spawn the
/// production app-server binary.
#[cfg(debug_assertions)]
@@ -52,7 +56,15 @@ struct AppServerArgs {
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
let args = AppServerArgs::parse();
let AppServerArgs {
listen,
session_source,
auth,
strict_config,
#[cfg(debug_assertions)]
disable_plugin_startup_tasks_for_tests,
remote_control,
} = AppServerArgs::parse();
let loader_overrides = if disable_managed_config_from_debug_env() {
LoaderOverrides::without_managed_config_for_tests()
} else {
@@ -60,20 +72,20 @@ fn main() -> anyhow::Result<()> {
.map(LoaderOverrides::with_managed_config_path_for_tests)
.unwrap_or_default()
};
let transport = args.listen;
let session_source = args.session_source;
let auth = args.auth.try_into_settings()?;
let transport = listen;
let auth = auth.try_into_settings()?;
let mut runtime_options = AppServerRuntimeOptions::default();
#[cfg(debug_assertions)]
if args.disable_plugin_startup_tasks_for_tests {
if disable_plugin_startup_tasks_for_tests {
runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip;
}
runtime_options.remote_control_enabled = args.remote_control;
runtime_options.remote_control_enabled = remote_control;
run_main_with_transport_options(
arg0_paths,
CliConfigOverrides::default(),
loader_overrides,
strict_config,
/*default_analytics_enabled*/ false,
transport,
session_source,
+1
View File
@@ -207,6 +207,7 @@ mod tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::without_managed_config_for_tests(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
loader.clone(),
@@ -30,6 +30,7 @@ use crate::request_processors::MarketplaceRequestProcessor;
use crate::request_processors::McpRequestProcessor;
use crate::request_processors::PluginRequestProcessor;
use crate::request_processors::ProcessExecRequestProcessor;
use crate::request_processors::RemoteControlRequestProcessor;
use crate::request_processors::SearchRequestProcessor;
use crate::request_processors::ThreadGoalRequestProcessor;
use crate::request_processors::ThreadRequestProcessor;
@@ -42,6 +43,7 @@ use crate::skills_watcher::SkillsWatcher;
use crate::thread_state::ConnectionCapabilities;
use crate::thread_state::ThreadStateManager;
use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
use async_trait::async_trait;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppServerRpcTransport;
@@ -172,6 +174,7 @@ pub(crate) struct MessageProcessor {
marketplace_processor: MarketplaceRequestProcessor,
mcp_processor: McpRequestProcessor,
plugin_processor: PluginRequestProcessor,
remote_control_processor: RemoteControlRequestProcessor,
search_processor: SearchRequestProcessor,
thread_goal_processor: ThreadGoalRequestProcessor,
thread_processor: ThreadRequestProcessor,
@@ -264,6 +267,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) installation_id: String,
pub(crate) rpc_transport: AppServerRpcTransport,
pub(crate) remote_control_handle: Option<RemoteControlHandle>,
pub(crate) plugin_startup_tasks: crate::PluginStartupTasks,
}
@@ -286,6 +290,7 @@ impl MessageProcessor {
auth_manager,
installation_id,
rpc_transport,
remote_control_handle,
plugin_startup_tasks,
} = args;
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
@@ -386,6 +391,7 @@ impl MessageProcessor {
config_manager.clone(),
workspace_settings_cache,
);
let remote_control_processor = RemoteControlRequestProcessor::new(remote_control_handle);
let search_processor = SearchRequestProcessor::new(outgoing.clone());
let thread_goal_processor = ThreadGoalRequestProcessor::new(
Arc::clone(&thread_manager),
@@ -484,6 +490,7 @@ impl MessageProcessor {
marketplace_processor,
mcp_processor,
plugin_processor,
remote_control_processor,
search_processor,
thread_goal_processor,
thread_processor,
@@ -882,6 +889,14 @@ impl MessageProcessor {
.experimental_feature_enablement_set(request_id.clone(), params)
.await
}
ClientRequest::RemoteControlEnable { .. } => self
.remote_control_processor
.enable()
.map(|response| Some(response.into())),
ClientRequest::RemoteControlDisable { .. } => self
.remote_control_processor
.disable()
.map(|response| Some(response.into())),
ClientRequest::ConfigRequirementsRead { params: _, .. } => self
.config_processor
.config_requirements_read()
@@ -239,6 +239,7 @@ async fn build_test_processor(
config.codex_home.to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
@@ -264,6 +265,7 @@ async fn build_test_processor(
auth_manager,
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
rpc_transport: AppServerRpcTransport::Stdio,
remote_control_handle: None,
plugin_startup_tasks: crate::PluginStartupTasks::Start,
}));
(processor, outgoing_rx)
@@ -443,6 +443,7 @@ mod command_exec_processor;
mod config_processor;
mod environment_processor;
mod external_agent_config_processor;
mod feedback_doctor_report;
mod feedback_processor;
mod fs_processor;
mod git_processor;
@@ -451,6 +452,7 @@ mod marketplace_processor;
mod mcp_processor;
mod plugins;
mod process_exec_processor;
mod remote_control_processor;
mod search;
mod thread_processor;
mod token_usage_replay;
@@ -472,6 +474,7 @@ pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
pub(crate) use mcp_processor::McpRequestProcessor;
pub(crate) use plugins::PluginRequestProcessor;
pub(crate) use process_exec_processor::ProcessExecRequestProcessor;
pub(crate) use remote_control_processor::RemoteControlRequestProcessor;
pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;
@@ -0,0 +1,212 @@
//! Builds a redacted doctor report attachment for feedback uploads.
//!
//! Feedback upload should never depend on doctor succeeding. This module runs
//! the configured Codex executable as a subprocess, accepts only valid JSON from
//! `codex doctor --json`, derives a small set of Sentry tags, and otherwise
//! skips the attachment with a warning. Keeping the report generation out of the
//! app-server process avoids sharing doctor internals across crates while still
//! attaching exactly the same JSON a user could copy from the CLI.
use std::collections::BTreeMap;
use std::time::Duration;
use codex_core::config::Config;
use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME;
use codex_feedback::FeedbackAttachment;
use serde_json::Value;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::warn;
const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25);
const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256;
/// Redacted doctor report data that can be merged into a feedback upload.
pub(crate) struct DoctorFeedbackReport {
/// JSON support report to upload as `codex-doctor-report.json`.
pub(crate) attachment: FeedbackAttachment,
/// Low-cardinality Sentry tags derived from the report status and check ids.
pub(crate) tags: BTreeMap<String, String>,
}
/// Runs `codex doctor --json` and returns a best-effort feedback attachment.
///
/// Failure to spawn Codex, finish before the timeout, or parse JSON means the
/// feedback upload proceeds without the doctor report. Callers should merge the
/// returned tags without overriding explicit client-provided tags.
pub(crate) async fn doctor_feedback_report(config: &Config) -> Option<DoctorFeedbackReport> {
let executable = config
.codex_self_exe
.clone()
.or_else(|| std::env::current_exe().ok())?;
let mut command = Command::new(&executable);
command.arg("doctor").arg("--json");
command.kill_on_drop(/*kill_on_drop*/ true);
let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => {
warn!(
executable = %executable.display(),
error = %err,
"failed to run doctor report for feedback; skipping attachment"
);
return None;
}
Err(_) => {
warn!(
executable = %executable.display(),
"timed out running doctor report for feedback; skipping attachment"
);
return None;
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(json_start) = stdout.find('{') else {
warn!(
executable = %executable.display(),
status = %output.status,
stderr = %String::from_utf8_lossy(&output.stderr),
"doctor report for feedback did not produce JSON; skipping attachment"
);
return None;
};
let json = stdout[json_start..].trim();
let report: Value = match serde_json::from_str(json) {
Ok(report) => report,
Err(err) => {
warn!(
executable = %executable.display(),
status = %output.status,
error = %err,
"doctor report for feedback was not valid JSON; skipping attachment"
);
return None;
}
};
let pretty = serde_json::to_vec_pretty(&report).unwrap_or_else(|_| json.as_bytes().to_vec());
Some(DoctorFeedbackReport {
tags: doctor_report_tags(&report),
attachment: FeedbackAttachment {
filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(),
content_type: Some("application/json".to_string()),
buffer: pretty,
},
})
}
fn doctor_report_tags(report: &Value) -> BTreeMap<String, String> {
let mut tags = BTreeMap::new();
if let Some(overall_status) = report.get("overallStatus").and_then(Value::as_str) {
tags.insert(
"doctor_overall_status".to_string(),
truncate_tag_value(overall_status),
);
}
let mut ok_count = 0usize;
let mut warning_count = 0usize;
let mut fail_count = 0usize;
let mut failed_checks = Vec::new();
let mut warning_checks = Vec::new();
if let Some(checks) = report.get("checks") {
for check in check_values(checks) {
let status = check.get("status").and_then(Value::as_str);
let id = check.get("id").and_then(Value::as_str).unwrap_or("unknown");
match status {
Some("ok") => ok_count += 1,
Some("warning") => {
warning_count += 1;
warning_checks.push(id.to_string());
}
Some("fail") => {
fail_count += 1;
failed_checks.push(id.to_string());
}
_ => {}
}
}
}
tags.insert("doctor_ok_count".to_string(), ok_count.to_string());
tags.insert(
"doctor_warning_count".to_string(),
warning_count.to_string(),
);
tags.insert("doctor_fail_count".to_string(), fail_count.to_string());
if !failed_checks.is_empty() {
tags.insert(
"doctor_failed_checks".to_string(),
truncate_tag_value(&failed_checks.join(",")),
);
}
if !warning_checks.is_empty() {
tags.insert(
"doctor_warning_checks".to_string(),
truncate_tag_value(&warning_checks.join(",")),
);
}
tags
}
/// Iterates checks from both the current keyed JSON shape and older array reports.
fn check_values(checks: &Value) -> Box<dyn Iterator<Item = &Value> + '_> {
match checks {
Value::Array(values) => Box::new(values.iter()),
Value::Object(values) => Box::new(values.values()),
_ => Box::new(std::iter::empty()),
}
}
fn truncate_tag_value(value: &str) -> String {
if value.chars().count() <= MAX_DOCTOR_TAG_VALUE_LEN {
return value.to_string();
}
let prefix = value
.chars()
.take(MAX_DOCTOR_TAG_VALUE_LEN.saturating_sub(3))
.collect::<String>();
format!("{prefix}...")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn doctor_report_tags_summarize_status_counts() {
let report = json!({
"overallStatus": "fail",
"checks": {
"runtime.provenance": {"id": "runtime.provenance", "status": "ok"},
"websocket.reachability": {
"id": "websocket.reachability",
"status": "warning"
},
"auth.credentials": {"id": "auth.credentials", "status": "fail"}
}
});
let tags = doctor_report_tags(&report);
let expected = BTreeMap::from([
("doctor_fail_count".to_string(), "1".to_string()),
(
"doctor_failed_checks".to_string(),
"auth.credentials".to_string(),
),
("doctor_ok_count".to_string(), "1".to_string()),
("doctor_overall_status".to_string(), "fail".to_string()),
(
"doctor_warning_checks".to_string(),
"websocket.reachability".to_string(),
),
("doctor_warning_count".to_string(), "1".to_string()),
]);
assert_eq!(tags, expected);
}
}
@@ -56,6 +56,7 @@ impl FeedbackRequestProcessor {
extra_log_files,
tags,
} = params;
let mut upload_tags = tags.unwrap_or_default();
let conversation_id = match thread_id.as_deref() {
Some(thread_id) => match ThreadId::from_string(thread_id) {
@@ -197,14 +198,27 @@ impl FeedbackRequestProcessor {
}
}
let mut extra_attachments = Vec::new();
if include_logs
&& let Some(doctor_report) =
super::feedback_doctor_report::doctor_feedback_report(&self.config).await
{
extra_attachments.push(doctor_report.attachment);
for (key, value) in doctor_report.tags {
upload_tags.entry(key).or_insert(value);
}
}
let session_source = self.thread_manager.session_source();
let upload_result = tokio::task::spawn_blocking(move || {
let tags = (!upload_tags.is_empty()).then_some(&upload_tags);
snapshot.upload_feedback(FeedbackUploadOptions {
classification: &classification,
reason: reason.as_deref(),
tags: tags.as_ref(),
tags,
include_logs,
extra_attachments: &extra_attachments,
extra_attachment_paths: &attachment_paths,
session_source: Some(session_source),
logs_override: sqlite_feedback_logs,
@@ -13,7 +13,7 @@ use super::*;
use crate::message_processor::ConnectionSessionState;
use crate::message_processor::InitializedConnectionSessionState;
const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon";
const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "codex-backend"];
#[derive(Clone)]
pub(crate) struct InitializeRequestProcessor {
@@ -92,7 +92,7 @@ impl InitializeRequestProcessor {
}
let originator = name.clone();
let user_agent_suffix = format!("{name}; {version}");
let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME;
let mutates_global_identity = !NON_ORIGINATING_CLIENT_NAMES.contains(&name.as_str());
let codex_home = self.config.codex_home.clone();
if session
.initialize(InitializedConnectionSessionState {
@@ -450,14 +450,15 @@ impl PluginRequestProcessor {
return Ok(empty_response());
}
let plugins_input = config.plugins_config_input();
let (mut data, marketplace_load_errors) = if include_local {
if include_local || marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) {
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
&roots,
Some(self.effective_plugins_changed_callback()),
);
}
let (mut data, marketplace_load_errors) = if include_local {
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?;
@@ -916,6 +917,9 @@ impl PluginRequestProcessor {
params: PluginShareUpdateTargetsParams,
) -> Result<PluginShareUpdateTargetsResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareUpdateTargetsParams {
remote_plugin_id,
discoverability,
@@ -0,0 +1,43 @@
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::transport::RemoteControlHandle;
use crate::transport::RemoteControlUnavailable;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
#[derive(Clone)]
pub(crate) struct RemoteControlRequestProcessor {
remote_control_handle: Option<RemoteControlHandle>,
}
impl RemoteControlRequestProcessor {
pub(crate) fn new(remote_control_handle: Option<RemoteControlHandle>) -> Self {
Self {
remote_control_handle,
}
}
pub(crate) fn enable(&self) -> Result<RemoteControlEnableResponse, JSONRPCErrorError> {
let handle = self.handle()?;
handle
.enable()
.map(RemoteControlEnableResponse::from)
.map_err(map_unavailable)
}
pub(crate) fn disable(&self) -> Result<RemoteControlDisableResponse, JSONRPCErrorError> {
let handle = self.handle()?;
Ok(RemoteControlDisableResponse::from(handle.disable()))
}
fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> {
self.remote_control_handle
.as_ref()
.ok_or_else(|| internal_error("remote control is unavailable for this app-server"))
}
}
fn map_unavailable(err: RemoteControlUnavailable) -> JSONRPCErrorError {
invalid_request(err.to_string())
}
@@ -1,5 +1,7 @@
use super::*;
use crate::error_code::method_not_found;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
@@ -3903,7 +3905,9 @@ fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path)
if matches!(
overrides.default_permissions.as_deref(),
Some(":workspace" | ":danger-no-sandbox")
Some(
BUILT_IN_PERMISSION_PROFILE_WORKSPACE | BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS
)
) {
return true;
}
@@ -65,6 +65,9 @@ mod thread_processor_behavior_tests {
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -470,14 +473,16 @@ mod thread_processor_behavior_tests {
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":workspace".to_string()),
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()),
..Default::default()
},
cwd.as_path()
));
assert!(requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":danger-no-sandbox".to_string()),
default_permissions: Some(
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()
),
..Default::default()
},
cwd.as_path()
@@ -491,7 +496,7 @@ mod thread_processor_behavior_tests {
));
assert!(!requested_permissions_trust_project(
&ConfigOverrides {
default_permissions: Some(":read-only".to_string()),
default_permissions: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()),
..Default::default()
},
cwd.as_path()
@@ -585,6 +590,7 @@ mod thread_processor_behavior_tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(StaticThreadConfigLoader::new(vec![
+2
View File
@@ -18,7 +18,9 @@ pub(crate) use codex_app_server_transport::ConnectionId;
pub(crate) use codex_app_server_transport::ConnectionOrigin;
pub(crate) use codex_app_server_transport::OutgoingMessage;
pub(crate) use codex_app_server_transport::QueuedOutgoingMessage;
pub(crate) use codex_app_server_transport::RemoteControlHandle;
pub(crate) use codex_app_server_transport::RemoteControlStartConfig;
pub(crate) use codex_app_server_transport::RemoteControlUnavailable;
pub(crate) use codex_app_server_transport::TransportEvent;
pub use codex_app_server_transport::app_server_control_socket_path;
pub use codex_app_server_transport::auth;
@@ -580,6 +580,18 @@ impl McpProcess {
.await
}
/// Send a `remoteControl/enable` JSON-RPC request.
pub async fn send_remote_control_enable_request(&mut self) -> anyhow::Result<i64> {
self.send_request("remoteControl/enable", /*params*/ None)
.await
}
/// Send a `remoteControl/disable` JSON-RPC request.
pub async fn send_remote_control_disable_request(&mut self) -> anyhow::Result<i64> {
self.send_request("remoteControl/disable", /*params*/ None)
.await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -149,6 +149,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
+1
View File
@@ -1,4 +1,5 @@
mod auth;
mod conversation_summary;
mod fuzzy_file_search;
mod strict_config;
mod v2;
@@ -0,0 +1,33 @@
use std::process::Command;
use anyhow::Result;
use tempfile::TempDir;
#[test]
fn strict_config_rejects_unknown_config_fields_for_standalone_app_server() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
foo = "bar"
"#,
)?;
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?)
.env("CODEX_HOME", codex_home.path())
.env(
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
codex_home.path().join("managed_config.toml"),
)
.args(["--strict-config", "--listen", "off"])
.output()?;
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr)?;
assert!(
stderr.contains("unknown configuration field `foo`"),
"expected strict config error in stderr, got: {stderr}"
);
Ok(())
}
@@ -306,7 +306,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu
);
assert!(
!codex_home.path().join("parent.txt").exists(),
"permissionProfile :project_roots write should not grant the server cwd when command cwd differs"
"permissionProfile :workspace_roots write should not grant the server cwd when command cwd differs"
);
Ok(())
@@ -82,6 +82,7 @@ sandbox_mode = "workspace-write"
origins.get("model").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let layers = layers.expect("layers present");
@@ -101,9 +102,6 @@ model = "gpt-user"
[tools.web_search]
context_size = "low"
allowed_domains = ["example.com"]
[tools]
view_image = false
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
@@ -138,7 +136,6 @@ view_image = false
allowed_domains: Some(vec!["example.com".to_string()]),
location: None,
}),
view_image: Some(false),
}
);
assert_eq!(
@@ -148,6 +145,7 @@ view_image = false
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
@@ -157,15 +155,9 @@ view_image = false
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
origins.get("tools.view_image").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
}
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
@@ -308,6 +300,7 @@ default_tools_approval_mode = "prompt"
origins.get("apps.app1.enabled").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
@@ -317,6 +310,7 @@ default_tools_approval_mode = "prompt"
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
@@ -326,6 +320,7 @@ default_tools_approval_mode = "prompt"
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
@@ -470,6 +465,7 @@ writable_roots = [{}]
origins.get("sandbox_mode").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
@@ -496,6 +492,7 @@ writable_roots = [{}]
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
@@ -739,7 +736,10 @@ fn assert_layers_user_then_optional_system(
assert_eq!(layers.len(), first_index + 2);
assert_eq!(
layers[first_index].name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
assert!(matches!(
layers[first_index + 1].name,
@@ -767,7 +767,10 @@ fn assert_layers_managed_user_then_optional_system(
);
assert_eq!(
layers[first_index + 1].name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
assert!(matches!(
layers[first_index + 2].name,
@@ -89,6 +89,33 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn initialize_codex_backend_does_not_override_originator() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let message = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_client_info(ClientInfo {
name: "codex-backend".to_string(),
title: Some("Codex Backend".to_string()),
version: "0.1.0".to_string(),
}),
)
.await??;
let JSONRPCMessage::Response(response) = message else {
anyhow::bail!("expected initialize response, got {message:?}");
};
let InitializeResponse { user_agent, .. } = to_response::<InitializeResponse>(response)?;
assert!(user_agent.starts_with("codex_cli_rs/"));
Ok(())
}
#[tokio::test]
async fn initialize_respects_originator_override_env_var() -> Result<()> {
let responses = Vec::new();
@@ -200,6 +200,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -38,6 +38,7 @@ mod plugin_uninstall;
mod process_exec;
mod rate_limits;
mod realtime_conversation;
mod remote_control;
#[cfg(debug_assertions)]
mod remote_thread_store;
mod request_permissions;
@@ -1360,7 +1360,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
write_plugins_enabled_config_with_base_url(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
@@ -1823,6 +1823,18 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
/*enabled*/ None,
))?;
shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null;
let shared_unlisted_body: serde_json::Value =
serde_json::from_str(&workspace_remote_plugin_page_body(
"plugins~Plugin_44444444444444444444444444444444",
"shared-unlisted-linear",
"Shared Unlisted Linear",
"UNLISTED",
/*enabled*/ None,
))?;
shared_plugin_body["plugins"]
.as_array_mut()
.expect("shared plugins should be an array")
.push(shared_unlisted_body["plugins"][0].clone());
let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?;
let mut workspace_installed_body: serde_json::Value =
serde_json::from_str(&workspace_remote_plugin_page_body(
@@ -1846,6 +1858,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
.push(unlisted_installed_body["plugins"][0].clone());
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -1878,10 +1891,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
.and_then(|interface| interface.display_name.as_deref()),
Some("Shared with me")
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins.len(), 2);
assert_eq!(
marketplace.plugins[0].id,
"shared-linear@workspace-shared-with-me-private"
"shared-linear@workspace-shared-with-me"
);
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
@@ -1913,6 +1926,29 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
Some("https://chatgpt.example/plugins/share/share-key-1")
);
assert_eq!(share_context.share_principals, None);
assert_eq!(
marketplace.plugins[1].id,
"shared-unlisted-linear@workspace-shared-with-me"
);
assert_eq!(
marketplace.plugins[1].remote_plugin_id.as_deref(),
Some("plugins~Plugin_44444444444444444444444444444444")
);
assert_eq!(marketplace.plugins[1].name, "shared-unlisted-linear");
assert_eq!(marketplace.plugins[1].installed, false);
assert_eq!(marketplace.plugins[1].enabled, false);
let share_context = marketplace.plugins[1]
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(
share_context.remote_plugin_id,
"plugins~Plugin_44444444444444444444444444444444"
);
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Unlisted)
);
let marketplace = response
.marketplaces
@@ -1929,7 +1965,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(
marketplace.plugins[0].id,
"unlisted-linear@workspace-shared-with-me-unlisted"
"unlisted-linear@workspace-shared-with-me"
);
assert_eq!(
marketplace.plugins[0].remote_plugin_id.as_deref(),
@@ -1951,6 +1987,12 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
share_context.discoverability,
Some(PluginShareDiscoverability::Unlisted)
);
wait_for_remote_plugin_request_count(
&server,
"/ps/plugins/installed",
/*expected_count*/ 5,
)
.await?;
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
@@ -300,74 +300,76 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("workspace-shared-with-me-private".to_string()),
plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(),
})
.await?;
for remote_marketplace_name in [
"workspace-shared-with-me-private",
"workspace-shared-with-me",
] {
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some(remote_marketplace_name.to_string()),
plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(
response.plugin.marketplace_name,
"workspace-shared-with-me-private"
);
assert_eq!(
response.plugin.summary.id,
"shared-linear@workspace-shared-with-me-private"
);
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_11111111111111111111111111111111")
);
let share_context = response
.plugin
.summary
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(
share_context.remote_plugin_id,
"plugins~Plugin_11111111111111111111111111111111"
);
assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Private)
);
assert_eq!(
share_context.creator_account_user_id.as_deref(),
Some("user-gavin__account-123")
);
assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));
assert_eq!(
share_context.share_url.as_deref(),
Some("https://chatgpt.example/plugins/share/share-key-1")
);
assert_eq!(
share_context.share_principals,
Some(vec![
PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-gavin__account-123".to_string(),
role: PluginSharePrincipalRole::Owner,
name: "Gavin".to_string(),
},
PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-ada__account-123".to_string(),
role: PluginSharePrincipalRole::Reader,
name: "Ada".to_string(),
},
])
);
assert_eq!(response.plugin.marketplace_name, "workspace-shared-with-me");
assert_eq!(
response.plugin.summary.id,
"shared-linear@workspace-shared-with-me"
);
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_11111111111111111111111111111111")
);
let share_context = response
.plugin
.summary
.share_context
.as_ref()
.expect("expected share context");
assert_eq!(
share_context.remote_plugin_id,
"plugins~Plugin_11111111111111111111111111111111"
);
assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4"));
assert_eq!(
share_context.discoverability,
Some(PluginShareDiscoverability::Private)
);
assert_eq!(
share_context.creator_account_user_id.as_deref(),
Some("user-gavin__account-123")
);
assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));
assert_eq!(
share_context.share_url.as_deref(),
Some("https://chatgpt.example/plugins/share/share-key-1")
);
assert_eq!(
share_context.share_principals,
Some(vec![
PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-gavin__account-123".to_string(),
role: PluginSharePrincipalRole::Owner,
name: "Gavin".to_string(),
},
PluginSharePrincipal {
principal_type: PluginSharePrincipalType::User,
principal_id: "user-ada__account-123".to_string(),
role: PluginSharePrincipalRole::Reader,
name: "Ada".to_string(),
},
])
);
}
Ok(())
}
@@ -169,7 +169,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "demo-plugin@workspace-shared-with-me-private".to_string(),
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
@@ -573,7 +573,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "demo-plugin@workspace-shared-with-me-private".to_string(),
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
@@ -1038,6 +1038,57 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_share_update_targets_rejects_when_plugin_sharing_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api"
[features]
plugins = true
remote_plugin = true
plugin_sharing = false
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/updateTargets",
Some(json!({
"remotePluginId": "plugins_123",
"discoverability": "UNLISTED",
"shareTargets": [],
})),
)
.await?;
let error: JSONRPCError = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(error.error.message, "plugin sharing is disabled");
Ok(())
}
#[tokio::test]
async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1123,7 +1174,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "demo-plugin@workspace-shared-with-me-private".to_string(),
id: "demo-plugin@workspace-shared-with-me".to_string(),
remote_plugin_id: Some("plugins_123".to_string()),
local_version: None,
name: "demo-plugin".to_string(),
@@ -0,0 +1,54 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn remote_control_disable_returns_disabled_status() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_remote_control_disable_request().await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: RemoteControlDisableResponse = to_response(response)?;
assert_eq!(received.status, RemoteControlConnectionStatus::Disabled);
assert_eq!(received.environment_id, None);
assert!(!received.installation_id.is_empty());
Ok(())
}
#[tokio::test]
async fn remote_control_enable_returns_connecting_status() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp.send_remote_control_enable_request().await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: RemoteControlEnableResponse = to_response(response)?;
assert_eq!(received.status, RemoteControlConnectionStatus::Connecting);
assert_eq!(received.environment_id, None);
assert!(!received.installation_id.is_empty());
Ok(())
}
@@ -75,6 +75,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -375,6 +375,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -440,6 +441,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -525,6 +527,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -245,6 +245,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -62,6 +62,7 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use core_test_support::responses;
@@ -780,7 +781,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn()
text_elements: Vec::new(),
}],
permissions: Some(PermissionProfileSelectionParams::Profile {
id: ":danger-no-sandbox".to_string(),
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
modifications: None,
}),
..Default::default()
@@ -1895,8 +1896,8 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![first_cwd.try_into()?],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}),
permissions: None,
model: Some("mock-model".to_string()),
@@ -537,8 +537,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.clone().try_into()?],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}),
model: Some("mock-model".to_string()),
effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium),
+4
View File
@@ -6,6 +6,9 @@ use anyhow::Context;
use serde::de::DeserializeOwned;
use std::time::Duration;
const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku";
const CODEX_PRODUCT_SKU: &str = "codex";
/// Make a GET request to the ChatGPT backend API.
pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
config: &Config,
@@ -46,6 +49,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
let mut request = client
.get(&url)
.headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers())
.header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU)
.header("Content-Type", "application/json");
if let Some(timeout) = timeout {
+9
View File
@@ -26,6 +26,7 @@ codex-app-server-daemon = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-app-server-test-client = { workspace = true }
codex-arg0 = { workspace = true }
codex-api = { workspace = true }
codex-chatgpt = { workspace = true }
codex-cloud-tasks = { path = "../cloud-tasks" }
codex-utils-cli = { workspace = true }
@@ -36,11 +37,14 @@ codex-exec = { workspace = true }
codex-exec-server = { workspace = true }
codex-execpolicy = { workspace = true }
codex-features = { workspace = true }
codex-install-context = { workspace = true }
codex-login = { workspace = true }
codex-memories-write = { workspace = true }
codex-mcp = { workspace = true }
codex-mcp-server = { workspace = true }
codex-model-provider = { workspace = true }
codex-models-manager = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-rmcp-client = { workspace = true }
@@ -52,18 +56,23 @@ codex-terminal-detection = { workspace = true }
codex-tui = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path = { workspace = true }
crossterm = { workspace = true }
http = { workspace = true }
libc = { workspace = true }
owo-colors = { workspace = true }
regex-lite = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
supports-color = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
"net",
"process",
"rt-multi-thread",
"signal",
"time",
] }
toml = { workspace = true }
tracing = { workspace = true }
+24 -3
View File
@@ -190,6 +190,7 @@ async fn run_command_under_sandbox(
.map_err(anyhow::Error::msg)?,
codex_linux_sandbox_exe,
config_options,
/*strict_config*/ false,
)
.await?;
@@ -636,12 +637,14 @@ async fn load_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
strict_config: bool,
) -> anyhow::Result<Config> {
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
options,
/*codex_home*/ None,
strict_config,
)
.await
}
@@ -651,6 +654,7 @@ async fn load_debug_sandbox_config_with_codex_home(
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
codex_home: Option<PathBuf>,
strict_config: bool,
) -> anyhow::Result<Config> {
let DebugSandboxConfigOptions {
permissions_profile,
@@ -680,6 +684,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home.clone(),
managed_requirements_mode,
strict_config,
)
.await?;
@@ -697,6 +702,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home,
managed_requirements_mode,
strict_config,
)
.await
.map_err(Into::into)
@@ -707,14 +713,16 @@ async fn build_debug_sandbox_config(
harness_overrides: ConfigOverrides,
codex_home: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
strict_config: bool,
) -> std::io::Result<Config> {
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides);
if let ManagedRequirementsMode::Ignore = managed_requirements_mode {
.harness_overrides(harness_overrides)
.strict_config(strict_config);
if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) {
builder = builder.loader_overrides(LoaderOverrides {
ignore_managed_requirements: true,
..Default::default()
..LoaderOverrides::default()
});
}
if let Some(codex_home) = codex_home {
@@ -783,6 +791,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let legacy_config = build_debug_sandbox_config(
@@ -793,6 +802,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -805,6 +815,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -840,6 +851,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let read_only_config = build_debug_sandbox_config(
@@ -850,6 +862,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -862,6 +875,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -905,6 +919,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -917,6 +932,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -942,6 +958,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -975,6 +992,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -1004,6 +1022,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -1015,6 +1034,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home.path().to_path_buf()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -1040,6 +1060,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
//! Reports app-server daemon state without starting or stopping the daemon.
//!
//! The background-server check is deliberately passive. It reads the daemon
//! state directory, PID files, settings file, and control socket path, then
//! attempts only a local socket connection when a socket already exists. That
//! keeps doctor safe to run while the user is debugging startup or update-loop
//! issues.
use std::path::Path;
use codex_core::config::Config;
use super::CheckStatus;
use super::DoctorCheck;
const STATE_DIR_NAME: &str = "app-server-daemon";
const SETTINGS_FILE_NAME: &str = "settings.json";
const PID_FILE_NAME: &str = "app-server.pid";
const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid";
/// Builds the app-server status row from existing daemon state.
///
/// Missing files are expected for the ephemeral/not-running case and should not
/// be treated as failures. A stale socket is a warning because it can explain
/// client connection problems without proving the daemon itself is broken.
pub(super) fn background_server_check(config: &Config) -> DoctorCheck {
let mut details = Vec::new();
let state_dir = config.codex_home.join(STATE_DIR_NAME);
details.push(format!("daemon state dir: {}", state_dir.display()));
push_file_detail(
&mut details,
"settings",
&state_dir.join(SETTINGS_FILE_NAME),
);
push_file_detail(&mut details, "pid file", &state_dir.join(PID_FILE_NAME));
push_file_detail(
&mut details,
"update-loop pid file",
&state_dir.join(UPDATE_PID_FILE_NAME),
);
let socket_path = match codex_app_server::app_server_control_socket_path(&config.codex_home) {
Ok(socket_path) => socket_path,
Err(err) => {
return DoctorCheck::new(
"app_server.status",
"app-server",
CheckStatus::Warning,
"background server socket path could not be resolved",
)
.details(details)
.detail(err.to_string());
}
};
details.push(format!("control socket: {}", socket_path.display()));
let status = socket_status(socket_path.as_path());
details.push(format!("status: {}", status.detail_label()));
details.push(format!("mode: {}", server_mode(&state_dir)));
let mut check = DoctorCheck::new(
"app_server.status",
"app-server",
status.check_status(),
status.summary(),
)
.details(details);
if status.check_status() == CheckStatus::Warning {
check = check.remediation("Run codex app-server daemon version for more details.");
}
check
}
fn push_file_detail(details: &mut Vec<String>, label: &str, path: &Path) {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => {
details.push(format!("{label}: {} (file)", path.display()));
}
Ok(_) => {
details.push(format!("{label}: {} (not a file)", path.display()));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
details.push(format!("{label}: {} (missing)", path.display()));
}
Err(err) => details.push(format!("{label}: {} ({err})", path.display())),
}
}
fn server_mode(state_dir: &Path) -> &'static str {
if state_dir.join(SETTINGS_FILE_NAME).is_file() {
"persistent"
} else {
"ephemeral"
}
}
#[derive(Clone, Copy)]
enum SocketStatus {
NotRunning,
Running,
#[cfg(unix)]
StaleOrUnreachable,
}
impl SocketStatus {
fn check_status(self) -> CheckStatus {
match self {
Self::NotRunning | Self::Running => CheckStatus::Ok,
#[cfg(unix)]
Self::StaleOrUnreachable => CheckStatus::Warning,
}
}
fn summary(self) -> &'static str {
match self {
Self::NotRunning => "background server is not running",
Self::Running => "background server is running",
#[cfg(unix)]
Self::StaleOrUnreachable => "background server socket is stale or unreachable",
}
}
fn detail_label(self) -> &'static str {
match self {
Self::NotRunning => "not running",
Self::Running => "running",
#[cfg(unix)]
Self::StaleOrUnreachable => "stale or unreachable",
}
}
}
fn socket_status(socket_path: &Path) -> SocketStatus {
if !socket_path.exists() {
return SocketStatus::NotRunning;
}
#[cfg(unix)]
{
match std::os::unix::net::UnixStream::connect(socket_path) {
Ok(_) => SocketStatus::Running,
Err(_) => SocketStatus::StaleOrUnreachable,
}
}
#[cfg(not(unix))]
{
SocketStatus::Running
}
}
File diff suppressed because it is too large Load Diff
+648
View File
@@ -0,0 +1,648 @@
//! Converts raw doctor detail strings into human-oriented rows.
//!
//! Checks intentionally store details as simple redacted `label: value` strings
//! so JSON serialization and human rendering share the same source data. This
//! module owns the presentation-only transformations: collapsing noisy booleans,
//! truncating long paths for terminal output, grouping repeated values, and
//! keeping the `--all` expansion behavior out of check construction.
use std::collections::BTreeSet;
use std::env;
use super::DoctorCheck;
use super::HumanOutputOptions;
use super::redact_detail;
const LIST_LIMIT: usize = 7;
const PATH_LIMIT: usize = 48;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum HumanDetail {
Row {
label: String,
value: String,
expected: Option<String>,
},
Continuation(String),
Bullet(String),
Remedy(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ParsedDetail {
label: String,
value: String,
}
pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec<HumanDetail> {
let parsed = parsed_details(check);
let details = match check.category.as_str() {
"runtime" => runtime_details(&parsed),
"install" => install_details(&parsed, options),
"config" => config_details(&parsed, options),
"state" => state_details(&parsed),
_ => generic_details(&parsed),
};
let mut details = details
.into_iter()
.map(|detail| attach_issue_metadata(detail, check))
.map(|detail| humanize_detail(detail, options))
.collect::<Vec<_>>();
details.extend(issue_remedies(check));
details
}
pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option<String> {
parsed_details(check)
.into_iter()
.find(|detail| detail.label == label)
.map(|detail| detail.value)
}
pub(super) fn rollout_summary(value: &str) -> Option<String> {
let (files, rest) = value.split_once(" files, ")?;
let (total_bytes, rest) = rest.split_once(" total bytes, ")?;
let (average_bytes, _) = rest.split_once(" average bytes")?;
let files = files.trim().parse::<u64>().ok()?;
let total_bytes = total_bytes.trim().parse::<u64>().ok()?;
let average_bytes = average_bytes.trim().parse::<u64>().ok()?;
Some(format!(
"{} files · {} (avg {})",
format_count(files),
format_bytes(total_bytes),
format_bytes(average_bytes)
))
}
pub(super) fn rollout_files_and_bytes(value: &str) -> Option<(u64, u64)> {
let (files, rest) = value.split_once(" files, ")?;
let (total_bytes, _) = rest.split_once(" total bytes, ")?;
Some((
files.trim().parse::<u64>().ok()?,
total_bytes.trim().parse::<u64>().ok()?,
))
}
pub(super) fn format_bytes(bytes: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = KIB * 1024.0;
const GIB: f64 = MIB * 1024.0;
let bytes = bytes as f64;
if bytes >= GIB {
format!("{:.2} GB", bytes / GIB)
} else if bytes >= MIB {
format!("{:.2} MB", bytes / MIB)
} else if bytes >= KIB {
format!("{:.2} KB", bytes / KIB)
} else {
format!("{} B", bytes as u64)
}
}
pub(super) fn format_count(count: u64) -> String {
let mut digits = count.to_string();
let mut out = String::new();
while digits.len() > 3 {
let tail = digits.split_off(digits.len() - 3);
if out.is_empty() {
out = tail;
} else {
out = format!("{tail},{out}");
}
}
if out.is_empty() {
digits
} else {
format!("{digits},{out}")
}
}
fn parsed_details(check: &DoctorCheck) -> Vec<ParsedDetail> {
check
.details
.iter()
.map(|detail| redact_detail(detail))
.map(|detail| {
detail
.split_once(": ")
.map(|(label, value)| ParsedDetail {
label: label.to_string(),
value: value.to_string(),
})
.unwrap_or_else(|| ParsedDetail {
label: String::new(),
value: detail,
})
})
.collect()
}
fn runtime_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> {
let mut out = Vec::new();
push_row_if_present(&mut out, parsed, "version", "version");
push_row_if_present(&mut out, parsed, "install method", "install method");
push_row_if_present(&mut out, parsed, "commit", "commit");
push_row_if_present(&mut out, parsed, "current executable", "executable");
push_remaining(
&mut out,
parsed,
&[
"version",
"platform",
"install method",
"commit",
"current executable",
],
&[],
);
out
}
fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> {
let mut out = Vec::new();
push_row_if_present(&mut out, parsed, "install context", "context");
if parsed.iter().any(|detail| {
detail.value == "ignored inherited package-manager launch env for cargo-built binary"
}) {
out.push(HumanDetail::Bullet(
"ignored inherited package-manager launch env for cargo-built binary".to_string(),
));
}
let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false");
let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false");
let package_root = value(parsed, "managed package root").unwrap_or("not set");
out.push(HumanDetail::Row {
label: "managed by".to_string(),
value: format!(
"npm: {} · bun: {} · package root {}",
yes_no(managed_by_npm),
yes_no(managed_by_bun),
if is_falsy(package_root) {
"—".to_string()
} else {
package_root.to_string()
}
),
expected: None,
});
let path_entries = numbered_values(parsed, "PATH codex #");
if !path_entries.is_empty() {
let total = path_entries.len();
let shown = if options.show_all {
total
} else {
total.min(3)
};
out.push(HumanDetail::Row {
label: format!("PATH entries ({total})"),
value: path_entries[0].clone(),
expected: None,
});
out.extend(
path_entries
.iter()
.skip(1)
.take(shown.saturating_sub(1))
.cloned()
.map(HumanDetail::Continuation),
);
if shown < total {
out.push(HumanDetail::Continuation(
"… (full list with --all)".to_string(),
));
}
}
push_remaining(
&mut out,
parsed,
&[
"current executable",
"install context",
"managed by npm",
"managed by bun",
"managed package root",
"PATH codex entries",
],
&["PATH codex #"],
);
out
}
fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec<HumanDetail> {
let mut out = Vec::new();
if let Some(model) = value(parsed, "model") {
let value = value(parsed, "model provider").map_or_else(
|| model.to_string(),
|provider| format!("{model} · {provider}"),
);
out.push(HumanDetail::Row {
label: "model".to_string(),
value,
expected: None,
});
}
push_row_if_present(&mut out, parsed, "cwd", "cwd");
push_row_if_present(&mut out, parsed, "config.toml", "config.toml");
push_row_if_present(&mut out, parsed, "config.toml parse", "config.toml parse");
push_row_if_present(&mut out, parsed, "config.toml read", "config.toml read");
push_row_if_present(&mut out, parsed, "mcp servers", "MCP servers");
push_feature_flags(&mut out, parsed, options);
for detail in parsed
.iter()
.filter(|detail| detail.label == "legacy feature flag")
{
out.push(HumanDetail::Row {
label: "legacy alias".to_string(),
value: detail.value.clone(),
expected: None,
});
}
push_remaining(
&mut out,
parsed,
&[
"CODEX_HOME",
"cwd",
"model",
"model provider",
"log dir",
"sqlite home",
"mcp servers",
"feature flags enabled",
"enabled feature flags",
"feature flag overrides",
"legacy feature flag",
"config.toml",
"config.toml parse",
"config.toml read",
],
&[],
);
out
}
fn state_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> {
let mut out = Vec::new();
push_row_if_present(&mut out, parsed, "CODEX_HOME", "CODEX_HOME");
push_row_if_present(&mut out, parsed, "log dir", "log dir");
push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home");
push_database_row(&mut out, parsed, "state DB");
push_database_row(&mut out, parsed, "log DB");
for (source, label) in [
("active rollout files", "active rollouts"),
("archived rollout files", "archived rollouts"),
] {
if let Some(value) = value(parsed, source) {
out.push(HumanDetail::Row {
label: label.to_string(),
value: rollout_summary(value).unwrap_or_else(|| value.to_string()),
expected: None,
});
}
}
push_remaining(
&mut out,
parsed,
&[
"CODEX_HOME",
"log dir",
"sqlite home",
"state DB",
"log DB",
"state DB integrity",
"log DB integrity",
"active rollout files",
"archived rollout files",
],
&[],
);
out
}
fn generic_details(parsed: &[ParsedDetail]) -> Vec<HumanDetail> {
parsed
.iter()
.map(|detail| {
if detail.label.is_empty() {
HumanDetail::Bullet(detail.value.clone())
} else {
HumanDetail::Row {
label: display_label(&detail.label),
value: detail.value.clone(),
expected: None,
}
}
})
.collect()
}
fn push_feature_flags(
out: &mut Vec<HumanDetail>,
parsed: &[ParsedDetail],
options: HumanOutputOptions,
) {
let enabled_count = value(parsed, "feature flags enabled")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or_default();
let overrides = list_items(value(parsed, "feature flag overrides").unwrap_or("none"));
let override_count = overrides.len();
let hint = if !options.show_all && enabled_count > 0 {
" (full list with --all)"
} else {
""
};
out.push(HumanDetail::Row {
label: "feature flags".to_string(),
value: format!("{enabled_count} enabled · {override_count} overridden{hint}"),
expected: None,
});
if !overrides.is_empty() {
push_list_row(out, "overrides", &override_names(&overrides), options);
}
if options.show_all {
let enabled = list_items(value(parsed, "enabled feature flags").unwrap_or("none"));
if !enabled.is_empty() {
push_list_row(out, "enabled flags", &enabled, options);
}
}
}
fn push_list_row(
out: &mut Vec<HumanDetail>,
label: &str,
items: &[String],
options: HumanOutputOptions,
) {
let limit = if options.show_all {
items.len()
} else {
items.len().min(LIST_LIMIT)
};
let mut value = items
.iter()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if limit < items.len() {
value.push_str(", … (full list with --all)");
}
out.push(HumanDetail::Row {
label: label.to_string(),
value,
expected: None,
});
}
fn push_database_row(out: &mut Vec<HumanDetail>, parsed: &[ParsedDetail], label: &str) {
let Some(path) = value(parsed, label) else {
return;
};
let integrity = value(parsed, &format!("{label} integrity"));
let value = integrity.map_or_else(
|| path.to_string(),
|integrity| format!("{path} · integrity {integrity}"),
);
out.push(HumanDetail::Row {
label: label.to_string(),
value,
expected: None,
});
}
fn push_row_if_present(
out: &mut Vec<HumanDetail>,
parsed: &[ParsedDetail],
source_label: &str,
display_label: &str,
) {
if let Some(value) = value(parsed, source_label) {
out.push(HumanDetail::Row {
label: display_label.to_string(),
value: value.to_string(),
expected: None,
});
}
}
fn push_remaining(
out: &mut Vec<HumanDetail>,
parsed: &[ParsedDetail],
consumed_labels: &[&str],
consumed_prefixes: &[&str],
) {
for detail in parsed {
if detail.value == "ignored inherited package-manager launch env for cargo-built binary" {
continue;
}
if consumed_labels.contains(&detail.label.as_str())
|| consumed_prefixes
.iter()
.any(|prefix| detail.label.starts_with(prefix))
{
continue;
}
if detail.label.is_empty() {
out.push(HumanDetail::Bullet(detail.value.clone()));
} else {
out.push(HumanDetail::Row {
label: display_label(&detail.label),
value: detail.value.clone(),
expected: None,
});
}
}
}
fn humanize_detail(detail: HumanDetail, options: HumanOutputOptions) -> HumanDetail {
match detail {
HumanDetail::Row {
label,
value,
expected,
} => HumanDetail::Row {
label,
value: humanize_value(&value, options),
expected,
},
HumanDetail::Continuation(value) => {
HumanDetail::Continuation(humanize_value(&value, options))
}
HumanDetail::Bullet(value) => HumanDetail::Bullet(humanize_value(&value, options)),
HumanDetail::Remedy(value) => HumanDetail::Remedy(value),
}
}
fn attach_issue_metadata(detail: HumanDetail, check: &DoctorCheck) -> HumanDetail {
let HumanDetail::Row {
label,
value,
expected,
} = detail
else {
return detail;
};
let expected = expected.or_else(|| issue_expected_for_label(check, &label));
HumanDetail::Row {
label,
value,
expected,
}
}
fn issue_expected_for_label(check: &DoctorCheck, label: &str) -> Option<String> {
check
.issues
.iter()
.find(|issue| {
issue
.fields
.iter()
.any(|field| display_label(field) == label || field == label)
})
.and_then(|issue| issue.expected.clone())
}
fn issue_remedies(check: &DoctorCheck) -> Vec<HumanDetail> {
let mut seen = BTreeSet::new();
check
.issues
.iter()
.filter_map(|issue| issue.remedy.as_ref())
.filter(|remedy| seen.insert((*remedy).clone()))
.cloned()
.map(HumanDetail::Remedy)
.collect()
}
fn humanize_value(value: &str, _options: HumanOutputOptions) -> String {
if looks_like_path(value) {
return shorten_path_prefix(value);
}
if let Some(timestamp) = humanize_timestamp(value) {
return timestamp;
}
value.to_string()
}
fn humanize_timestamp(value: &str) -> Option<String> {
if value.len() < 17 || !value.ends_with('Z') {
return None;
}
let (date, time) = value.split_once('T')?;
let hour_minute = time.get(..5)?;
Some(format!("{date} {hour_minute} UTC"))
}
fn shorten_path_prefix(value: &str) -> String {
let (path, suffix) = value.split_once(" (").map_or_else(
|| (value, String::new()),
|(path, suffix)| (path, format!(" ({suffix}")),
);
let home_shortened = home_shortened_path(path);
let shortened = middle_truncate(&home_shortened, PATH_LIMIT);
format!("{shortened}{suffix}")
}
fn home_shortened_path(path: &str) -> String {
let Some(home) = env::var_os("HOME").and_then(|home| home.into_string().ok()) else {
return path.to_string();
};
if path == home {
"~".to_string()
} else {
path.strip_prefix(&format!("{home}/"))
.map_or_else(|| path.to_string(), |tail| format!("~/{tail}"))
}
}
fn middle_truncate(value: &str, max_chars: usize) -> String {
let char_count = value.chars().count();
if char_count <= max_chars {
return value.to_string();
}
let head_len = max_chars / 2;
let tail_len = max_chars.saturating_sub(head_len + 1);
let head = value.chars().take(head_len).collect::<String>();
let tail = value
.chars()
.rev()
.take(tail_len)
.collect::<String>()
.chars()
.rev()
.collect::<String>();
format!("{head}…{tail}")
}
fn looks_like_path(value: &str) -> bool {
value.starts_with('/')
|| value.starts_with("~/")
|| value.starts_with("./")
|| value.starts_with("../")
}
fn numbered_values(parsed: &[ParsedDetail], prefix: &str) -> Vec<String> {
parsed
.iter()
.filter(|detail| detail.label.starts_with(prefix))
.map(|detail| detail.value.clone())
.collect()
}
fn value<'a>(parsed: &'a [ParsedDetail], label: &str) -> Option<&'a str> {
parsed
.iter()
.find(|detail| detail.label == label)
.map(|detail| detail.value.as_str())
}
fn display_label(label: &str) -> String {
match label {
"codex-linux-sandbox helper" => "linux helper",
"optional reachability failed" => "optional reachability",
"check for update on startup" => "startup update check",
other => other,
}
.to_string()
}
fn list_items(value: &str) -> Vec<String> {
if is_falsy(value) {
return Vec::new();
}
value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect()
}
fn override_names(items: &[String]) -> Vec<String> {
items
.iter()
.map(|item| item.split_once('=').map_or(item.as_str(), |(name, _)| name))
.map(str::to_string)
.collect()
}
fn yes_no(value: &str) -> &'static str {
if value == "true" { "yes" } else { "no" }
}
pub(super) fn is_falsy(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "false" | "none" | "not set" | "unknown" | "missing" | "absent" | "no" | "—" | "-"
)
}
+139
View File
@@ -0,0 +1,139 @@
use std::io;
use std::io::IsTerminal;
use std::io::Write;
use std::sync::Mutex;
use std::time::Duration;
use super::CheckStatus;
/// Receives check lifecycle events while doctor builds the final report.
///
/// Progress implementations must not write to stdout. The final report owns
/// stdout so JSON and redirected human reports stay clean.
pub(super) trait DoctorProgress: Send + Sync {
fn begin(&self, label: &'static str);
fn heartbeat(&self, label: &'static str, elapsed: Duration);
fn finish(&self, label: &'static str, status: CheckStatus);
fn settle(&self);
}
/// Selects the progress implementation for the current output mode.
///
/// JSON output is always quiet so stdout remains valid JSON. Human output uses a
/// transient stderr line only for interactive terminals, then clears it before
/// the final report is printed.
pub(super) fn doctor_progress(json: bool) -> std::sync::Arc<dyn DoctorProgress> {
if should_show_progress(
json,
std::env::var("TERM").ok().as_deref(),
io::stderr().is_terminal(),
) {
std::sync::Arc::new(StderrProgress::default())
} else {
std::sync::Arc::new(QuietProgress)
}
}
fn should_show_progress(json: bool, term: Option<&str>, stderr_is_tty: bool) -> bool {
!json && stderr_is_tty && term != Some("dumb")
}
struct QuietProgress;
impl DoctorProgress for QuietProgress {
fn begin(&self, _label: &'static str) {}
fn heartbeat(&self, _label: &'static str, _elapsed: Duration) {}
fn finish(&self, _label: &'static str, _status: CheckStatus) {}
fn settle(&self) {}
}
#[derive(Default)]
struct StderrProgress {
state: Mutex<StderrProgressState>,
}
#[derive(Default)]
struct StderrProgressState {
wrote_line: bool,
}
impl StderrProgress {
fn render(&self, message: String) {
let Ok(mut state) = self.state.lock() else {
return;
};
let mut stderr = io::stderr().lock();
let _ = write!(stderr, "\r\x1b[2K{message}");
let _ = stderr.flush();
state.wrote_line = true;
}
}
impl DoctorProgress for StderrProgress {
fn begin(&self, label: &'static str) {
self.render(format!("Checking {label}..."));
}
fn heartbeat(&self, label: &'static str, elapsed: Duration) {
self.render(format!("Still checking {label}... {}s", elapsed.as_secs()));
}
fn finish(&self, _label: &'static str, _status: CheckStatus) {}
fn settle(&self) {
let Ok(mut state) = self.state.lock() else {
return;
};
if !state.wrote_line {
return;
}
let mut stderr = io::stderr().lock();
let _ = write!(stderr, "\r\x1b[2K");
let _ = stderr.flush();
state.wrote_line = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_is_quiet_for_json() {
assert!(!should_show_progress(
/*json*/ true,
Some("xterm-256color"),
/*stderr_is_tty*/ true,
));
}
#[test]
fn progress_is_quiet_for_non_tty() {
assert!(!should_show_progress(
/*json*/ false,
Some("xterm-256color"),
/*stderr_is_tty*/ false,
));
}
#[test]
fn progress_is_quiet_for_dumb_terminal() {
assert!(!should_show_progress(
/*json*/ false,
Some("dumb"),
/*stderr_is_tty*/ true,
));
}
#[test]
fn progress_is_shown_for_human_tty_output() {
assert!(should_show_progress(
/*json*/ false,
Some("xterm-256color"),
/*stderr_is_tty*/ true,
));
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Captures how this Codex process was launched.
//!
//! Runtime diagnostics answer provenance questions that are hard to infer from
//! user reports: which binary is running, which install channel it resembles,
//! which platform it targets, and whether the search command comes from bundled
//! standalone resources or from PATH.
use std::env;
use std::process::Command;
use codex_install_context::InstallContext;
use super::CheckStatus;
use super::DoctorCheck;
use super::describe_install_context;
use super::doctor_install_context;
use super::push_path_detail;
/// Builds the process provenance row for the current Codex executable.
///
/// This check is informational and should not fail on its own; inconsistent
/// install state is reported by the installation and update checks instead.
pub(super) fn runtime_check() -> DoctorCheck {
let current_exe = env::current_exe().ok();
let install_context = doctor_install_context(current_exe.as_deref());
let os = env::consts::OS;
let arch = env::consts::ARCH;
let platform = format!("{os}-{arch}");
let install_method = install_method_name(&install_context);
let mut details = vec![
format!("version: {}", env!("CARGO_PKG_VERSION")),
format!("platform: {platform}"),
format!(
"install method: {}",
describe_install_context(&install_context)
),
format!("commit: {}", build_commit()),
];
push_path_detail(&mut details, "current executable", current_exe.as_deref());
DoctorCheck::new(
"runtime.provenance",
"runtime",
CheckStatus::Ok,
format!("running {install_method} on {platform}"),
)
.details(details)
}
/// Verifies that the search command selected by the install context is usable.
///
/// Standalone installs should point at a bundled ripgrep binary, while local or
/// package-managed installs usually resolve rg from PATH. A warning here means
/// features that depend on file search may degrade even when the CLI launches.
pub(super) fn search_check() -> DoctorCheck {
let current_exe = env::current_exe().ok();
let install_context = doctor_install_context(current_exe.as_deref());
let rg_command = install_context.rg_command();
let provider = search_provider(&install_context);
let mut details = vec![
format!("search command: {}", rg_command.display()),
format!("search provider: {provider}"),
];
let status = if rg_command.components().count() > 1 {
match std::fs::metadata(&rg_command) {
Ok(metadata) if metadata.is_file() => {
details.push("search command readiness: file exists".to_string());
CheckStatus::Ok
}
Ok(_) => {
details.push("search command readiness: path is not a file".to_string());
CheckStatus::Warning
}
Err(err) => {
details.push(format!("search command readiness: {err}"));
CheckStatus::Warning
}
}
} else {
match Command::new(&rg_command).arg("--version").output() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.unwrap_or("rg version unknown")
.to_string();
details.push(format!("search command readiness: {version}"));
CheckStatus::Ok
}
Ok(output) => {
details.push(format!(
"search command readiness: exited with status {}",
output.status
));
CheckStatus::Warning
}
Err(err) => {
details.push(format!("search command readiness: {err}"));
CheckStatus::Warning
}
}
};
let summary = match status {
CheckStatus::Ok => format!("search is OK ({provider})"),
CheckStatus::Warning => "search command could not be verified".to_string(),
CheckStatus::Fail => unreachable!(),
};
let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details);
if status != CheckStatus::Ok {
check = check.remediation("Install ripgrep or repair the bundled standalone resources.");
}
check
}
fn install_method_name(context: &InstallContext) -> &'static str {
match context {
InstallContext::Standalone { .. } => "standalone",
InstallContext::Npm => "npm",
InstallContext::Bun => "bun",
InstallContext::Brew => "brew",
InstallContext::Other => "local build",
}
}
fn search_provider(context: &InstallContext) -> &'static str {
match context {
InstallContext::Standalone {
resources_dir: Some(resources_dir),
..
} if context.rg_command().starts_with(resources_dir) => "bundled",
_ => "system",
}
}
fn build_commit() -> &'static str {
option_env!("CODEX_BUILD_COMMIT")
.or(option_env!("GIT_COMMIT"))
.unwrap_or("unknown")
}
+227
View File
@@ -0,0 +1,227 @@
//! Diagnoses whether Codex update paths target the running installation.
//!
//! Update diagnostics combine cached version metadata, install-channel hints,
//! and bounded latest-version probes. For npm-managed launches, this module also
//! verifies that npm install -g would update the package root that launched the
//! current process, which catches PATH and prefix mismatches before the user runs
//! an update command.
use std::path::Path;
use codex_core::config::Config;
use codex_install_context::InstallContext;
use serde::Deserialize;
use super::CheckStatus;
use super::DoctorCheck;
use super::NpmRootCheck;
use super::doctor_install_context;
use super::doctor_managed_by_npm;
use super::npm_global_root_check;
use super::run_command;
const VERSION_FILE_NAME: &str = "version.json";
const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json";
/// Builds the update-health row for the current installation.
///
/// Network failures while fetching latest-version metadata degrade the row to a
/// warning instead of failing doctor outright; update freshness is useful
/// support context but should not mask more direct install/config failures.
pub(super) fn updates_check(config: &Config) -> DoctorCheck {
let current_exe = std::env::current_exe().ok();
let install_context = doctor_install_context(current_exe.as_deref());
let mut details = vec![
format!(
"check for update on startup: {}",
config.check_for_update_on_startup
),
format!("update action: {}", update_action_label(&install_context)),
];
let version_file = config.codex_home.join(VERSION_FILE_NAME);
push_cached_version_details(&mut details, &version_file);
let mut status = CheckStatus::Ok;
let mut summary = "update configuration is locally consistent".to_string();
let mut remediation = None;
if doctor_managed_by_npm(current_exe.as_deref()) {
match npm_global_root_check() {
NpmRootCheck::Match { package_root } => {
details.push(format!("npm update target: {}", package_root.display()));
}
NpmRootCheck::Mismatch {
running_package_root,
npm_package_root,
} => {
status = CheckStatus::Fail;
summary = "update would target a different npm install".to_string();
details.push(format!(
"running package root: {}",
running_package_root.display()
));
details.push(format!("npm package root: {}", npm_package_root.display()));
remediation = Some(format!(
"Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).",
running_package_root.display(),
npm_package_root.display()
));
}
NpmRootCheck::MissingPackageRoot => {
status = status.max(CheckStatus::Warning);
summary = "npm update target could not be proven".to_string();
remediation = Some(
"Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT."
.to_string(),
);
}
NpmRootCheck::NpmUnavailable(error) => {
status = status.max(CheckStatus::Warning);
summary = "npm update target could not be inspected".to_string();
details.push(format!("npm root -g failed: {error}"));
}
}
}
match fetch_latest_version(&install_context) {
Ok(latest_version) => {
details.push(format!("latest version: {latest_version}"));
if is_newer(&latest_version, env!("CARGO_PKG_VERSION")) == Some(true) {
details.push("latest version status: newer version is available".to_string());
} else {
details.push("latest version status: current version is not older".to_string());
}
}
Err(err) => {
status = status.max(CheckStatus::Warning);
details.push(format!("latest version probe: {err}"));
}
}
let mut check = DoctorCheck::new("updates.status", "updates", status, summary).details(details);
if let Some(remediation) = remediation {
check = check.remediation(remediation);
}
check
}
fn push_cached_version_details(details: &mut Vec<String>, version_file: &Path) {
details.push(format!("version cache: {}", version_file.display()));
match std::fs::read_to_string(version_file) {
Ok(contents) => match serde_json::from_str::<VersionInfo>(&contents) {
Ok(info) => {
details.push(format!("cached latest version: {}", info.latest_version));
if let Some(last_checked_at) = info.last_checked_at {
details.push(format!("last checked at: {last_checked_at}"));
}
if let Some(dismissed_version) = info.dismissed_version {
details.push(format!("dismissed version: {dismissed_version}"));
}
}
Err(err) => details.push(format!("version cache parse: {err}")),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
details.push("version cache: missing".to_string());
}
Err(err) => details.push(format!("version cache read: {err}")),
}
}
fn update_action_label(context: &InstallContext) -> &'static str {
match context {
InstallContext::Npm => "npm install -g @openai/codex",
InstallContext::Bun => "bun install -g @openai/codex",
InstallContext::Brew => "brew upgrade --cask codex",
InstallContext::Standalone { .. } => "standalone installer",
InstallContext::Other => "manual or unknown",
}
}
fn fetch_latest_version(context: &InstallContext) -> Result<String, String> {
match context {
InstallContext::Brew => fetch_homebrew_cask_version(),
InstallContext::Npm
| InstallContext::Bun
| InstallContext::Standalone { .. }
| InstallContext::Other => fetch_latest_github_release_version(),
}
}
fn fetch_latest_github_release_version() -> Result<String, String> {
#[derive(Deserialize)]
struct ReleaseInfo {
tag_name: String,
}
let info = http_get_json::<ReleaseInfo>(GITHUB_LATEST_RELEASE_URL)?;
info.tag_name
.strip_prefix("rust-v")
.map(str::to_string)
.ok_or_else(|| format!("failed to parse latest tag {}", info.tag_name))
}
fn fetch_homebrew_cask_version() -> Result<String, String> {
#[derive(Deserialize)]
struct HomebrewCaskInfo {
version: String,
}
http_get_json::<HomebrewCaskInfo>(HOMEBREW_CASK_API_URL).map(|info| info.version)
}
fn http_get_json<T>(url: &str) -> Result<T, String>
where
T: for<'de> Deserialize<'de>,
{
let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?;
serde_json::from_str::<T>(&body).map_err(|err| err.to_string())
}
fn is_newer(latest: &str, current: &str) -> Option<bool> {
match (parse_version(latest), parse_version(current)) {
(Some(latest), Some(current)) => Some(latest > current),
(Some(_), None) | (None, Some(_)) | (None, None) => None,
}
}
fn parse_version(value: &str) -> Option<(u64, u64, u64)> {
let mut parts = value.trim().split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch = parts.next()?.parse::<u64>().ok()?;
Some((major, minor, patch))
}
#[derive(Deserialize)]
struct VersionInfo {
latest_version: String,
#[serde(default)]
last_checked_at: Option<String>,
#[serde(default)]
dismissed_version: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_compares_plain_semver() {
assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true));
assert_eq!(is_newer("1.2.3", "1.2.4"), Some(false));
assert_eq!(is_newer("1.2.3-beta.1", "1.2.2"), None);
}
#[test]
fn update_action_labels_install_contexts() {
assert_eq!(
update_action_label(&InstallContext::Npm),
"npm install -g @openai/codex"
);
assert_eq!(
update_action_label(&InstallContext::Other),
"manual or unknown"
);
}
}
+492 -47
View File
@@ -37,6 +37,7 @@ use codex_tui::ExitReason;
use codex_tui::UpdateAction;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_cli::ProfileV2Name;
use owo_colors::OwoColorize;
use std::io::IsTerminal;
use std::path::PathBuf;
@@ -46,19 +47,25 @@ use supports_color::Stream;
mod app_cmd;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod desktop_app;
mod doctor;
mod marketplace_cmd;
mod mcp_cmd;
mod plugin_cmd;
#[cfg(not(windows))]
mod wsl_paths;
use crate::marketplace_cmd::MarketplaceCli;
use crate::mcp_cmd::McpCli;
use crate::plugin_cmd::PluginCli;
use crate::plugin_cmd::PluginSubcommand;
use doctor::DoctorCommand;
use codex_config::LoaderOverrides;
use codex_core::build_models_manager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_core::config::resolve_profile_v2_config_path;
use codex_features::FEATURES;
use codex_features::Stage;
use codex_features::is_known_feature_key;
@@ -109,7 +116,7 @@ enum Subcommand {
Exec(ExecCli),
/// Run a code review non-interactively.
Review(ReviewArgs),
Review(ReviewCommand),
/// Manage login.
Login(LoginCommand),
@@ -124,13 +131,13 @@ enum Subcommand {
Plugin(PluginCli),
/// Start Codex as an MCP server (stdio).
McpServer,
McpServer(McpServerCommand),
/// [experimental] Run the app server or related tooling.
AppServer(AppServerCommand),
/// [experimental] Ensure the app-server daemon is running with remote control enabled.
RemoteControl,
/// [experimental] Manage the app-server daemon with remote control enabled.
RemoteControl(RemoteControlCommand),
/// Launch the Codex desktop app (opens the app installer if missing).
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -142,6 +149,9 @@ enum Subcommand {
/// Update Codex to the latest version.
Update,
/// Diagnose local Codex installation, config, auth, and runtime health.
Doctor(DoctorCommand),
/// Run commands within a Codex-provided sandbox.
Sandbox(SandboxArgs),
@@ -181,22 +191,6 @@ enum Subcommand {
Features(FeaturesCli),
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin")]
struct PluginCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
subcommand: PluginSubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum PluginSubcommand {
/// Manage plugin marketplaces for Codex.
Marketplace(MarketplaceCli),
}
#[derive(Debug, Parser)]
struct CompletionCommand {
/// Shell to generate completions for
@@ -266,6 +260,23 @@ struct DebugModelsCommand {
bundled: bool,
}
#[derive(Debug, Parser)]
struct ReviewCommand {
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
#[clap(flatten)]
args: ReviewArgs,
}
#[derive(Debug, Parser)]
struct McpServerCommand {
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
}
#[derive(Debug, Parser)]
struct DebugTraceReduceCommand {
/// Trace bundle directory containing manifest.json and trace.jsonl.
@@ -419,6 +430,10 @@ struct AppServerCommand {
#[command(subcommand)]
subcommand: Option<AppServerSubcommand>,
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`.
#[arg(
@@ -541,6 +556,21 @@ struct AppServerBootstrapCommand {
remote_control: bool,
}
#[derive(Debug, Args)]
struct RemoteControlCommand {
#[command(subcommand)]
subcommand: Option<RemoteControlSubcommand>,
}
#[derive(Debug, Clone, Copy, clap::Subcommand)]
enum RemoteControlSubcommand {
/// Start the app-server daemon with remote control enabled.
Start,
/// Stop the app-server daemon.
Stop,
}
#[derive(Debug, Args)]
struct GenerateTsCommand {
/// Output directory where .ts files will be written
@@ -810,6 +840,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_config_overrides.raw_overrides.extend(toggle_overrides);
let root_remote = remote.remote;
let root_remote_auth_token_env = remote.remote_auth_token_env;
let root_strict_config = interactive.strict_config;
reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?;
if let Some(subcommand) = subcommand.as_ref() {
profile_v2_for_subcommand(&interactive, subcommand)?;
}
match subcommand {
None => {
@@ -835,33 +870,46 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
exec_cli
.shared
.inherit_exec_root_options(&interactive.shared);
exec_cli.strict_config |= root_strict_config;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
codex_exec::run_main(exec_cli, arg0_paths.clone()).await?;
}
Some(Subcommand::Review(review_args)) => {
Some(Subcommand::Review(ReviewCommand {
strict_config,
args: review_args,
})) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"review",
)?;
let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?;
exec_cli
.shared
.inherit_exec_root_options(&interactive.shared);
exec_cli.command = Some(ExecCommand::Review(review_args));
exec_cli.strict_config = strict_config || root_strict_config;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
);
codex_exec::run_main(exec_cli, arg0_paths.clone()).await?;
}
Some(Subcommand::McpServer) => {
Some(Subcommand::McpServer(McpServerCommand { strict_config })) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"mcp-server",
)?;
codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?;
codex_mcp_server::run_main(
arg0_paths.clone(),
root_config_overrides,
strict_config || root_strict_config,
)
.await?;
}
Some(Subcommand::Mcp(mut mcp_cli)) => {
reject_remote_mode_for_subcommand(
@@ -885,20 +933,41 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
} = plugin_cli;
prepend_config_flags(&mut config_overrides, root_config_overrides.clone());
match subcommand {
PluginSubcommand::Add(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_add(overrides, args).await?;
}
PluginSubcommand::List(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_list(overrides, args).await?;
}
PluginSubcommand::Marketplace(mut marketplace_cli) => {
prepend_config_flags(&mut marketplace_cli.config_overrides, config_overrides);
marketplace_cli.run().await?;
}
PluginSubcommand::Remove(args) => {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
plugin_cmd::run_plugin_remove(overrides, args).await?;
}
}
}
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
subcommand,
strict_config: app_server_strict_config,
listen,
remote_control,
analytics_default_enabled,
auth,
} = app_server_cli;
let strict_config = app_server_strict_config || root_strict_config;
reject_strict_config_for_app_server_subcommand(strict_config, subcommand.as_ref())?;
reject_remote_mode_for_app_server_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
@@ -915,7 +984,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
codex_app_server::run_main_with_transport_options(
arg0_paths.clone(),
root_config_overrides,
codex_config::LoaderOverrides::default(),
LoaderOverrides::default(),
strict_config,
analytics_default_enabled,
transport,
codex_protocol::protocol::SessionSource::VSCode,
@@ -991,14 +1061,25 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
}
}
}
Some(Subcommand::RemoteControl) => {
Some(Subcommand::RemoteControl(remote_control_cli)) => {
let subcommand_name = remote_control_subcommand_name(&remote_control_cli);
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"remote-control",
subcommand_name,
)?;
let output = codex_app_server_daemon::ensure_remote_control_started().await?;
println!("{}", serde_json::to_string(&output)?);
match remote_control_cli
.subcommand
.unwrap_or(RemoteControlSubcommand::Start)
{
RemoteControlSubcommand::Start => {
let output = codex_app_server_daemon::ensure_remote_control_started().await?;
println!("{}", serde_json::to_string(&output)?);
}
RemoteControlSubcommand::Stop => {
print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?;
}
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
Some(Subcommand::App(app_cli)) => {
@@ -1135,6 +1216,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
)?;
run_update_command()?;
}
Some(Subcommand::Doctor(doctor_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"doctor",
)?;
doctor::run_doctor(
doctor_cli,
root_config_overrides.clone(),
&interactive,
&arg0_paths,
)
.await?;
}
Some(Subcommand::Cloud(mut cloud_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
@@ -1320,11 +1415,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
..Default::default()
};
let config = Config::load_with_cli_overrides_and_harness_overrides(
cli_kv_overrides,
overrides,
)
.await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let mut rows = Vec::with_capacity(FEATURES.len());
let mut name_width = 0;
let mut stage_width = 0;
@@ -1364,6 +1459,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
Ok(())
}
fn profile_v2_for_subcommand<'a>(
interactive: &'a TuiCli,
subcommand: &Subcommand,
) -> anyhow::Result<Option<&'a ProfileV2Name>> {
let Some(profile_v2) = interactive.config_profile_v2.as_ref() else {
return Ok(None);
};
match subcommand {
Subcommand::Exec(_)
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Fork(_)
| Subcommand::Debug(DebugCommand {
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile-v2 only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`."
),
}
}
async fn run_exec_server_command(
cmd: ExecServerCommand,
arg0_paths: &Arg0DispatchPaths,
@@ -1422,6 +1539,22 @@ async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyho
Ok(())
}
fn loader_overrides_for_profile(
profile_v2: Option<&ProfileV2Name>,
) -> anyhow::Result<LoaderOverrides> {
match profile_v2 {
Some(profile_v2) => {
let codex_home = find_codex_home()?;
Ok(LoaderOverrides {
user_config_path: Some(resolve_profile_v2_config_path(&codex_home, profile_v2)),
user_config_profile: Some(profile_v2.clone()),
..Default::default()
})
}
None => Ok(LoaderOverrides::default()),
}
}
fn maybe_print_under_development_feature_warning(
codex_home: &std::path::Path,
interactive: &TuiCli,
@@ -1464,6 +1597,7 @@ async fn run_debug_prompt_input_command(
interactive: TuiCli,
arg0_paths: Arg0DispatchPaths,
) -> anyhow::Result<()> {
let loader_overrides = loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?;
let shared = interactive.shared.into_inner();
let mut cli_kv_overrides = root_config_overrides
.parse_overrides()
@@ -1500,8 +1634,12 @@ async fn run_debug_prompt_input_command(
additional_writable_roots: shared.add_dir,
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.loader_overrides(loader_overrides)
.build()
.await?;
let mut input = shared
.images
@@ -1532,7 +1670,10 @@ async fn run_debug_models_command(
let cli_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(cli_overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.build()
.await?;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await;
let models_manager = build_models_manager(&config, auth_manager);
@@ -1557,8 +1698,11 @@ async fn run_debug_clear_memories_command(
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let state_path = state_db_path(config.sqlite_home.as_path());
let mut cleared_state_db = false;
@@ -1593,9 +1737,7 @@ fn prepend_config_flags(
subcommand_config_overrides: &mut CliConfigOverrides,
cli_config_overrides: CliConfigOverrides,
) {
subcommand_config_overrides
.raw_overrides
.splice(0..0, cli_config_overrides.raw_overrides);
subcommand_config_overrides.prepend_root_overrides(cli_config_overrides);
}
fn reject_remote_mode_for_subcommand(
@@ -1616,12 +1758,114 @@ fn reject_remote_mode_for_subcommand(
Ok(())
}
fn reject_root_strict_config_for_subcommand(
strict_config: bool,
subcommand: &Option<Subcommand>,
) -> anyhow::Result<()> {
if !strict_config {
return Ok(());
}
match unsupported_subcommand_name_for_strict_config(subcommand) {
Some(subcommand_name) => {
reject_strict_config_for_unsupported_subcommand(strict_config, subcommand_name)
}
None => Ok(()),
}
}
/// Return the selected subcommand name when a root-level `--strict-config`
/// flag should be rejected after parsing.
///
/// `--strict-config` is parsed on the root interactive CLI so commands like
/// `codex --strict-config` continue to work for the TUI and for wrappers that
/// forward root options into another command shape. Clap will still accept that
/// root flag before the dispatcher knows which subcommand the user selected, so
/// unsupported subcommands need an explicit post-parse reject path.
///
/// `Some(...)` returns the user-facing command name fragment to embed in the
/// rejection error, such as `cloud` or `app-server proxy`. `None` means the
/// selected command is allowed to inherit root `--strict-config`.
fn unsupported_subcommand_name_for_strict_config(
subcommand: &Option<Subcommand>,
) -> Option<&'static str> {
match subcommand {
None
| Some(Subcommand::Exec(_))
| Some(Subcommand::Review(_))
| Some(Subcommand::McpServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Fork(_))
| Some(Subcommand::Doctor(_)) => None,
Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None,
Some(Subcommand::AppServer(app_server)) => {
Some(app_server_subcommand_name(app_server.subcommand.as_ref()))
}
Some(Subcommand::RemoteControl(remote_control)) => {
Some(remote_control_subcommand_name(remote_control))
}
Some(Subcommand::Mcp(_)) => Some("mcp"),
Some(Subcommand::Plugin(_)) => Some("plugin"),
#[cfg(any(target_os = "macos", target_os = "windows"))]
Some(Subcommand::App(_)) => Some("app"),
Some(Subcommand::Login(_)) => Some("login"),
Some(Subcommand::Logout(_)) => Some("logout"),
Some(Subcommand::Completion(_)) => Some("completion"),
Some(Subcommand::Update) => Some("update"),
Some(Subcommand::Cloud(_)) => Some("cloud"),
Some(Subcommand::Sandbox(_)) => Some("sandbox"),
Some(Subcommand::Debug(_)) => Some("debug"),
Some(Subcommand::Execpolicy(_)) => Some("execpolicy"),
Some(Subcommand::Apply(_)) => Some("apply"),
Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"),
Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"),
Some(Subcommand::ExecServer(_)) => Some("exec-server"),
Some(Subcommand::Features(_)) => Some("features"),
}
}
fn reject_strict_config_for_app_server_subcommand(
strict_config: bool,
subcommand: Option<&AppServerSubcommand>,
) -> anyhow::Result<()> {
if subcommand.is_none() {
return Ok(());
}
reject_strict_config_for_unsupported_subcommand(
strict_config,
app_server_subcommand_name(subcommand),
)
}
fn reject_strict_config_for_unsupported_subcommand(
strict_config: bool,
subcommand: &str,
) -> anyhow::Result<()> {
if strict_config {
anyhow::bail!("`--strict-config` is not supported for `codex {subcommand}`");
}
Ok(())
}
fn reject_remote_mode_for_app_server_subcommand(
remote: Option<&str>,
remote_auth_token_env: Option<&str>,
subcommand: Option<&AppServerSubcommand>,
) -> anyhow::Result<()> {
let subcommand_name = match subcommand {
let subcommand_name = app_server_subcommand_name(subcommand);
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
}
fn remote_control_subcommand_name(command: &RemoteControlCommand) -> &'static str {
match command.subcommand {
None => "remote-control",
Some(RemoteControlSubcommand::Start) => "remote-control start",
Some(RemoteControlSubcommand::Stop) => "remote-control stop",
}
}
fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str {
match subcommand {
None => "app-server",
Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand {
AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap",
@@ -1643,8 +1887,7 @@ fn reject_remote_mode_for_app_server_subcommand(
Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => {
"app-server generate-internal-json-schema"
}
};
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
}
}
async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> {
@@ -1816,6 +2059,7 @@ fn finalize_fork_interactive(
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
let TuiCli {
shared,
strict_config,
approval_policy,
web_search,
prompt,
@@ -1831,6 +2075,9 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
if web_search {
interactive.web_search = true;
}
if strict_config {
interactive.strict_config = true;
}
if let Some(prompt) = prompt {
// Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state.
interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
@@ -1913,6 +2160,49 @@ mod tests {
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
fn profile_v2_for_args(args: &[&str]) -> anyhow::Result<Option<String>> {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let Some(subcommand) = cli.subcommand.as_ref() else {
return Ok(cli
.interactive
.config_profile_v2
.as_ref()
.map(std::string::ToString::to_string));
};
Ok(profile_v2_for_subcommand(&cli.interactive, subcommand)?.map(ToString::to_string))
}
#[test]
fn profile_v2_is_rejected_for_config_management_subcommands() {
assert!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "features", "list"]).is_err()
);
}
#[test]
fn profile_v2_is_allowed_for_runtime_subcommands() {
assert_eq!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "resume"])
.expect("resume supports profile-v2")
.as_deref(),
Some("work")
);
assert_eq!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "debug", "prompt-input"])
.expect("debug prompt-input supports profile-v2")
.as_deref(),
Some("work")
);
}
#[test]
fn profile_v2_rejects_non_plain_names_at_parse_time() {
assert!(
MultitoolCli::try_parse_from(["codex", "--profile-v2", "nested/work", "resume"])
.is_err()
);
}
#[test]
fn exec_resume_last_accepts_prompt_positional() {
let cli =
@@ -2053,6 +2343,7 @@ mod tests {
for (subcommand, usage) in [
("add", "Usage: codex plugin marketplace add"),
("list", "Usage: codex plugin marketplace list"),
("upgrade", "Usage: codex plugin marketplace upgrade"),
("remove", "Usage: codex plugin marketplace remove"),
] {
@@ -2079,6 +2370,45 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_add_parses_under_plugin() {
let cli = MultitoolCli::try_parse_from([
"codex",
"plugin",
"add",
"sample",
"--marketplace",
"debug",
])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_list_parses_under_plugin() {
let cli =
MultitoolCli::try_parse_from(["codex", "plugin", "list", "--marketplace", "debug"])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn plugin_remove_parses_under_plugin() {
let cli = MultitoolCli::try_parse_from([
"codex",
"plugin",
"remove",
"sample",
"--marketplace",
"debug",
])
.expect("parse");
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
}
#[test]
fn update_parses_as_update_subcommand() {
let cli = MultitoolCli::try_parse_from(["codex", "update"]).expect("parse");
@@ -2316,8 +2646,11 @@ mod tests {
"gpt-5.1-test",
"-p",
"my-profile",
"--profile-v2",
"my-config",
"-C",
"/tmp",
"--strict-config",
"-i",
"/tmp/a.png,/tmp/b.png",
]
@@ -2327,6 +2660,7 @@ mod tests {
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert!(interactive.oss);
assert_eq!(interactive.config_profile.as_deref(), Some("my-profile"));
assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config"));
assert_matches!(
interactive.sandbox_mode,
Some(codex_utils_cli::SandboxModeCliArg::WorkspaceWrite)
@@ -2340,6 +2674,7 @@ mod tests {
Some(std::path::Path::new("/tmp"))
);
assert!(interactive.web_search);
assert!(interactive.strict_config);
let has_a = interactive
.images
.iter()
@@ -2434,11 +2769,87 @@ mod tests {
assert!(app_server.analytics_default_enabled);
}
#[test]
fn strict_config_parses_for_supported_commands() {
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config"]).expect("parse");
assert!(cli.interactive.strict_config);
let cli = MultitoolCli::try_parse_from(["codex", "mcp-server", "--strict-config"])
.expect("parse");
assert_matches!(
cli.subcommand,
Some(Subcommand::McpServer(McpServerCommand {
strict_config: true,
}))
);
let cli =
MultitoolCli::try_parse_from(["codex", "review", "--strict-config", "--uncommitted"])
.expect("parse");
assert_matches!(
cli.subcommand,
Some(Subcommand::Review(ReviewCommand {
strict_config: true,
..
}))
);
}
#[test]
fn root_strict_config_is_rejected_for_unsupported_subcommands() {
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "mcp", "list"])
.expect("parse");
let err = reject_root_strict_config_for_subcommand(
cli.interactive.strict_config,
&cli.subcommand,
)
.expect_err("mcp should not support root --strict-config");
assert_eq!(
err.to_string(),
"`--strict-config` is not supported for `codex mcp`"
);
let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "remote-control"])
.expect("parse");
let err = reject_root_strict_config_for_subcommand(
cli.interactive.strict_config,
&cli.subcommand,
)
.expect_err("remote-control should not support root --strict-config");
assert_eq!(
err.to_string(),
"`--strict-config` is not supported for `codex remote-control`"
);
}
#[test]
fn app_server_subcommands_reject_strict_config() {
let app_server =
app_server_from_args(["codex", "app-server", "--strict-config", "proxy"].as_ref());
let err = reject_strict_config_for_app_server_subcommand(
app_server.strict_config,
app_server.subcommand.as_ref(),
)
.expect_err("app-server proxy should not support --strict-config");
assert_eq!(
err.to_string(),
"`--strict-config` is not supported for `codex app-server proxy`"
);
}
#[test]
fn reject_remote_flag_for_remote_control() {
let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"])
.expect("parse");
assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl));
assert_matches!(
cli.subcommand,
Some(Subcommand::RemoteControl(RemoteControlCommand {
subcommand: None
}))
);
let err = reject_remote_mode_for_subcommand(
cli.remote.remote.as_deref(),
@@ -2880,4 +3291,38 @@ mod tests {
.expect_err("feature should be rejected");
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_unknown_enable_errors() {
let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref());
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_unknown_disable_errors() {
let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref());
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_compound_enable_errors() {
let err = strict_config_feature_toggle_error(
["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(),
);
assert_eq!(
err.to_string(),
"Unknown feature flag: multi_agent_v2.subagent_usage_hint_text"
);
}
fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error {
let cli_args = std::iter::once("codex")
.chain(std::iter::once("--strict-config"))
.chain(args.iter().copied());
let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed");
assert!(cli.interactive.strict_config);
cli.feature_toggles
.to_overrides()
.expect_err("feature should be rejected")
}
}
+75 -5
View File
@@ -6,10 +6,13 @@ use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
#[derive(Debug, Parser)]
@@ -24,21 +27,36 @@ pub struct MarketplaceCli {
#[derive(Debug, clap::Subcommand)]
enum MarketplaceSubcommand {
/// Add a local or Git marketplace to the configured marketplace sources.
Add(AddMarketplaceArgs),
/// List configured marketplace names and their local snapshot roots.
List,
/// Refresh configured Git marketplace snapshots.
///
/// Omit MARKETPLACE_NAME to upgrade all configured Git marketplaces.
Upgrade(UpgradeMarketplaceArgs),
/// Remove a configured marketplace source by name.
Remove(RemoveMarketplaceArgs),
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace add")]
#[command(
bin_name = "codex plugin marketplace add",
after_help = "Examples:\n codex plugin marketplace add ./path/to/marketplace\n codex plugin marketplace add owner/repo --ref main\n codex plugin marketplace add https://github.com/owner/repo --sparse plugins/foo"
)]
struct AddMarketplaceArgs {
/// Marketplace source. Supports owner/repo[@ref], HTTP(S) Git URLs, SSH URLs,
/// or local marketplace root directories.
/// Marketplace source: a local path, owner/repo[@ref], HTTPS Git URL, or SSH Git URL.
#[arg(value_name = "SOURCE")]
source: String,
/// Git ref to fetch for Git marketplace sources.
#[arg(long = "ref", value_name = "REF")]
ref_name: Option<String>,
/// Sparse checkout path for Git marketplace sources. Can be repeated.
#[arg(
long = "sparse",
value_name = "PATH",
@@ -48,15 +66,24 @@ struct AddMarketplaceArgs {
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace upgrade")]
#[command(
bin_name = "codex plugin marketplace upgrade",
after_help = "Examples:\n codex plugin marketplace upgrade\n codex plugin marketplace upgrade debug"
)]
struct UpgradeMarketplaceArgs {
/// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces.
#[arg(value_name = "MARKETPLACE_NAME")]
marketplace_name: Option<String>,
}
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace remove")]
#[command(
bin_name = "codex plugin marketplace remove",
after_help = "Example:\n codex plugin marketplace remove debug"
)]
struct RemoveMarketplaceArgs {
/// Configured marketplace name to remove.
#[arg(value_name = "MARKETPLACE_NAME")]
marketplace_name: String,
}
@@ -73,6 +100,7 @@ impl MarketplaceCli {
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::List => run_list(overrides).await?,
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
MarketplaceSubcommand::Remove(args) => run_remove(args).await?,
}
@@ -118,6 +146,48 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
Ok(())
}
async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let configured_marketplaces = config
.config_layer_stack
.get_user_layer()
.and_then(|layer| layer.config.get("marketplaces"))
.and_then(toml::Value::as_table);
let Some(configured_marketplaces) = configured_marketplaces else {
println!("No configured plugin marketplaces.");
return Ok(());
};
if configured_marketplaces.is_empty() {
println!("No configured plugin marketplaces.");
return Ok(());
}
let default_install_root = marketplace_install_root(config.codex_home.as_path());
for (marketplace_name, marketplace) in configured_marketplaces {
if !marketplace.is_table() {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table.");
continue;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}.");
continue;
}
let root = resolve_configured_marketplace_root(
marketplace_name,
marketplace,
default_install_root.as_path(),
)
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<invalid source>".to_string());
println!("{marketplace_name}\t{root}");
}
Ok(())
}
async fn run_upgrade(
overrides: Vec<(String, toml::Value)>,
args: UpgradeMarketplaceArgs,
+405
View File
@@ -0,0 +1,405 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::ConfiguredMarketplace;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsConfigInput;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::MarketplaceListError;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::path::PathBuf;
use crate::marketplace_cmd::MarketplaceCli;
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin")]
pub struct PluginCli {
#[clap(flatten)]
pub config_overrides: CliConfigOverrides,
#[command(subcommand)]
pub subcommand: PluginSubcommand,
}
#[derive(Debug, clap::Subcommand)]
pub enum PluginSubcommand {
/// Install a plugin from a configured marketplace snapshot.
///
/// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with
/// `--marketplace MARKETPLACE`.
Add(AddPluginArgs),
/// List plugins available from configured marketplace snapshots.
List(ListPluginsArgs),
/// Add, list, upgrade, or remove configured plugin marketplaces.
Marketplace(MarketplaceCli),
/// Remove an installed plugin from local config and cache.
///
/// Pass either `PLUGIN@MARKETPLACE` or pass `PLUGIN` with
/// `--marketplace MARKETPLACE`.
Remove(RemovePluginArgs),
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin add",
after_help = "Examples:\n codex plugin add sample@debug\n codex plugin add sample --marketplace debug"
)]
pub struct AddPluginArgs {
/// Plugin selector to install: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace.
#[arg(value_name = "PLUGIN[@MARKETPLACE]")]
plugin: String,
/// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE.
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
marketplace_name: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin list",
after_help = "Examples:\n codex plugin list\n codex plugin list --marketplace debug"
)]
pub struct ListPluginsArgs {
/// Only list plugins from this configured marketplace name.
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
marketplace_name: Option<String>,
}
#[derive(Debug, Parser)]
#[command(
bin_name = "codex plugin remove",
after_help = "Examples:\n codex plugin remove sample@debug\n codex plugin remove sample --marketplace debug"
)]
pub struct RemovePluginArgs {
/// Plugin selector to remove: either PLUGIN@MARKETPLACE or PLUGIN with --marketplace.
#[arg(value_name = "PLUGIN[@MARKETPLACE]")]
plugin: String,
/// Marketplace name to use when PLUGIN does not include @MARKETPLACE.
#[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")]
marketplace_name: Option<String>,
}
pub async fn run_plugin_add(
overrides: Vec<(String, toml::Value)>,
args: AddPluginArgs,
) -> Result<()> {
let PluginCommandContext {
codex_home,
plugins_input,
manager,
} = load_plugin_command_context(overrides).await?;
let PluginSelection {
plugin_name,
marketplace_name,
..
} = parse_plugin_selection(args.plugin, args.marketplace_name)?;
let marketplace = find_marketplace_for_plugin(
&manager,
codex_home.as_path(),
&plugins_input,
&marketplace_name,
&plugin_name,
)?;
let outcome = manager
.install_plugin(PluginInstallRequest {
plugin_name,
marketplace_path: marketplace.path,
})
.await?;
println!(
"Added plugin `{}` from marketplace `{}`.",
outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name
);
println!(
"Installed plugin root: {}",
outcome.installed_path.as_path().display()
);
Ok(())
}
pub async fn run_plugin_list(
overrides: Vec<(String, toml::Value)>,
args: ListPluginsArgs,
) -> Result<()> {
let PluginCommandContext {
codex_home,
plugins_input,
manager,
..
} = load_plugin_command_context(overrides).await?;
let outcome = manager
.list_marketplaces_for_config(&plugins_input, &[])
.context("failed to list marketplace plugins")?;
ensure_configured_marketplace_snapshots_loaded(
codex_home.as_path(),
&plugins_input,
&outcome.errors,
/*marketplace_name*/ None,
)?;
let marketplaces = outcome
.marketplaces
.into_iter()
.filter(|marketplace| {
args.marketplace_name
.as_ref()
.is_none_or(|name| marketplace.name == *name)
})
.collect::<Vec<_>>();
if marketplaces.is_empty() {
if let Some(marketplace_name) = args.marketplace_name {
println!("No plugins found in marketplace `{marketplace_name}`.");
} else {
println!("No marketplace plugins found.");
}
} else {
for marketplace in marketplaces {
println!("Marketplace `{}`", marketplace.name);
println!("Path: {}", marketplace.path.as_path().display());
for plugin in &marketplace.plugins {
let state = if plugin.installed && plugin.enabled {
"installed, enabled"
} else if plugin.installed {
"installed, disabled"
} else {
"not installed"
};
println!(" {} ({state})", plugin.id);
}
}
}
Ok(())
}
pub async fn run_plugin_remove(
overrides: Vec<(String, toml::Value)>,
args: RemovePluginArgs,
) -> Result<()> {
let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?;
let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?;
manager.uninstall_plugin(selection.plugin_key).await?;
println!(
"Removed plugin `{}` from marketplace `{}`.",
selection.plugin_name, selection.marketplace_name
);
Ok(())
}
struct PluginCommandContext {
codex_home: PathBuf,
plugins_input: PluginsConfigInput,
manager: PluginsManager,
}
async fn load_plugin_command_context(
overrides: Vec<(String, toml::Value)>,
) -> Result<PluginCommandContext> {
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let plugins_input = config.plugins_config_input();
let manager = PluginsManager::new(codex_home.to_path_buf());
Ok(PluginCommandContext {
codex_home: codex_home.to_path_buf(),
plugins_input,
manager,
})
}
struct PluginSelection {
plugin_name: String,
marketplace_name: String,
plugin_key: String,
}
impl PluginSelection {
fn from_plugin_id(plugin_id: PluginId) -> Self {
let plugin_key = plugin_id.as_key();
Self {
plugin_name: plugin_id.plugin_name,
marketplace_name: plugin_id.marketplace_name,
plugin_key,
}
}
}
fn parse_plugin_selection(
plugin: String,
marketplace_name: Option<String>,
) -> Result<PluginSelection> {
match (PluginId::parse(&plugin), marketplace_name) {
(Ok(plugin_id), None) => Ok(PluginSelection::from_plugin_id(plugin_id)),
(Ok(plugin_id), Some(marketplace_name)) => {
if plugin_id.marketplace_name != marketplace_name {
bail!(
"plugin id `{}` belongs to marketplace `{}`, but --marketplace specified `{}`",
plugin,
plugin_id.marketplace_name,
marketplace_name
);
}
Ok(PluginSelection::from_plugin_id(plugin_id))
}
(Err(_), Some(marketplace_name)) => Ok(PluginSelection::from_plugin_id(PluginId::new(
plugin,
marketplace_name,
)?)),
(Err(_), None) => {
bail!("plugin requires --marketplace unless passed as <plugin>@<marketplace>")
}
}
}
fn find_marketplace_for_plugin(
manager: &PluginsManager,
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
marketplace_name: &str,
plugin_name: &str,
) -> Result<ConfiguredMarketplace> {
let outcome = manager
.list_marketplaces_for_config(plugins_input, &[])
.context("failed to list marketplace plugins")?;
ensure_configured_marketplace_snapshots_loaded(
codex_home,
plugins_input,
&outcome.errors,
Some(marketplace_name),
)?;
let matches = outcome
.marketplaces
.into_iter()
.filter(|marketplace| marketplace.name == marketplace_name)
.filter(|marketplace| {
marketplace
.plugins
.iter()
.any(|plugin| plugin.name == plugin_name)
})
.collect::<Vec<_>>();
match matches.as_slice() {
[] => bail!("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`"),
[marketplace] => Ok(marketplace.clone()),
_ => bail!(
"plugin `{plugin_name}` in marketplace `{marketplace_name}` matched multiple marketplace roots"
),
}
}
struct ConfiguredMarketplaceSnapshotIssue {
marketplace_name: String,
path: PathBuf,
message: String,
}
fn ensure_configured_marketplace_snapshots_loaded(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Result<()> {
let issues = configured_marketplace_snapshot_issues(
codex_home,
plugins_input,
load_errors,
marketplace_name,
);
if issues.is_empty() {
return Ok(());
}
let issue_lines = issues
.iter()
.map(|issue| {
format!(
"- `{}` at {}: {}",
issue.marketplace_name,
issue.path.display(),
issue.message
)
})
.collect::<Vec<_>>()
.join("\n");
bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}");
}
fn configured_marketplace_snapshot_issues(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Vec<ConfiguredMarketplaceSnapshotIssue> {
let Some(user_layer) = plugins_input.config_layer_stack.get_user_layer() else {
return Vec::new();
};
let Some(configured_marketplaces) = user_layer
.config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
return Vec::new();
};
let default_install_root = marketplace_install_root(codex_home);
let mut manifest_paths = Vec::new();
let mut issues = Vec::new();
for (configured_name, marketplace) in configured_marketplaces {
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
if !marketplace.is_table()
|| validate_plugin_segment(configured_name, "marketplace name").is_err()
{
continue;
}
let Some(root) = resolve_configured_marketplace_root(
configured_name,
marketplace,
&default_install_root,
) else {
continue;
};
match find_marketplace_manifest_path(&root) {
Some(path) => manifest_paths.push((configured_name.clone(), path)),
None => issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: root,
message: "marketplace root does not contain a supported manifest".to_string(),
}),
}
}
for error in load_errors {
if let Some((configured_name, _)) = manifest_paths
.iter()
.find(|(_, path)| path.as_path() == error.path.as_path())
{
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: error.path.to_path_buf(),
message: error.message.clone(),
});
}
}
issues
}
+30
View File
@@ -0,0 +1,30 @@
use std::path::Path;
use anyhow::Result;
use predicates::str::contains;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
#[test]
fn strict_config_rejects_unknown_config_fields_for_app_server() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
foo = "bar"
"#,
)?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["app-server", "--strict-config", "--listen", "off"])
.assert()
.failure()
.stderr(contains("unknown configuration field"));
Ok(())
}
+28
View File
@@ -11,6 +11,34 @@ fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
Ok(cmd)
}
#[test]
fn strict_config_rejects_unknown_config_override() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--strict-config", "-c", "foo=bar", "mcp-server"])
.assert()
.failure()
.stderr(contains("unknown configuration field"));
Ok(())
}
#[test]
fn strict_config_is_not_supported_for_cloud_command() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--strict-config", "-c", "foo=bar", "cloud", "list"])
.assert()
.failure()
.stderr(contains(
"`--strict-config` is not supported for `codex cloud`",
));
Ok(())
}
#[tokio::test]
async fn features_enable_writes_feature_flag_to_config() -> Result<()> {
let codex_home = TempDir::new()?;
+327
View File
@@ -0,0 +1,327 @@
use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}
fn codex_command_in(codex_home: &Path, current_dir: &Path) -> Result<assert_cmd::Command> {
let mut cmd = codex_command(codex_home)?;
cmd.current_dir(current_dir);
Ok(cmd)
}
fn configured_local_marketplace(source: &str) -> MarketplaceConfigUpdate<'_> {
MarketplaceConfigUpdate {
last_updated: "2026-05-06T00:00:00Z",
last_revision: None,
source_type: "local",
source,
ref_name: None,
sparse_paths: &[],
}
}
fn write_plugins_enabled_config(codex_home: &Path) -> Result<()> {
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
)?;
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
std::fs::create_dir_all(source.join(".agents/plugins"))?;
std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?;
std::fs::write(
source.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
std::fs::write(
source.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample","description":"Sample plugin"}"#,
)?;
Ok(())
}
fn setup_local_marketplace() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(source.path())?;
Ok((codex_home, source))
}
fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::create_dir_all(source.path().join(".agents/plugins"))?;
std::fs::write(
source.path().join(".agents/plugins/marketplace.json"),
"{not valid json",
)?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
#[tokio::test]
async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains("debug"))
.stdout(contains(source.path().display().to_string()));
Ok(())
}
#[tokio::test]
async fn plugin_list_shows_plugins_grouped_by_marketplace() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("Marketplace `debug`"))
.stdout(contains("sample@debug (not installed)"));
Ok(())
}
#[tokio::test]
async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<()> {
let (codex_home, source) = setup_unconfigured_local_marketplace()?;
codex_command_in(codex_home.path(), source.path())?
.args(["plugin", "list"])
.assert()
.success()
.stdout(contains("No marketplace plugins found."))
.stdout(predicates::str::is_match("sample@debug").unwrap().not());
Ok(())
}
#[tokio::test]
async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.path().display().to_string()))
.stderr(contains(
"marketplace root does not contain a supported manifest",
));
Ok(())
}
#[tokio::test]
async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success()
.stdout(contains("Added plugin `sample` from marketplace `debug`."));
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
assert!(config.contains("[plugins.\"sample@debug\"]"));
codex_command(codex_home.path())?
.args(["plugin", "remove", "sample", "--marketplace", "debug"])
.assert()
.success()
.stdout(contains(
"Removed plugin `sample` from marketplace `debug`.",
));
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
assert!(!config.contains("[plugins.\"sample@debug\"]"));
Ok(())
}
#[tokio::test]
async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> {
let (codex_home, source) = setup_unconfigured_local_marketplace()?;
codex_command_in(codex_home.path(), source.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"plugin `sample` was not found in marketplace `debug`",
));
Ok(())
}
#[tokio::test]
async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains("invalid marketplace file"))
.stderr(contains("key must be a string"));
Ok(())
}
#[tokio::test]
async fn plugin_add_reinstalls_from_configured_marketplace_snapshot() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success()
.stdout(contains("Added plugin `sample` from marketplace `debug`."));
assert!(
codex_home
.path()
.join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json")
.is_file()
);
Ok(())
}
#[tokio::test]
async fn plugin_remove_works_after_marketplace_is_removed() -> Result<()> {
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample", "--marketplace", "debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "remove", "debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "remove", "sample@debug"])
.assert()
.success()
.stdout(contains(
"Removed plugin `sample` from marketplace `debug`.",
));
let config = std::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE))?;
assert!(!config.contains("[plugins.\"sample@debug\"]"));
Ok(())
}
#[tokio::test]
async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snapshot() -> Result<()>
{
let (codex_home, _source) = setup_local_marketplace()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.success();
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "remove", "debug"])
.assert()
.success();
assert!(
codex_home
.path()
.join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json")
.is_file()
);
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"plugin `sample` was not found in marketplace `debug`",
));
Ok(())
}
+3 -6
View File
@@ -7,10 +7,8 @@ use std::collections::BTreeMap;
use crate::PUBLIC_TOOL_NAME;
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
const CODE_MODE_ONLY_PREFACE: &str =
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly";
const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`.
To find one, filter `ALL_TOOLS` by `name` and `description`; do not print the full `ALL_TOOLS` array. Print only a small set of relevant matches if you need to inspect them."#;
To find one, filter `ALL_TOOLS` by `name` and `description`."#;
const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls
- Evaluates the provided JavaScript code in a fresh V8 isolate as an async module.
- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`.
@@ -256,9 +254,6 @@ pub fn build_exec_tool_description(
deferred_tools_available: bool,
) -> String {
let mut sections = Vec::new();
if code_mode_only {
sections.push(CODE_MODE_ONLY_PREFACE.to_string());
}
sections.push(EXEC_DESCRIPTION_TEMPLATE.to_string());
if deferred_tools_available {
sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string());
@@ -875,6 +870,7 @@ mod tests {
"### `foo`
bar"
));
assert!(!description.contains("do not attempt to use any other tools directly"));
}
#[test]
@@ -1097,5 +1093,6 @@ bar"
assert!(description.contains("Some nested MCP/app tools may be omitted"));
assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`"));
assert!(!description.contains("do not print the full `ALL_TOOLS` array"));
}
}
+2
View File
@@ -24,4 +24,6 @@ pub use realtime_websocket::session_update_session_json;
pub use responses::ResponsesClient;
pub use responses::ResponsesOptions;
pub use responses_websocket::ResponsesWebsocketClient;
pub use responses_websocket::ResponsesWebsocketClose;
pub use responses_websocket::ResponsesWebsocketConnection;
pub use responses_websocket::ResponsesWebsocketProbe;
@@ -35,6 +35,7 @@ use tokio_tungstenite::connect_async_tls_with_config;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tracing::Instrument;
use tracing::Span;
use tracing::debug;
@@ -321,12 +322,40 @@ impl ResponsesWebsocketConnection {
}
}
/// Client for connecting to the Responses WebSocket endpoint for one provider.
pub struct ResponsesWebsocketClient {
provider: Provider,
auth: SharedAuthProvider,
}
/// Close frame information captured by a handshake probe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponsesWebsocketClose {
/// WebSocket close code returned by the server.
pub code: String,
/// Human-readable close reason returned by the server.
pub reason: String,
}
/// Result of a handshake-only Responses WebSocket probe.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponsesWebsocketProbe {
/// Redacted by callers before displaying or serializing support reports.
pub url: String,
/// HTTP status returned by the successful WebSocket upgrade.
pub status: StatusCode,
/// Whether the server reported reasoning support in the upgrade response.
pub reasoning_included: bool,
/// Whether the server returned a model catalog ETag in the upgrade response.
pub models_etag_present: bool,
/// Whether the server returned a server-selected model in the upgrade response.
pub server_model_present: bool,
/// Close frame received immediately after upgrade, when one arrives quickly.
pub immediate_close: Option<ResponsesWebsocketClose>,
}
impl ResponsesWebsocketClient {
/// Creates a Responses WebSocket client for an already-resolved provider and auth source.
pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self {
Self { provider, auth }
}
@@ -353,7 +382,7 @@ impl ResponsesWebsocketClient {
merge_request_headers(&self.provider.headers, extra_headers, default_headers);
self.auth.add_auth_headers(&mut headers);
let (stream, server_reasoning_included, models_etag, server_model) =
let (stream, _status, server_reasoning_included, models_etag, server_model) =
connect_websocket(ws_url, headers, turn_state.clone()).await?;
Ok(ResponsesWebsocketConnection::new(
stream,
@@ -364,6 +393,64 @@ impl ResponsesWebsocketClient {
telemetry,
))
}
/// Opens a WebSocket connection long enough to validate the upgrade response.
///
/// The probe uses the same URL construction, headers, authentication, TLS,
/// and custom-CA path as a real Responses WebSocket connection, but it does
/// not send a request frame. After the HTTP 101 upgrade succeeds, it waits
/// briefly for an immediate server close frame so diagnostics can distinguish
/// a usable connection from a policy rejection that closes right away.
pub async fn probe_handshake(
&self,
extra_headers: HeaderMap,
default_headers: HeaderMap,
immediate_close_timeout: Duration,
) -> Result<ResponsesWebsocketProbe, ApiError> {
let ws_url = self
.provider
.websocket_url_for_path("responses")
.map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?;
let mut headers =
merge_request_headers(&self.provider.headers, extra_headers, default_headers);
self.auth.add_auth_headers(&mut headers);
let (mut stream, status, reasoning_included, models_etag, server_model) =
connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?;
let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next())
.await
.ok()
.flatten()
.transpose()
.map_err(|err| {
ApiError::Stream(format!("failed to read websocket probe event: {err}"))
})?
.and_then(immediate_close_from_message);
Ok(ResponsesWebsocketProbe {
url: ws_url.to_string(),
status,
reasoning_included,
models_etag_present: models_etag.is_some(),
server_model_present: server_model.is_some(),
immediate_close,
})
}
}
fn immediate_close_from_message(message: Message) -> Option<ResponsesWebsocketClose> {
let Message::Close(frame) = message else {
return None;
};
frame.map(close_frame_to_probe)
}
fn close_frame_to_probe(frame: CloseFrame) -> ResponsesWebsocketClose {
ResponsesWebsocketClose {
code: frame.code.to_string(),
reason: frame.reason.to_string(),
}
}
fn merge_request_headers(
@@ -385,7 +472,7 @@ async fn connect_websocket(
url: Url,
headers: HeaderMap,
turn_state: Option<Arc<OnceLock<String>>>,
) -> Result<(WsStream, bool, Option<String>, Option<String>), ApiError> {
) -> Result<(WsStream, StatusCode, bool, Option<String>, Option<String>), ApiError> {
ensure_rustls_crypto_provider();
info!("connecting to websocket: {url}");
@@ -445,6 +532,7 @@ async fn connect_websocket(
}
Ok((
WsStream::new(stream),
response.status(),
reasoning_included,
models_etag,
server_model,
+2
View File
@@ -55,7 +55,9 @@ pub use crate::endpoint::RealtimeWebsocketWriter;
pub use crate::endpoint::ResponsesClient;
pub use crate::endpoint::ResponsesOptions;
pub use crate::endpoint::ResponsesWebsocketClient;
pub use crate::endpoint::ResponsesWebsocketClose;
pub use crate::endpoint::ResponsesWebsocketConnection;
pub use crate::endpoint::ResponsesWebsocketProbe;
pub use crate::endpoint::session_update_session_json;
pub use crate::error::ApiError;
pub use crate::files::upload_local_file;
@@ -5,11 +5,9 @@ use http::HeaderValue;
pub fn build_session_headers(session_id: Option<String>, thread_id: Option<String>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(id) = session_id {
insert_header(&mut headers, "session_id", &id);
insert_header(&mut headers, "session-id", &id);
}
if let Some(id) = thread_id {
insert_header(&mut headers, "thread_id", &id);
insert_header(&mut headers, "thread-id", &id);
}
headers
-8
View File
@@ -458,18 +458,10 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> {
assert_eq!(requests.len(), 1);
let req = &requests[0];
assert_eq!(
req.headers.get("session_id").and_then(|v| v.to_str().ok()),
Some("sess_123")
);
assert_eq!(
req.headers.get("session-id").and_then(|v| v.to_str().ok()),
Some("sess_123")
);
assert_eq!(
req.headers.get("thread_id").and_then(|v| v.to_str().ok()),
Some("thread_123")
);
assert_eq!(
req.headers.get("thread-id").and_then(|v| v.to_str().ok()),
Some("thread_123")
@@ -596,8 +596,8 @@ fn codex_apps_tools_cache_filters_disallowed_connectors() {
create_test_tool_with_connector(
CODEX_APPS_MCP_SERVER_NAME,
"blocked_tool",
"connector_openai_hidden",
Some("Hidden"),
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
Some("Blocked"),
),
create_test_tool_with_connector(
CODEX_APPS_MCP_SERVER_NAME,
+1
View File
@@ -32,6 +32,7 @@ multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_ignored = { workspace = true }
serde_json = { workspace = true }
serde_path_to_error = { workspace = true }
sha2 = { workspace = true }
-14
View File
@@ -177,14 +177,6 @@ pub struct ConfigToml {
/// Compact prompt used for history compaction.
pub compact_prompt: Option<String>,
/// Optional commit attribution text for commit message co-author trailers.
/// This top-level setting only takes effect when `[features].codex_git_commit`
/// is enabled.
///
/// When enabled and unset, Codex uses `Codex <noreply@openai.com>`.
/// Set to an empty string to disable automatic commit attribution.
pub commit_attribution: Option<String>,
/// When set, restricts ChatGPT login to a specific workspace identifier.
#[serde(default)]
pub forced_chatgpt_workspace_id: Option<String>,
@@ -451,7 +443,6 @@ pub struct ConfigToml {
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
pub oss_provider: Option<String>,
}
@@ -606,10 +597,6 @@ pub struct ToolsToml {
deserialize_with = "deserialize_optional_web_search_tool_config"
)]
pub web_search: Option<WebSearchToolConfig>,
/// Enable the `view_image` tool that lets the agent attach local images.
#[serde(default)]
pub view_image: Option<bool>,
}
#[derive(Deserialize)]
@@ -687,7 +674,6 @@ impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
web_search: tools_toml.web_search.is_some().then_some(true),
view_image: tools_toml.view_image,
}
}
}
+87 -7
View File
@@ -86,6 +86,23 @@ impl std::error::Error for ConfigLoadError {
}
}
#[derive(Clone, Copy)]
pub(crate) enum ConfigDiagnosticSource<'a> {
Path(&'a Path),
#[cfg(any(target_os = "macos", test))]
DisplayName(&'a str),
}
impl ConfigDiagnosticSource<'_> {
pub(crate) fn to_path_buf(self) -> PathBuf {
match self {
ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
#[cfg(any(target_os = "macos", test))]
ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
}
}
}
pub fn io_error_from_config_error(
kind: io::ErrorKind,
error: ConfigError,
@@ -98,21 +115,39 @@ pub fn config_error_from_toml(
path: impl AsRef<Path>,
contents: &str,
err: toml::de::Error,
) -> ConfigError {
config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err)
}
pub(crate) fn config_error_from_toml_for_source(
source: ConfigDiagnosticSource<'_>,
contents: &str,
err: toml::de::Error,
) -> ConfigError {
let range = err
.span()
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
ConfigError::new(path.as_ref().to_path_buf(), range, err.message())
ConfigError::new(source.to_path_buf(), range, err.message())
}
pub fn config_error_from_typed_toml<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
) -> Option<ConfigError> {
config_error_from_typed_toml_for_source::<T>(
ConfigDiagnosticSource::Path(path.as_ref()),
contents,
)
}
fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(
source: ConfigDiagnosticSource<'_>,
contents: &str,
) -> Option<ConfigError> {
let deserializer = match toml::de::Deserializer::parse(contents) {
Ok(deserializer) => deserializer,
Err(err) => return Some(config_error_from_toml(path, contents, err)),
Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)),
};
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
@@ -126,7 +161,7 @@ pub fn config_error_from_typed_toml<T: DeserializeOwned>(
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
path.as_ref().to_path_buf(),
source.to_path_buf(),
range,
toml_err.message(),
))
@@ -194,7 +229,7 @@ where
fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option<PathBuf> {
match &layer.name {
ConfigLayerSource::System { file } => Some(file.to_path_buf()),
ConfigLayerSource::User { file } => Some(file.to_path_buf()),
ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()),
ConfigLayerSource::Project { dot_codex_folder } => {
Some(dot_codex_folder.as_path().join(config_toml_file))
}
@@ -205,7 +240,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op
}
}
fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
let start = position_for_offset(contents, span.start);
let end_index = if span.end > span.start {
span.end - 1
@@ -290,7 +325,7 @@ fn position_for_offset(contents: &str, index: usize) -> TextPosition {
}
}
fn default_range() -> TextRange {
pub(crate) fn default_range() -> TextRange {
let position = TextPosition { line: 1, column: 1 };
TextRange {
start: position,
@@ -314,7 +349,10 @@ fn span_for_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usi
}
}
fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usize>> {
pub(crate) fn span_for_config_path(
contents: &str,
path: &SerdePath,
) -> Option<std::ops::Range<usize>> {
if is_features_table_path(path)
&& let Some(span) = span_for_features_value(contents)
{
@@ -323,6 +361,48 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Ra
span_for_path(contents, path)
}
pub(crate) fn span_for_toml_key_path(
contents: &str,
path: &[String],
) -> Option<std::ops::Range<usize>> {
let doc = contents.parse::<Document<String>>().ok()?;
let mut node = TomlNode::Item(doc.as_item());
for (index, segment) in path.iter().enumerate() {
if index + 1 == path.len() {
let key_span = match &node {
TomlNode::Item(item) => item
.as_table_like()
.and_then(|table| table.get_key_value(segment))
.and_then(|(key, _)| key.span()),
TomlNode::Table(table) => {
table.get_key_value(segment).and_then(|(key, _)| key.span())
}
TomlNode::Value(Value::InlineTable(table)) => {
table.get_key_value(segment).and_then(|(key, _)| key.span())
}
_ => None,
};
if key_span.is_some() {
return key_span;
}
}
if let Some(next) = map_child(&node, segment) {
node = next;
continue;
}
let index = segment.parse::<usize>().ok()?;
node = seq_child(&node, index)?;
}
match node {
TomlNode::Item(item) => item.span(),
TomlNode::Table(table) => table.span(),
TomlNode::Value(value) => value.span(),
}
}
fn is_features_table_path(path: &SerdePath) -> bool {
let mut segments = path.iter();
matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")
+5
View File
@@ -21,6 +21,7 @@ mod requirements_exec_policy;
pub mod schema;
mod skills_config;
mod state;
mod strict_config;
mod thread_config;
mod tui_keymap;
pub mod types;
@@ -31,6 +32,8 @@ pub use cloud_requirements::CloudRequirementsLoadError;
pub use cloud_requirements::CloudRequirementsLoadErrorCode;
pub use cloud_requirements::CloudRequirementsLoader;
pub use codex_app_server_protocol::ConfigLayerSource;
pub use codex_protocol::config_types::ProfileV2Name;
pub use codex_protocol::config_types::ProfileV2NameParseError;
pub use codex_utils_absolute_path::AbsolutePathBuf;
pub use config_requirements::AppRequirementToml;
pub use config_requirements::AppToolRequirementToml;
@@ -116,7 +119,9 @@ pub use skills_config::SkillsConfig;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
pub use state::ConfigLoadOptions;
pub use state::LoaderOverrides;
pub use strict_config::config_error_from_ignored_toml_fields;
pub use thread_config::NoopThreadConfigLoader;
pub use thread_config::RemoteThreadConfigLoader;
pub use thread_config::SessionThreadConfig;
+2 -1
View File
@@ -10,13 +10,14 @@ This module is the canonical place to **load and describe Codex configuration la
Exported from `codex_config::loader`:
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `ConfigLayerStack`
- `effective_config() -> toml::Value`
- `origins() -> HashMap<String, ConfigLayerMetadata>`
- `layers_high_to_low() -> Vec<ConfigLayer>`
- `with_user_config(user_config) -> ConfigLayerStack`
- `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata)
- `ConfigLoadOptions` (user-facing load behavior such as strict config validation)
- `LoaderOverrides` (test/override hooks for managed config sources)
- `merge_toml_values(base, overlay)` (public helper used elsewhere)
+56 -12
View File
@@ -2,11 +2,14 @@
use super::macos::ManagedAdminConfigLayer;
#[cfg(target_os = "macos")]
use super::macos::load_managed_admin_config_layer;
use crate::config_toml::ConfigToml;
use crate::diagnostics::config_error_from_toml;
use crate::diagnostics::io_error_from_config_error;
use crate::state::LoaderOverrides;
use crate::strict_config::config_error_from_ignored_toml_value_fields;
use codex_file_system::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use std::io;
use std::path::Path;
use std::path::PathBuf;
@@ -39,6 +42,7 @@ pub(super) async fn load_config_layers_internal(
fs: &dyn ExecutorFileSystem,
codex_home: &Path,
overrides: LoaderOverrides,
strict_config: bool,
) -> io::Result<LoadedConfigLayers> {
#[cfg(target_os = "macos")]
let LoaderOverrides {
@@ -57,19 +61,26 @@ pub(super) async fn load_config_layers_internal(
managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)),
)?;
let managed_config =
read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false)
.await?
.map(|managed_config| MangedConfigFromFile {
managed_config,
file: managed_config_path.clone(),
});
let managed_config = read_config_from_path(
fs,
&managed_config_path,
/*log_missing_as_info*/ false,
strict_config,
)
.await?
.map(|loaded| MangedConfigFromFile {
managed_config: loaded,
file: managed_config_path.clone(),
});
#[cfg(target_os = "macos")]
let managed_preferences =
load_managed_admin_config_layer(managed_preferences_base64.as_deref())
.await?
.map(map_managed_admin_layer);
let managed_preferences = load_managed_admin_config_layer(
managed_preferences_base64.as_deref(),
strict_config,
codex_home,
)
.await?
.map(map_managed_admin_layer);
#[cfg(not(target_os = "macos"))]
let managed_preferences = None;
@@ -93,10 +104,16 @@ pub(super) async fn read_config_from_path(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
log_missing_as_info: bool,
strict_config: bool,
) -> io::Result<Option<TomlValue>> {
match fs.read_file_text(path, /*sandbox*/ None).await {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(value) => Ok(Some(value)),
Ok(value) => {
if strict_config {
validate_config_toml_strictly(path, &contents, &value)?;
}
Ok(Some(value))
}
Err(err) => {
tracing::error!("Failed to parse {}: {err}", path.as_path().display());
let config_error = config_error_from_toml(path.as_path(), &contents, err.clone());
@@ -122,6 +139,33 @@ pub(super) async fn read_config_from_path(
}
}
fn validate_config_toml_strictly(
path: &AbsolutePathBuf,
contents: &str,
value: &TomlValue,
) -> io::Result<()> {
let Some(base_dir) = path.as_path().parent() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Config file {} has no parent directory", path.display()),
));
};
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
path.as_path(),
contents,
value.clone(),
) {
return Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
));
}
Ok(())
}
/// Return the default managed config path.
pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf {
#[cfg(unix)]
+74 -12
View File
@@ -2,13 +2,20 @@ use super::merge_requirements_with_remote_sandbox_config;
use crate::config_requirements::ConfigRequirementsToml;
use crate::config_requirements::ConfigRequirementsWithSources;
use crate::config_requirements::RequirementSource;
use crate::config_toml::ConfigToml;
use crate::diagnostics::ConfigDiagnosticSource;
use crate::diagnostics::config_error_from_toml_for_source;
use crate::diagnostics::io_error_from_config_error;
use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use core_foundation::string::CFStringRef;
use std::ffi::c_void;
use std::io;
use std::path::Path;
use tokio::task;
use toml::Value as TomlValue;
@@ -31,17 +38,20 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
strict_config: bool,
base_dir: &Path,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
if let Some(encoded) = override_base64 {
let trimmed = encoded.trim();
return if trimmed.is_empty() {
Ok(None)
} else {
parse_managed_config_base64(trimmed).map(Some)
parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some)
};
}
match task::spawn_blocking(load_managed_admin_config).await {
let base_dir = base_dir.to_path_buf();
match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await {
Ok(result) => result,
Err(join_err) => {
if join_err.is_cancelled() {
@@ -54,11 +64,14 @@ pub(crate) async fn load_managed_admin_config_layer(
}
}
fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
fn load_managed_admin_config(
strict_config: bool,
base_dir: &Path,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?
.as_deref()
.map(str::trim)
.map(parse_managed_config_base64)
.map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir))
.transpose()
}
@@ -134,24 +147,73 @@ fn load_managed_preference(key_name: &str) -> io::Result<Option<String>> {
Ok(Some(value))
}
fn parse_managed_config_base64(encoded: &str) -> io::Result<ManagedAdminConfigLayer> {
fn parse_managed_config_base64(
encoded: &str,
strict_config: bool,
base_dir: &Path,
) -> io::Result<ManagedAdminConfigLayer> {
let raw_toml = decode_managed_preferences_base64(encoded)?;
match toml::from_str::<TomlValue>(&raw_toml) {
Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer {
let source_name =
format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}");
let parsed = toml::from_str::<TomlValue>(&raw_toml).map_err(|err| {
tracing::error!("Failed to parse managed config TOML: {err}");
if strict_config {
let config_error = config_error_from_toml_for_source(
ConfigDiagnosticSource::DisplayName(&source_name),
&raw_toml,
err.clone(),
);
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
} else {
io::Error::new(io::ErrorKind::InvalidData, err)
}
})?;
validate_managed_config_toml_strictly_if_requested(
strict_config,
&source_name,
&raw_toml,
&parsed,
base_dir,
)?;
match parsed {
TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer {
config: TomlValue::Table(parsed),
raw_toml,
}),
Ok(other) => {
other => {
tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
Err(io::Error::new(
io::ErrorKind::InvalidData,
"managed config root must be a table",
))
}
Err(err) => {
tracing::error!("Failed to parse managed config TOML: {err}");
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
}
}
fn validate_managed_config_toml_strictly_if_requested(
strict_config: bool,
source_name: &str,
raw_toml: &str,
parsed: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
if !strict_config {
return Ok(());
}
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::<
ConfigToml,
>(source_name, raw_toml, parsed.clone())
{
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
} else {
Ok(())
}
}
+151 -33
View File
@@ -4,6 +4,7 @@ mod macos;
use self::layer_io::LoadedConfigLayers;
use crate::CONFIG_TOML_FILE;
use crate::ProfileV2Name;
use crate::cloud_requirements::CloudRequirementsLoader;
use crate::config_requirements::ConfigRequirementsToml;
use crate::config_requirements::ConfigRequirementsWithSources;
@@ -21,7 +22,11 @@ use crate::project_root_markers::default_project_root_markers;
use crate::project_root_markers::project_root_markers_from_config;
use crate::state::ConfigLayerEntry;
use crate::state::ConfigLayerStack;
use crate::state::ConfigLoadOptions;
use crate::state::LoaderOverrides;
use crate::strict_config::config_error_from_ignored_toml_value_fields;
use crate::strict_config::ignored_toml_value_field;
use crate::strict_config::unknown_feature_toml_value_field;
use crate::thread_config::ThreadConfigContext;
use crate::thread_config::ThreadConfigLoader;
use codex_app_server_protocol::ConfigLayerSource;
@@ -85,6 +90,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O
/// - system `/etc/codex/config.toml` (Unix) or
/// `%ProgramData%\OpenAI\Codex\config.toml` (Windows)
/// - user `${CODEX_HOME}/config.toml`
/// - profile `${CODEX_HOME}/<name>.config.toml`, when selected
/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted)
/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted)
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted)
@@ -104,10 +110,15 @@ pub async fn load_config_layers_state(
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
options: impl Into<ConfigLoadOptions>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: &dyn ThreadConfigLoader,
) -> io::Result<ConfigLayerStack> {
let ConfigLoadOptions {
loader_overrides: overrides,
strict_config,
} = options.into();
let active_user_profile = overrides.user_config_profile.clone();
let ignore_managed_requirements = overrides.ignore_managed_requirements;
let ignore_user_config = overrides.ignore_user_config;
let ignore_user_and_project_exec_policy_rules =
@@ -140,7 +151,8 @@ pub async fn load_config_layers_state(
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers =
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?;
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config)
.await?;
if !ignore_managed_requirements {
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
@@ -168,6 +180,9 @@ pub async fn load_config_layers_state(
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(codex_home);
if strict_config {
validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?;
}
Some(resolve_relative_paths_in_config_toml(
cli_overrides_layer,
base_dir,
@@ -177,41 +192,50 @@ pub async fn load_config_layers_state(
// Include an entry for the "system" config folder, loading its config.toml,
// if it exists.
let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?;
let system_layer =
load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| {
let system_layer = load_config_toml_for_required_layer(
fs,
&system_config_toml_file,
strict_config,
|config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: system_config_toml_file.clone(),
},
config_toml,
)
})
.await?;
},
)
.await?;
layers.push(system_layer);
// Add a layer for $CODEX_HOME/config.toml so folder-derived resources such
// as rules/ can still be discovered. When user config is ignored, preserve
// the layer metadata without reading config.toml.
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
let user_layer = if ignore_user_config {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
},
TomlValue::Table(toml::map::Map::new()),
// Add the base user config layer. When profile-v2 is selected, add the
// profile config as a second user layer on top so the profile only needs to
// contain overrides.
let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
layers.push(
load_user_config_layer(
fs,
&base_user_file,
/*profile*/ None,
ignore_user_config,
strict_config,
)
} else {
load_config_toml_for_required_layer(fs, &user_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
},
config_toml,
.await?,
);
let active_user_file = overrides.user_config_path(codex_home)?;
if active_user_file != base_user_file {
layers.push(
load_user_config_layer(
fs,
&active_user_file,
active_user_profile.as_ref(),
ignore_user_config,
strict_config,
)
})
.await?
};
layers.push(user_layer);
.await?,
);
}
let mut startup_warnings = None;
if let Some(cwd) = cwd {
@@ -242,7 +266,7 @@ pub async fn load_config_layers_state(
&cwd,
&project_root_markers,
codex_home,
&user_file,
&active_user_file,
)
.await
{
@@ -268,6 +292,7 @@ pub async fn load_config_layers_state(
&project_trust_context.project_root,
&project_trust_context,
codex_home,
strict_config,
)
.await?;
layers.extend(project_layers.layers);
@@ -339,6 +364,36 @@ pub async fn load_config_layers_state(
})
}
async fn load_user_config_layer(
fs: &dyn ExecutorFileSystem,
user_file: &AbsolutePathBuf,
profile: Option<&ProfileV2Name>,
ignore_user_config: bool,
strict_config: bool,
) -> io::Result<ConfigLayerEntry> {
let profile = profile.map(ToString::to_string);
if ignore_user_config {
return Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
profile,
},
TomlValue::Table(toml::map::Map::new()),
));
}
load_config_toml_for_required_layer(fs, user_file, strict_config, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
profile: profile.clone(),
},
config_toml,
)
})
.await
}
fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigLayerEntry) {
match layers
.iter()
@@ -359,15 +414,11 @@ fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigL
async fn load_config_toml_for_required_layer(
fs: &dyn ExecutorFileSystem,
toml_file: &AbsolutePathBuf,
strict_config: bool,
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
) -> io::Result<ConfigLayerEntry> {
let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
@@ -377,6 +428,19 @@ async fn load_config_toml_for_required_layer(
),
)
})?;
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
if strict_config {
validate_config_toml_strictly(
toml_file.as_path(),
&contents,
&config,
config_parent,
)?;
}
resolve_relative_paths_in_config_toml(config, config_parent)
}
Err(e) => {
@@ -397,6 +461,51 @@ async fn load_config_toml_for_required_layer(
Ok(create_entry(toml_value))
}
fn validate_config_toml_strictly(
toml_file: &Path,
contents: &str,
value: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
toml_file,
contents,
value.clone(),
) {
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
} else {
Ok(())
}
}
fn validate_cli_overrides_strictly(
cli_overrides_layer: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(ignored_path) = ignored_toml_value_field::<ConfigToml>(cli_overrides_layer.clone())
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
));
}
if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
));
}
Ok(())
}
/// If available, apply requirements from the platform system
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
@@ -998,6 +1107,7 @@ async fn load_project_layers(
project_root: &AbsolutePathBuf,
trust_context: &ProjectTrustContext,
codex_home: &Path,
strict_config: bool,
) -> io::Result<LoadedProjectLayers> {
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
let codex_home_normalized =
@@ -1063,6 +1173,14 @@ async fn load_project_layers(
}
};
let mut config = config;
if disabled_reason.is_none() && strict_config {
validate_config_toml_strictly(
config_file.as_path(),
&contents,
&config,
dot_codex_abs.as_path(),
)?;
}
let ignored_project_config_keys = sanitize_project_config(&mut config);
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
-3
View File
@@ -54,14 +54,11 @@ pub struct ConfigProfile {
#[schemars(skip)]
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub include_apply_patch_tool: Option<bool>,
pub include_permissions_instructions: Option<bool>,
pub include_apps_instructions: Option<bool>,
pub include_collaboration_mode_instructions: Option<bool>,
pub include_environment_context: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_view_image: Option<bool>,
pub tools: Option<ToolsToml>,
pub web_search: Option<WebSearchMode>,
pub analytics: Option<AnalyticsConfigToml>,
+177 -50
View File
@@ -5,18 +5,38 @@ use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
use super::key_aliases::normalized_with_key_aliases;
use super::merge::merge_toml_values;
use crate::ProfileV2Name;
use codex_app_server_protocol::ConfigLayer;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
/// User-facing config loading behavior that is not part of the config document.
#[derive(Debug, Default, Clone)]
pub struct ConfigLoadOptions {
pub loader_overrides: LoaderOverrides,
pub strict_config: bool,
}
impl From<LoaderOverrides> for ConfigLoadOptions {
fn from(loader_overrides: LoaderOverrides) -> Self {
Self {
loader_overrides,
strict_config: false,
}
}
}
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {
pub user_config_path: Option<AbsolutePathBuf>,
pub user_config_profile: Option<ProfileV2Name>,
pub managed_config_path: Option<PathBuf>,
pub system_config_path: Option<PathBuf>,
pub system_requirements_path: Option<PathBuf>,
@@ -36,6 +56,8 @@ impl LoaderOverrides {
pub fn without_managed_config_for_tests() -> Self {
let base = std::env::temp_dir().join("codex-config-tests");
Self {
user_config_path: None,
user_config_profile: None,
managed_config_path: Some(base.join("managed_config.toml")),
system_config_path: Some(base.join("config.toml")),
system_requirements_path: Some(base.join("requirements.toml")),
@@ -53,10 +75,22 @@ impl LoaderOverrides {
/// This is intended for tests that supply an explicit managed config fixture.
pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self {
Self {
user_config_path: None,
user_config_profile: None,
managed_config_path: Some(managed_config_path),
..Self::without_managed_config_for_tests()
}
}
pub fn user_config_path(&self, codex_home: &Path) -> std::io::Result<AbsolutePathBuf> {
match self.user_config_path.as_ref() {
Some(path) => Ok(path.clone()),
None => Ok(AbsolutePathBuf::resolve_path_against_base(
crate::CONFIG_TOML_FILE,
codex_home,
)),
}
}
}
#[derive(Debug, Clone, PartialEq)]
@@ -147,7 +181,7 @@ impl ConfigLayerEntry {
match &self.name {
ConfigLayerSource::Mdm { .. } => None,
ConfigLayerSource::System { file } => file.parent(),
ConfigLayerSource::User { file } => file.parent(),
ConfigLayerSource::User { file, .. } => file.parent(),
ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
ConfigLayerSource::SessionFlags => None,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
@@ -180,7 +214,12 @@ pub struct ConfigLayerStack {
/// later entries in the Vec override earlier ones.
layers: Vec<ConfigLayerEntry>,
/// Index into [layers] of the user config layer, if any.
/// Index into [layers] of the active user config layer, if any.
///
/// When profile config is active, there can be more than one user layer:
/// the base `$CODEX_HOME/config.toml` layer followed by the profile override
/// layer. This index points at the highest-precedence user layer because that
/// is the writable layer for profile-aware edits.
user_layer_index: Option<usize>,
/// Constraints that must be enforced when deriving a [Config] from the
@@ -240,14 +279,61 @@ impl ConfigLayerStack {
self.startup_warnings.as_deref()
}
/// Returns the raw user config layer, if any.
/// Returns the active raw user config layer, if any.
///
/// This does not merge other config layers or apply any requirements.
pub fn get_user_layer(&self) -> Option<&ConfigLayerEntry> {
/// This does not merge other config layers or apply any requirements. When
/// a profile-v2 layer is active, this returns that profile layer rather than
/// the base `$CODEX_HOME/config.toml` layer because the active layer is the
/// writable target for profile-aware edits.
pub fn get_active_user_layer(&self) -> Option<&ConfigLayerEntry> {
self.user_layer_index
.and_then(|index| self.layers.get(index))
}
pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> {
let layer = self.get_active_user_layer()?;
let ConfigLayerSource::User { file, .. } = &layer.name else {
return None;
};
Some(file)
}
/// Returns all user config layers in the requested precedence order.
///
/// With profile-v2 enabled, `LowestPrecedenceFirst` returns the base user
/// config before the profile overlay, while `HighestPrecedenceFirst` returns
/// the profile overlay before the base user config.
pub fn get_user_layers(
&self,
ordering: ConfigLayerStackOrdering,
include_disabled: bool,
) -> Vec<&ConfigLayerEntry> {
self.get_layers(ordering, include_disabled)
.into_iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.collect()
}
/// Returns the merged config from enabled user layers only.
///
/// When profile config is active, this includes the base user config followed
/// by the profile override config.
pub fn effective_user_config(&self) -> Option<TomlValue> {
let user_layers = self.get_user_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
);
if user_layers.is_empty() {
return None;
}
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in user_layers {
merge_toml_values(&mut merged, &layer.config);
}
Some(merged)
}
pub fn requirements(&self) -> &ConfigRequirements {
&self.requirements
}
@@ -256,54 +342,101 @@ impl ConfigLayerStack {
&self.requirements_toml
}
/// Creates a new [ConfigLayerStack] using the specified values to inject a
/// "user layer" into the stack. If such a layer already exists, it is
/// replaced; otherwise, it is inserted into the stack at the appropriate
/// position based on precedence rules.
/// Creates a new [ConfigLayerStack] using the specified values to inject one
/// user layer into the stack. If such a layer already exists, it is replaced;
/// otherwise, it is inserted into the stack at the appropriate position
/// based on precedence rules. When the stack has both base and profile-v2
/// user layers, this updates only the layer whose file matches
/// `config_toml`.
pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self {
self.with_user_layer(Some(ConfigLayerEntry::new(
let profile = self.layers.iter().find_map(|layer| match &layer.name {
ConfigLayerSource::User { file, profile } if file == config_toml => profile
.as_deref()
.and_then(|profile| profile.parse::<ProfileV2Name>().ok()),
_ => None,
});
self.with_user_config_profile(config_toml, profile.as_ref(), user_config)
}
pub fn with_user_config_profile(
&self,
config_toml: &AbsolutePathBuf,
profile: Option<&ProfileV2Name>,
user_config: TomlValue,
) -> Self {
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
profile: profile.map(ToString::to_string),
},
user_config,
)))
);
let mut layers = self.layers.clone();
if let Some(index) = layers.iter().position(|layer| {
matches!(
&layer.name,
ConfigLayerSource::User { file, .. } if file == config_toml
)
}) {
layers.remove(index);
}
match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => layers.insert(index, user_layer),
None => layers.push(user_layer),
}
let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
Some(index)
} else {
None
}
});
Self {
layers,
user_layer_index,
requirements: self.requirements.clone(),
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
startup_warnings: self.startup_warnings.clone(),
}
}
/// Returns a new stack with the user layer copied from `other`, preserving
/// every non-user layer already present in this stack.
pub fn with_user_layer_from(&self, other: &Self) -> Self {
self.with_user_layer(other.get_user_layer().cloned())
}
fn with_user_layer(&self, user_layer: Option<ConfigLayerEntry>) -> Self {
let mut layers = self.layers.clone();
let user_layer_index = match (self.user_layer_index, user_layer) {
(Some(index), Some(user_layer)) => {
layers[index] = user_layer;
Some(index)
let user_layers = other
.layers
.iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.cloned()
.collect::<Vec<_>>();
let mut layers = self
.layers
.iter()
.filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. }))
.cloned()
.collect::<Vec<_>>();
for user_layer in user_layers {
match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => layers.insert(index, user_layer),
None => layers.push(user_layer),
}
(Some(index), None) => {
layers.remove(index);
}
let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
Some(index)
} else {
None
}
(None, Some(user_layer)) => {
let user_layer_index = match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => {
layers.insert(index, user_layer);
index
}
None => {
layers.push(user_layer);
layers.len() - 1
}
};
Some(user_layer_index)
}
(None, None) => None,
};
});
Self {
layers,
user_layer_index,
@@ -379,7 +512,7 @@ impl ConfigLayerStack {
}
/// Ensures precedence ordering of config layers is correct. Returns the index
/// of the user config layer, if any (at most one should exist).
/// of the active user config layer, if any.
fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
if !layers.iter().map(|layer| &layer.name).is_sorted() {
return Err(std::io::Error::new(
@@ -389,19 +522,13 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
}
// The previous check ensured `layers` is sorted by precedence, so now we
// further verify that:
// 1. There is at most one user config layer.
// 2. Project layers are ordered from root to cwd.
// further verify that project layers are ordered from root to cwd. Multiple
// user layers are allowed so a profile override can layer on top of the base
// user config.
let mut user_layer_index: Option<usize> = None;
let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None;
for (index, layer) in layers.iter().enumerate() {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
if user_layer_index.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"multiple user config layers found",
));
}
user_layer_index = Some(index);
}
+107
View File
@@ -1,5 +1,11 @@
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn test_user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name))
.expect("test user config path should be absolute")
}
#[test]
fn origins_use_canonical_key_aliases() {
@@ -32,3 +38,104 @@ no_memories_if_mcp_or_web_search = true
"legacy key should be canonicalized before origin recording"
);
}
#[test]
fn active_user_layer_is_highest_precedence_user_layer() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let profile_file = test_user_config_path(&temp_dir, "work.config.toml");
let base_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file,
profile: None,
},
toml::from_str(
r#"
model = "base"
approval_policy = "on-failure"
"#,
)
.expect("base config"),
);
let profile_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: profile_file.clone(),
profile: Some("work".to_string()),
},
toml::from_str(r#"model = "profile""#).expect("profile config"),
);
let stack = ConfigLayerStack::new(
vec![base_layer, profile_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("multiple user layers should be valid");
assert_eq!(stack.get_user_config_file(), Some(&profile_file));
assert_eq!(
stack
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("profile")
);
assert_eq!(
stack
.effective_user_config()
.expect("merged user config")
.get("approval_policy")
.and_then(toml::Value::as_str),
Some("on-failure")
);
}
#[test]
fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() {
let temp_dir = TempDir::new().expect("tempdir");
let base_file = test_user_config_path(&temp_dir, "config.toml");
let profile_file = test_user_config_path(&temp_dir, "work.config.toml");
let base_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: base_file.clone(),
profile: None,
},
toml::from_str(r#"model = "base""#).expect("base config"),
);
let profile_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: profile_file.clone(),
profile: Some("work".to_string()),
},
toml::from_str(r#"approval_policy = "on-failure""#).expect("profile config"),
);
let stack = ConfigLayerStack::new(
vec![base_layer, profile_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("multiple user layers should be valid");
let updated = stack.with_user_config(
&base_file,
toml::from_str(r#"model = "updated-base""#).expect("updated base config"),
);
assert_eq!(updated.get_user_config_file(), Some(&profile_file));
assert_eq!(
updated
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("updated-base")
);
assert_eq!(
updated
.effective_user_config()
.expect("merged user config")
.get("approval_policy")
.and_then(toml::Value::as_str),
Some("on-failure")
);
}

Some files were not shown because too many files have changed in this diff Show More