mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
Generated
+1
@@ -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,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",
|
||||
|
||||
Generated
+22
-13
@@ -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
@@ -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"
|
||||
|
||||
@@ -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://"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+7
-6
@@ -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": [
|
||||
{
|
||||
|
||||
+7
-6
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>>() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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" | "—" | "-"
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user