Merge 7ea8e231d0 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-06-04 19:21:39 -07:00
committed by GitHub
198 changed files with 4176 additions and 1808 deletions
+9 -5
View File
@@ -105,9 +105,9 @@ common:ci --disk_cache=
# Shared config for the main Bazel CI workflow.
common:ci-bazel --config=ci
common:ci-bazel --build_metadata=TAG_workflow=bazel
# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests
# are not stable in that setup yet. Keep running the rest of the Rust
# integration suites through the workspace-root launcher.
# Keep code-mode integration cases out of ordinary Bazel legs. The
# Windows-cross config below re-enables them after generating its Windows V8
# snapshot on the Windows runner.
common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::
# Shared config for Bazel-backed Rust linting.
@@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows
common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true
common:ci-windows-cross --host_platform=//:rbe
common:ci-windows-cross --strategy=TestRunner=local
# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be
# generated by a Windows mksnapshot binary rather than the Linux RBE host tool.
common:ci-windows-cross --strategy=V8Mksnapshot=local
common:ci-windows-cross --local_test_jobs=4
common:ci-windows-cross --test_env=RUST_TEST_THREADS=1
# Native Windows CI still covers the PowerShell tests. The cross-built gnullvm
# binaries currently hang in PowerShell AST parser tests when those binaries are
# run on the Windows runner.
common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell
# run on the Windows runner. Keep V8-backed code-mode tests enabled except for
# the hidden dynamic-tool callback test, which currently times out on Windows.
common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools
common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm
common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc
common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain
+17
View File
@@ -0,0 +1,17 @@
---
name: pushing-ci-changes
description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions.
---
The Codex repository prevents anyone from uploading changes to its CI configuration unless they've
been granted a temporary role.
In order to push changes to `.github/**/*.yml` and related files you'll need the user to read
go/workflow-approvals and request an approval through that flow. It is not possible for you to
make progress by requesting an exemption yourself.
If you know you're about to push changes that would fail due to these restrictions you should still
try to push anyways to confirm the user's account doesn't already have the necessary approval.
If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with
the user and ask them to return control to you once the approval has propagated to GitHub.
+12 -12
View File
@@ -20,8 +20,9 @@ jobs:
test:
# 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.
# signal without putting PR latency back on the critical path. When
# authenticated RBE is available, the Windows-cross shards exercise the
# source-built V8/code-mode targets.
timeout-minutes: 30
strategy:
fail-fast: false
@@ -91,9 +92,9 @@ jobs:
# path. V8 consumers under `//codex-rs/...` still participate
# transitively through `//...`.
-//third_party/v8:all
# V8-backed code-mode tests are covered by Cargo CI. Bazel CI
# cross-compiles in several legs, and those tests are not stable in
# that setup yet.
# Keep V8-backed code-mode tests out of the ordinary macOS/Linux
# legs; authenticated Windows-cross shards below exercise the
# source-built gnullvm V8 path.
-//codex-rs/code-mode:code-mode-unit-tests
-//codex-rs/v8-poc:v8-poc-unit-tests
)
@@ -135,9 +136,9 @@ jobs:
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.
# Split the Windows Bazel test leg across separate Windows hosts. Jobs with
# BuildBuddy credentials use Linux RBE for build actions; test execution
# remains on a Windows runner.
timeout-minutes: 30
strategy:
fail-fast: false
@@ -183,7 +184,7 @@ jobs:
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(//...))'
bazel_test_query='tests(//...) except tests(//third_party/v8:all) except attr(tags, "manual", tests(//...))'
mapfile -t bazel_targets < <(
MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \
| LC_ALL=C sort
@@ -289,9 +290,8 @@ jobs:
# path. V8 consumers under `//codex-rs/...` still participate
# transitively through `//...`.
-//third_party/v8:all
# Keep this aligned with the main Bazel job. The native Windows
# job preserves broad post-merge coverage, but code-mode/V8 tests
# are covered by Cargo CI rather than Bazel for now.
# Keep this job broad and cheap; authenticated Windows-cross jobs
# add source-built V8-backed code-mode coverage.
-//codex-rs/code-mode:code-mode-unit-tests
-//codex-rs/v8-poc:v8-poc-unit-tests
)
-2
View File
@@ -167,8 +167,6 @@ jobs:
USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }}
CARGO_INCREMENTAL: "0"
SCCACHE_CACHE_SIZE: 10G
# In rust-ci, representative release-profile checks use thin LTO for faster feedback.
CARGO_PROFILE_RELEASE_LTO: ${{ matrix.profile == 'release' && 'thin' || 'fat' }}
strategy:
fail-fast: false
+11 -28
View File
@@ -2,23 +2,6 @@ name: rust-release-windows
on:
workflow_call:
inputs:
release-lto:
required: true
type: string
secrets:
AZURE_TRUSTED_SIGNING_CLIENT_ID:
required: true
AZURE_TRUSTED_SIGNING_TENANT_ID:
required: true
AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID:
required: true
AZURE_TRUSTED_SIGNING_ENDPOINT:
required: true
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME:
required: true
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME:
required: true
# Cargo's libgit2 transport has been flaky when fetching git dependencies with
# nested submodules. Prefer the system git CLI across every Cargo invocation.
@@ -29,17 +12,14 @@ jobs:
build-windows-binaries:
name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }}
runs-on: ${{ matrix.runs_on }}
# Windows release builds can exceed an hour on fat-LTO mainline releases,
# so keep the timeout aligned with the top-level release build headroom.
# Windows release builds can exceed an hour, so keep the timeout aligned
# with the top-level release build headroom.
timeout-minutes: 90
permissions:
contents: read
defaults:
run:
working-directory: codex-rs
env:
CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }}
strategy:
fail-fast: false
matrix:
@@ -150,6 +130,9 @@ jobs:
- build-windows-binaries
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runs_on }}
environment:
name: azure-artifact-signing
deployment: false
timeout-minutes: 90
permissions:
contents: read
@@ -211,12 +194,12 @@ jobs:
with:
target: ${{ matrix.target }}
binaries: ${{ env.WINDOWS_BINARIES }}
client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
client-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_ARTIFACT_SIGNING_SUBSCRIPTION_ID }}
endpoint: ${{ secrets.AZURE_ARTIFACT_SIGNING_ENDPOINT }}
account-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}
- name: Stage artifacts
shell: bash
+3 -6
View File
@@ -149,9 +149,6 @@ jobs:
run:
working-directory: codex-rs
env:
# 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' }}
# Use the git CLI instead of Cargo's libgit2 path for git dependencies.
# macOS release runners have intermittently failed to fetch nested
# submodules through SecureTransport/libgit2, especially libwebrtc's
@@ -325,7 +322,6 @@ jobs:
for binary in ${{ matrix.binaries }}; do
build_args+=(--bin "$binary")
done
echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}"
cargo build --target "$target" --release --timings "${build_args[@]}"
- name: Upload Cargo timings
@@ -1330,8 +1326,6 @@ jobs:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'build_unsigned' }}
needs: tag-check
uses: ./.github/workflows/rust-release-windows.yml
with:
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
secrets: inherit
argument-comment-lint-release-assets:
@@ -1952,6 +1946,9 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
environment:
name: mainline-release-winget
deployment: false
steps:
- name: Publish to WinGet
+1
View File
@@ -3052,6 +3052,7 @@ dependencies = [
"codex-model-provider-info",
"codex-protocol",
"codex-tools",
"codex-utils-absolute-path",
"http 1.4.0",
"pretty_assertions",
"schemars 0.8.22",
+1 -1
View File
@@ -501,7 +501,7 @@ debug = "none"
strip = "symbols"
[profile.release]
lto = "fat"
lto = "thin"
split-debuginfo = "off"
# Because we bundle some of these executables with the TypeScript CLI, we
# remove everything to make the binary as small as possible.
+2 -98
View File
@@ -1,99 +1,3 @@
# Codex CLI (Rust Implementation)
# Codex CLI
We provide Codex CLI as a standalone executable to ensure a zero-dependency install.
## Installing Codex
Today, the easiest way to install Codex is via `npm`:
```shell
npm i -g @openai/codex
codex
```
You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
## Documentation quickstart
- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
## What's new in the Rust CLI
The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported.
### Config
Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details.
### Model Context Protocol Support
#### MCP client
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
#### MCP server (experimental)
Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent.
Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out:
```shell
npx @modelcontextprotocol/inspector codex mcp-server
```
Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly.
### Notifications
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
### `codex exec` to run Codex programmatically/non-interactively
To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `<stdin>` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on.
Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk.
### Experimenting with the Codex Sandbox
To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI:
```
# Uses the sandbox implementation for the current host OS:
# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows.
codex sandbox [COMMAND]...
# macOS-only diagnostic option
codex sandbox --log-denials [COMMAND]...
```
`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer
`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed
command.
### Selecting a sandbox policy via `--sandbox`
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
```shell
# Run Codex with the default, read-only sandbox
codex --sandbox read-only
# Allow the agent to write within the current workspace while still blocking network access
codex --sandbox workspace-write
# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env)
codex --sandbox danger-full-access
```
In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval.
## Code Organization
This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates:
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex.
- [`exec/`](./exec) "headless" CLI for use in automation.
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned.
[**Codex CLI Documentation**](https://developers.openai.com/codex/cli)
@@ -1347,6 +1347,7 @@ fn thread_initialized_event_serializes_expected_shape() {
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
parent_thread_id: None,
forked_from_thread_id: None,
created_at: 1,
},
});
@@ -1379,6 +1380,7 @@ fn thread_initialized_event_serializes_expected_shape() {
"initialization_mode": "new",
"subagent_source": null,
"parent_thread_id": null,
"forked_from_thread_id": null,
"created_at": 1
}
})
@@ -2429,6 +2431,7 @@ fn subagent_thread_started_review_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2461,18 +2464,26 @@ fn subagent_thread_started_review_serializes_expected_shape() {
assert_eq!(payload["event_params"]["initialization_mode"], "new");
assert_eq!(payload["event_params"]["subagent_source"], "review");
assert_eq!(payload["event_params"]["parent_thread_id"], json!(null));
assert_eq!(
payload["event_params"]["forked_from_thread_id"],
json!(null)
);
}
#[test]
fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
fn subagent_thread_started_thread_spawn_serializes_thread_lineage() {
let parent_thread_id =
codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111")
.expect("valid thread id");
let forked_from_thread_id =
codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222")
.expect("valid thread id");
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-spawn".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: Some(forked_from_thread_id.to_string()),
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2497,6 +2508,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
payload["event_params"]["parent_thread_id"],
"11111111-1111-1111-1111-111111111111"
);
assert_eq!(
payload["event_params"]["forked_from_thread_id"],
"22222222-2222-4222-8222-222222222222"
);
assert_eq!(payload["event_params"]["session_id"], "session-root");
}
@@ -2507,6 +2522,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-memory".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2533,6 +2549,7 @@ fn subagent_thread_started_other_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2558,6 +2575,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2588,6 +2606,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2662,6 +2681,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: None,
product_client_id: "parent-client".to_string(),
client_name: "parent-client".to_string(),
client_version: "1.0.0".to_string(),
@@ -2732,6 +2752,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
session_id: "session-root".to_string(),
thread_id: "thread-subagent".to_string(),
parent_thread_id: Some("thread-1".to_string()),
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
+2
View File
@@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams {
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) forked_from_thread_id: Option<String>,
pub(crate) created_at: u64,
}
@@ -1060,6 +1061,7 @@ pub(crate) fn subagent_thread_started_event_request(
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
parent_thread_id: input.parent_thread_id,
forked_from_thread_id: input.forked_from_thread_id,
created_at: input.created_at,
};
ThreadInitializedEvent {
+1
View File
@@ -348,6 +348,7 @@ pub struct SubAgentThreadStartedInput {
pub session_id: String,
pub thread_id: String,
pub parent_thread_id: Option<String>,
pub forked_from_thread_id: Option<String>,
pub product_client_id: String,
pub client_name: String,
pub client_version: String,
+2
View File
@@ -1268,6 +1268,7 @@ impl AnalyticsReducer {
let session_id = thread.session_id;
let thread_id = thread.id;
let parent_thread_id = thread.parent_thread_id;
let forked_from_thread_id = thread.forked_from_id;
let Some(connection_state) = self.connections.get(&connection_id) else {
return;
};
@@ -1299,6 +1300,7 @@ impl AnalyticsReducer {
initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id,
forked_from_thread_id,
created_at: u64::try_from(thread.created_at).unwrap_or_default(),
},
},
+2 -9
View File
@@ -1971,15 +1971,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningItemContent": {
@@ -2777,15 +2777,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -3786,8 +3786,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -6278,6 +6278,69 @@
],
"type": "object"
},
"AppTemplateSummary": {
"properties": {
"canonicalConnectorId": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"logoUrl": {
"type": [
"string",
"null"
]
},
"logoUrlDark": {
"type": [
"string",
"null"
]
},
"materializedAppIds": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"reason": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppTemplateUnavailableReason"
},
{
"type": "null"
}
]
},
"templateId": {
"type": "string"
}
},
"required": [
"materializedAppIds",
"name",
"templateId"
],
"type": "object"
},
"AppTemplateUnavailableReason": {
"enum": [
"NOT_CONFIGURED_FOR_WORKSPACE",
"NO_ACTIVE_WORKSPACE"
],
"type": "string"
},
"AppToolApproval": {
"enum": [
"auto",
@@ -12168,6 +12231,12 @@
},
"PluginDetail": {
"properties": {
"appTemplates": {
"items": {
"$ref": "#/definitions/v2/AppTemplateSummary"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/v2/AppSummary"
@@ -12216,6 +12285,7 @@
}
},
"required": [
"appTemplates",
"apps",
"hooks",
"marketplaceName",
@@ -13525,15 +13595,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {
@@ -19239,4 +19302,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -643,6 +643,69 @@
],
"type": "object"
},
"AppTemplateSummary": {
"properties": {
"canonicalConnectorId": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"logoUrl": {
"type": [
"string",
"null"
]
},
"logoUrlDark": {
"type": [
"string",
"null"
]
},
"materializedAppIds": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"reason": {
"anyOf": [
{
"$ref": "#/definitions/AppTemplateUnavailableReason"
},
{
"type": "null"
}
]
},
"templateId": {
"type": "string"
}
},
"required": [
"materializedAppIds",
"name",
"templateId"
],
"type": "object"
},
"AppTemplateUnavailableReason": {
"enum": [
"NOT_CONFIGURED_FOR_WORKSPACE",
"NO_ACTIVE_WORKSPACE"
],
"type": "string"
},
"AppToolApproval": {
"enum": [
"auto",
@@ -8690,6 +8753,12 @@
},
"PluginDetail": {
"properties": {
"appTemplates": {
"items": {
"$ref": "#/definitions/AppTemplateSummary"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/AppSummary"
@@ -8738,6 +8807,7 @@
}
},
"required": [
"appTemplates",
"apps",
"hooks",
"marketplaceName",
@@ -10047,15 +10117,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {
@@ -667,15 +667,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -462,15 +462,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -462,15 +462,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -185,15 +185,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {
@@ -37,6 +37,69 @@
],
"type": "object"
},
"AppTemplateSummary": {
"properties": {
"canonicalConnectorId": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"logoUrl": {
"type": [
"string",
"null"
]
},
"logoUrlDark": {
"type": [
"string",
"null"
]
},
"materializedAppIds": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"reason": {
"anyOf": [
{
"$ref": "#/definitions/AppTemplateUnavailableReason"
},
{
"type": "null"
}
]
},
"templateId": {
"type": "string"
}
},
"required": [
"materializedAppIds",
"name",
"templateId"
],
"type": "object"
},
"AppTemplateUnavailableReason": {
"enum": [
"NOT_CONFIGURED_FOR_WORKSPACE",
"NO_ACTIVE_WORKSPACE"
],
"type": "string"
},
"HookEventName": {
"enum": [
"preToolUse",
@@ -78,6 +141,12 @@
},
"PluginDetail": {
"properties": {
"appTemplates": {
"items": {
"$ref": "#/definitions/AppTemplateSummary"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/AppSummary"
@@ -126,6 +195,7 @@
}
},
"required": [
"appTemplates",
"apps",
"hooks",
"marketplaceName",
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -124,15 +124,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -154,15 +154,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -5,4 +5,4 @@
/**
* See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
*/
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type ReasoningEffort = string;
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE";
@@ -3,8 +3,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { AppSummary } from "./AppSummary";
import type { AppTemplateSummary } from "./AppTemplateSummary";
import type { PluginHookSummary } from "./PluginHookSummary";
import type { PluginSummary } from "./PluginSummary";
import type { SkillSummary } from "./SkillSummary";
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, appTemplates: Array<AppTemplateSummary>, mcpServers: Array<string>, };
@@ -21,6 +21,8 @@ export type { AppMetadata } from "./AppMetadata";
export type { AppReview } from "./AppReview";
export type { AppScreenshot } from "./AppScreenshot";
export type { AppSummary } from "./AppSummary";
export type { AppTemplateSummary } from "./AppTemplateSummary";
export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
export type { AppToolApproval } from "./AppToolApproval";
export type { AppToolsConfig } from "./AppToolsConfig";
export type { ApprovalsReviewer } from "./ApprovalsReviewer";
@@ -621,7 +621,7 @@ impl ThreadHistoryBuilder {
receiver_thread_ids: Vec::new(),
prompt: Some(payload.prompt.clone()),
model: Some(payload.model.clone()),
reasoning_effort: Some(payload.reasoning_effort),
reasoning_effort: Some(payload.reasoning_effort.clone()),
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
@@ -656,7 +656,7 @@ impl ThreadHistoryBuilder {
receiver_thread_ids,
prompt: Some(payload.prompt.clone()),
model: Some(payload.model.clone()),
reasoning_effort: Some(payload.reasoning_effort),
reasoning_effort: Some(payload.reasoning_effort.clone()),
agents_states,
});
}
@@ -642,9 +642,32 @@ pub struct PluginDetail {
pub skills: Vec<SkillSummary>,
pub hooks: Vec<PluginHookSummary>,
pub apps: Vec<AppSummary>,
pub app_templates: Vec<AppTemplateSummary>,
pub mcp_servers: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[ts(export_to = "v2/")]
pub enum AppTemplateUnavailableReason {
NotConfiguredForWorkspace,
NoActiveWorkspace,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AppTemplateSummary {
pub template_id: String,
pub name: String,
pub description: Option<String>,
pub canonical_connector_id: Option<String>,
pub logo_url: Option<String>,
pub logo_url_dark: Option<String>,
pub materialized_app_ids: Vec<String>,
pub reason: Option<AppTemplateUnavailableReason>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+2 -2
View File
@@ -188,11 +188,11 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
- `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. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. 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 currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
- `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`).
@@ -316,9 +316,8 @@ impl ExternalAgentConfigService {
Some(self.external_agent_home.as_path()),
mcp_settings.as_ref(),
)?;
let mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
if !is_empty_toml_table(&migrated_mcp) {
let mut should_include = true;
if target_config.exists() {
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
@@ -328,10 +327,10 @@ impl ExternalAgentConfigService {
invalid_data_error(format!("invalid existing config.toml: {err}"))
})?
};
should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?;
mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?;
}
if should_include {
if !mcp_server_names.is_empty() {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
@@ -341,7 +340,7 @@ impl ExternalAgentConfigService {
),
cwd: cwd.clone(),
details: Some(MigrationDetails {
mcp_servers: named_migrations(mcp_server_names.clone()),
mcp_servers: named_migrations(mcp_server_names),
..Default::default()
}),
});
@@ -863,7 +862,7 @@ impl ExternalAgentConfigService {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
if merge_missing_toml_values(&mut existing, &migrated)? {
if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() {
write_toml_file(&target_config, &existing)?;
}
Ok(())
@@ -1538,6 +1537,43 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) ->
}
}
fn merge_missing_mcp_servers(
existing: &mut TomlValue,
incoming: &TomlValue,
) -> io::Result<Vec<String>> {
let existing_root = existing
.as_table_mut()
.ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?;
let incoming_root = incoming
.as_table()
.ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?;
let Some(incoming_servers) = incoming_root.get("mcp_servers") else {
return Ok(Vec::new());
};
let incoming_servers = incoming_servers
.as_table()
.ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?;
let Some(existing_servers) = existing_root.get_mut("mcp_servers") else {
existing_root.insert(
"mcp_servers".to_string(),
TomlValue::Table(incoming_servers.clone()),
);
return Ok(incoming_servers.keys().cloned().collect());
};
let Some(existing_servers) = existing_servers.as_table_mut() else {
return Ok(Vec::new());
};
let mut merged_server_names = Vec::new();
for (server_name, incoming_server) in incoming_servers {
if !existing_servers.contains_key(server_name) {
existing_servers.insert(server_name.clone(), incoming_server.clone());
merged_server_names.push(server_name.clone());
}
}
Ok(merged_server_names)
}
fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
let serialized = toml::to_string_pretty(value)
.map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;
@@ -638,6 +638,124 @@ Research with Codex carefully."""
assert_eq!(agent, expected_agent);
}
#[tokio::test]
async fn import_repo_mcp_preserves_existing_same_named_server() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"mixedTransport": {
"command": "mcp-remote-proxy",
"args": [
"https://example.com/mixed-transport",
"--transport",
"http"
],
"url": "https://example.com/mixed-transport"
}
}
}"#,
)
.expect("write mcp");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
let existing_config = r#"[mcp_servers.mixedTransport]
url = "https://example.com/mixed-transport"
"#;
fs::write(
repo_root.join(".codex").join("config.toml"),
existing_config,
)
.expect("write config");
let service = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
);
assert_eq!(
service
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect"),
Vec::<ExternalAgentConfigMigrationItem>::new()
);
service
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await
.expect("import");
assert_eq!(
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
existing_config
);
}
#[tokio::test]
async fn detect_repo_mcp_lists_only_missing_servers() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"docs": {"command": "docs-server"},
"mixedTransport": {"command": "mcp-remote-proxy"}
}
}"#,
)
.expect("write mcp");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
fs::write(
repo_root.join(".codex").join("config.toml"),
r#"[mcp_servers.mixedTransport]
url = "https://example.com/mixed-transport"
"#,
)
.expect("write config");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
"Migrate MCP servers from {} into {}",
repo_root.display(),
repo_root.join(".codex").join("config.toml").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
mcp_servers: vec![NamedMigration {
name: "docs".to_string(),
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, external_agent_home, codex_home) = fixture_paths();
+1 -2
View File
@@ -455,14 +455,13 @@ impl MessageProcessor {
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(
&config.plugins_config_input(),
auth_manager.clone(),
auth_manager,
Some(on_effective_plugins_changed),
);
}
let config_processor = ConfigRequestProcessor::new(
outgoing.clone(),
config_manager.clone(),
auth_manager,
thread_manager.clone(),
analytics_events_client,
);
+2 -2
View File
@@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset(
efforts: Vec<ReasoningEffortPreset>,
) -> Vec<ReasoningEffortOption> {
efforts
.iter()
.into_iter()
.map(|preset| ReasoningEffortOption {
reasoning_effort: preset.effort,
description: preset.description.to_string(),
description: preset.description,
})
.collect()
}
@@ -30,6 +30,8 @@ use codex_app_server_protocol::AdditionalContextKind;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::AppTemplateSummary;
use codex_app_server_protocol::AppTemplateUnavailableReason;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AskForApproval;
@@ -7,7 +7,6 @@ use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::ComputerUseRequirements;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -29,9 +28,7 @@ use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_chatgpt::connectors;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
@@ -42,7 +39,6 @@ use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_plugin::PluginId;
use codex_protocol::config_types::WebSearchMode;
@@ -50,21 +46,18 @@ use serde_json::json;
use std::path::PathBuf;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"auth_elicitation",
"memories",
"mentions_v2",
"plugins",
"remote_control",
"remote_plugin",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[derive(Clone)]
pub(crate) struct ConfigRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
}
@@ -73,14 +66,12 @@ impl ConfigRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
) -> Self {
Self {
outgoing,
config_manager,
auth_manager,
thread_manager,
analytics_events_client,
}
@@ -151,7 +142,6 @@ impl ConfigRequestProcessor {
request_id: ConnectionRequestId,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true);
let response = self
.handle_config_mutation_result(self.set_experimental_feature_enablement(params).await)
.await?;
@@ -161,10 +151,6 @@ impl ConfigRequestProcessor {
ClientResponsePayload::ExperimentalFeatureEnablementSet(response),
)
.await;
if should_refresh_apps_list {
self.refresh_apps_list_after_experimental_feature_enablement_set()
.await;
}
Ok(None)
}
@@ -195,71 +181,6 @@ impl ConfigRequestProcessor {
Ok(response)
}
async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) {
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => config,
Err(error) => {
tracing::warn!(
"failed to load config for apps list refresh after experimental feature enablement: {}",
error.message
);
return;
}
};
let auth = self.auth_manager.auth().await;
if !config.features.apps_enabled_for_auth(
auth.as_ref()
.is_some_and(codex_login::CodexAuth::uses_codex_backend),
) {
return;
}
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
tokio::spawn(async move {
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
/*force_refetch*/ true,
Arc::clone(&environment_manager),
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
tracing::warn!(
"failed to force-refresh directory apps after experimental feature enablement: {err:#}"
);
return;
}
};
let accessible_connectors = match accessible_connectors_result {
Ok(status) => status.connectors,
Err(err) => {
tracing::warn!(
"failed to force-refresh accessible apps after experimental feature enablement: {err:#}"
);
return;
}
};
let data = connectors::with_app_enabled_state(
connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
),
&config,
);
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
});
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
@@ -317,28 +238,19 @@ impl ConfigRequestProcessor {
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(invalid_request(format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
)));
let ExperimentalFeatureEnablementSetParams { mut enablement } = params;
let mut invalid_keys = Vec::new();
enablement.retain(|key, _| {
let valid = canonical_feature_for_key(key).is_some()
&& SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str());
if !valid {
invalid_keys.push(key.clone());
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(invalid_request(message));
valid
});
if !invalid_keys.is_empty() {
let invalid_keys = invalid_keys.join(", ");
tracing::warn!("ignoring invalid experimental feature enablement keys: {invalid_keys}");
}
if enablement.is_empty() {
@@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole;
use codex_config::types::McpServerConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason;
use codex_core_plugins::remote::RemotePluginScope;
use codex_core_plugins::remote::is_valid_remote_plugin_id;
use codex_core_plugins::remote::validate_remote_plugin_id;
@@ -1062,6 +1063,7 @@ impl PluginRequestProcessor {
})
.collect(),
apps: app_summaries,
app_templates: Vec::new(),
mcp_servers: outcome.plugin.mcp_server_names,
}
}
@@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info(
detail: RemoteCatalogPluginDetail,
apps: Vec<AppSummary>,
) -> PluginDetail {
let app_templates = detail
.app_templates
.into_iter()
.map(|template| AppTemplateSummary {
template_id: template.template_id,
name: template.name,
description: template.description,
canonical_connector_id: template.canonical_connector_id,
logo_url: template.logo_url,
logo_url_dark: template.logo_url_dark,
materialized_app_ids: template.materialized_app_ids,
reason: template.reason.map(|reason| match reason {
RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => {
AppTemplateUnavailableReason::NotConfiguredForWorkspace
}
RemoteAppTemplateUnavailableReason::NoActiveWorkspace => {
AppTemplateUnavailableReason::NoActiveWorkspace
}
}),
})
.collect();
PluginDetail {
marketplace_name: detail.marketplace_name,
marketplace_path: None,
@@ -2026,7 +2050,8 @@ fn remote_plugin_detail_to_info(
.collect(),
hooks: Vec::new(),
apps,
mcp_servers: Vec::new(),
app_templates,
mcp_servers: detail.mcp_servers,
}
}
@@ -158,7 +158,7 @@ fn merge_persisted_resume_metadata(
typesafe_overrides.model = persisted_metadata.model.clone();
typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone());
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort {
if let Some(reasoning_effort) = persisted_metadata.reasoning_effort.as_ref() {
request_overrides.get_or_insert_with(HashMap::new).insert(
"model_reasoning_effort".to_string(),
serde_json::Value::String(reasoning_effort.to_string()),
@@ -659,12 +659,6 @@ impl ThreadRequestProcessor {
.map(|response| Some(response.into()))
}
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
codex_core::AgentsMdManager::new(config)
.instruction_sources(LOCAL_FS.as_ref())
.await
}
async fn load_thread(
&self,
thread_id: &str,
@@ -1043,7 +1037,6 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let environments = environments.unwrap_or_else(|| {
listener_task_context
.thread_manager
@@ -1113,6 +1106,7 @@ impl ThreadRequestProcessor {
)
.await?;
let instruction_sources = thread.instruction_sources().await;
let config_snapshot = thread
.config_snapshot()
.instrument(tracing::info_span!(
@@ -2524,13 +2518,12 @@ impl ThreadRequestProcessor {
}
};
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let response_history = thread_history.clone();
match self
.thread_manager
.resume_thread_with_history(
config.clone(),
config,
thread_history,
self.auth_manager.clone(),
self.request_trace_context(&request_id).await,
@@ -2553,6 +2546,7 @@ impl ThreadRequestProcessor {
self.outgoing.send_error(request_id, err).await;
return Ok(());
}
let instruction_sources = codex_thread.instruction_sources().await;
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
let Some(rollout_path) = rollout_path else {
let error =
@@ -2853,10 +2847,7 @@ impl ThreadRequestProcessor {
/*include_turns*/ false,
);
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
let mut config_for_instruction_sources = self.config.as_ref().clone();
config_for_instruction_sources.cwd = config_snapshot.cwd.clone();
let instruction_sources =
Self::instruction_sources_from_config(&config_for_instruction_sources).await;
let instruction_sources = existing_thread.instruction_sources().await;
let listener_command_tx = {
let thread_state = thread_state.lock().await;
@@ -3233,7 +3224,6 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let NewThread {
thread_id,
@@ -3284,6 +3274,8 @@ impl ThreadRequestProcessor {
.map_err(|err| core_thread_write_error("inherit source thread name", err))?;
}
let instruction_sources = forked_thread.instruction_sources().await;
// Auto-attach a conversation listener when forking a thread.
log_listener_attach_result(
self.ensure_conversation_listener(
@@ -701,6 +701,7 @@ mod thread_processor_behavior_tests {
},
},
session_source: SessionSource::Cli,
forked_from_thread_id: None,
parent_thread_id: None,
thread_source: None,
};
@@ -203,7 +203,7 @@ pub(crate) fn thread_settings_from_config_snapshot(
model: config_snapshot.model.clone(),
model_provider: config_snapshot.model_provider_id.clone(),
service_tier: config_snapshot.service_tier.clone(),
effort: config_snapshot.reasoning_effort,
effort: config_snapshot.reasoning_effort.clone(),
summary: config_snapshot.reasoning_summary,
collaboration_mode: config_snapshot.collaboration_mode.clone(),
personality: config_snapshot.personality,
@@ -629,7 +629,7 @@ impl TurnRequestProcessor {
profile_workspace_roots: profile_workspace_roots.clone(),
windows_sandbox_level: None,
model: model.clone(),
effort,
effort: effort.clone(),
summary,
service_tier: service_tier.clone(),
collaboration_mode: collaboration_mode.clone(),
@@ -18,7 +18,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
slug: preset.id.clone(),
display_name: preset.display_name.clone(),
description: Some(preset.description.clone()),
default_reasoning_level: Some(preset.default_reasoning_effort),
default_reasoning_level: Some(preset.default_reasoning_effort.clone()),
supported_reasoning_levels: preset.supported_reasoning_efforts.clone(),
shell_type: ConfigShellToolType::ShellCommand,
visibility: if preset.show_in_picker {
@@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
input_modalities: default_input_modalities(),
used_fallback_model_metadata: false,
supports_search_tool: false,
use_responses_lite: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
@@ -1,5 +1,4 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
@@ -28,7 +27,6 @@ use codex_app_server_protocol::AppScreenshot;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -1335,108 +1333,6 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_on() -> Result<()> {
let initial_connectors = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control(
initial_connectors,
Vec::new(),
Duration::ZERO,
Duration::ZERO,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-enable-refresh")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let disable_request = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement: BTreeMap::from([("apps".to_string(), false)]),
})
.await?;
let _disable_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(disable_request)),
)
.await??;
server_control.set_connectors(vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v2".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}]);
server_control.set_tools(vec![connector_tool("alpha", "Alpha App")?]);
let enable_request = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement: BTreeMap::from([("apps".to_string(), true)]),
})
.await?;
let _enable_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(enable_request)),
)
.await??;
let update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
update.data,
vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v2".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}]
);
server_handle.abort();
Ok(())
}
async fn read_app_list_updated_notification(
mcp: &mut TestAppServer,
) -> Result<AppListUpdatedNotification> {
@@ -267,13 +267,15 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let actual =
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
let actual = set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([("auth_elicitation".to_string(), true)]),
)
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([("apps".to_string(), true)]),
enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]),
}
);
@@ -284,7 +286,7 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
.and_then(|features| features.get("auth_elicitation")),
Some(&json!(true))
);
}
@@ -333,16 +335,18 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([("mentions_v2".to_string(), true)]),
)
.await?;
let actual = set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([
("auth_elicitation".to_string(), true),
("memories".to_string(), true),
("plugins".to_string(), true),
("remote_plugin".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
("tool_suggest".to_string(), false),
]),
)
.await?;
@@ -351,11 +355,10 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([
("auth_elicitation".to_string(), true),
("memories".to_string(), true),
("plugins".to_string(), true),
("remote_plugin".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
("tool_suggest".to_string(), false),
]),
}
);
@@ -366,7 +369,14 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
.and_then(|features| features.get("mentions_v2")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("auth_elicitation")),
Some(&json!(true))
);
assert_eq!(
@@ -376,13 +386,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
.and_then(|features| features.get("memories")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("plugins")),
Some(&json!(true))
);
assert_eq!(
config
.additional
@@ -395,13 +398,6 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
.additional
.get("features")
.and_then(|features| features.get("tool_suggest")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_call_mcp_elicitation")),
Some(&json!(false))
);
@@ -432,8 +428,11 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()>
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([("mentions_v2".to_string(), true)]),
)
.await?;
let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?;
assert_eq!(
@@ -449,7 +448,7 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()>
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
.and_then(|features| features.get("mentions_v2")),
Some(&json!(true))
);
@@ -457,36 +456,30 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()>
}
#[tokio::test]
async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> {
async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement: BTreeMap::from([("personality".to_string(), true)]),
})
.await?;
let JSONRPCError { error, .. } = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
let actual = set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([
("apps".to_string(), false),
("auth_elicitation".to_string(), true),
("connectors".to_string(), false),
("personality".to_string(), false),
("plugins".to_string(), false),
("tool_call_mcp_elicitation".to_string(), false),
("unknown_feature".to_string(), true),
]),
)
.await??;
.await?;
assert_eq!(error.code, -32600);
assert!(
error
.message
.contains("unsupported feature enablement `personality`"),
"{}",
error.message
);
assert!(
error.message.contains(
"apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation"
),
"{}",
error.message
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]),
}
);
Ok(())
@@ -44,7 +44,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> {
async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> {
let call_id = "image-run-1";
let server = responses::start_mock_server().await;
mount_image_response(&server).await;
@@ -124,7 +124,13 @@ async fn standalone_image_generation_persists_image_and_returns_it_to_model() ->
"detail": "high",
})
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(1));
let output_hint = output["output"][1]["text"]
.as_str()
.context("image output should include model-visible path hint")?;
assert!(
output_hint.contains(&saved_path.display().to_string()),
"output hint should identify the path core saved"
);
assert!(
!requests[1]
.message_input_texts("developer")
@@ -199,7 +205,7 @@ const result = await tools.image_gen__imagegen({
action: "generate",
prompt: "paint a blue whale",
});
image(result);
generatedImage(result);
"#,
),
responses::ev_completed("resp-1"),
@@ -246,7 +252,12 @@ image(result);
"detail": "high",
})
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(2));
assert!(
output["output"][2]["text"]
.as_str()
.is_some_and(|text| text.contains("Generated images are saved"))
);
assert_eq!(output["output"].as_array().map(Vec::len), Some(3));
Ok(())
}
@@ -1,5 +1,6 @@
use std::time::Duration;
use anyhow::Error;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
@@ -48,11 +49,11 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
.supported_reasoning_efforts
.iter()
.map(|preset| ReasoningEffortOption {
reasoning_effort: preset.effort,
reasoning_effort: preset.effort.clone(),
description: preset.description.clone(),
})
.collect(),
default_reasoning_effort: preset.default_reasoning_effort,
default_reasoning_effort: preset.default_reasoning_effort.clone(),
input_modalities: preset.input_modalities.clone(),
// `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not
// preserve personality placeholders in base instructions, so app-server list results from
@@ -164,10 +165,11 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result<
"slug": "chatgpt-remote-only",
"display_name": "ChatGPT Remote Only",
"description": "Remote-only model for app-server model/list coverage",
"default_reasoning_level": "medium",
"default_reasoning_level": "max",
"supported_reasoning_levels": [
{"effort": "low", "description": "low"},
{"effort": "medium", "description": "medium"}
{"effort": "max", "description": "Maximum"},
{"effort": "low", "description": "Low"},
{"effort": "focused", "description": "Focused"}
],
"shell_type": "shell_command",
"visibility": "list",
@@ -238,10 +240,24 @@ openai_base_url = "{server_uri}/v1"
} = to_response::<ModelListResponse>(response)?;
let mut expected_presets: Vec<ModelPreset> = vec![remote_model.into()];
ModelPreset::mark_default_by_picker_visibility(&mut expected_presets);
let expected_items = expected_presets
let mut expected_items = expected_presets
.iter()
.map(model_from_preset)
.collect::<Vec<_>>();
expected_items[0].supported_reasoning_efforts = vec![
ReasoningEffortOption {
reasoning_effort: "max".parse().map_err(Error::msg)?,
description: "Maximum".to_string(),
},
ReasoningEffortOption {
reasoning_effort: "low".parse().map_err(Error::msg)?,
description: "Low".to_string(),
},
ReasoningEffortOption {
reasoning_effort: "focused".parse().map_err(Error::msg)?,
description: "Focused".to_string(),
},
];
assert_eq!(items, expected_items);
assert!(next_cursor.is_none());
@@ -17,6 +17,8 @@ use axum::http::Uri;
use axum::http::header::AUTHORIZATION;
use axum::routing::get;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppTemplateSummary;
use codex_app_server_protocol::AppTemplateUnavailableReason;
use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
@@ -122,7 +124,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
}
#[tokio::test]
async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> {
async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
@@ -148,22 +150,31 @@ plugins = true
let detail_body = r#"{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"name": "example-plugin",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"version": "1.2.1",
"display_name": "Example Plugin",
"description": "Example plugin",
"app_ids": [],
"keywords": [],
"interface": {
"short_description": "Plan and track work",
"short_description": "Example plugin",
"capabilities": [],
"default_prompt": "Use the legacy Linear prompt",
"default_prompt": "Use the legacy example prompt",
"default_prompts": []
},
"skills": []
"skills": [],
"mcp_servers": [
{
"key": "example-server",
"metadata": {
"command": "example-mcp"
}
}
]
}
}"#;
let installed_body = r#"{
@@ -211,12 +222,15 @@ plugins = true
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
assert_eq!(
response.plugin.summary.id,
"example-plugin@openai-curated-remote"
);
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
);
assert_eq!(response.plugin.summary.name, "linear");
assert_eq!(response.plugin.summary.name, "example-plugin");
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
assert_eq!(response.plugin.summary.share_context, None);
assert_eq!(
@@ -226,7 +240,11 @@ plugins = true
.interface
.as_ref()
.and_then(|interface| interface.default_prompt.clone()),
Some(vec!["Use the legacy Linear prompt".to_string()])
Some(vec!["Use the legacy example prompt".to_string()])
);
assert_eq!(
response.plugin.mcp_servers,
vec!["example-server".to_string()]
);
Ok(())
}
@@ -411,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"app_templates": [
{
"template_id": "templated_apps_GitHubEnterprise",
"name": "GitHub Enterprise",
"description": "Connect GitHub Enterprise",
"canonical_connector_id": "github_enterprise",
"logo_url": "https://example.com/ghe-light.png",
"logo_url_dark": "https://example.com/ghe-dark.png",
"materialized_app_ids": ["asdk_app_ghe"],
"reason": null
},
{
"template_id": "templated_apps_Databricks",
"name": "Databricks",
"description": null,
"canonical_connector_id": null,
"logo_url": null,
"logo_url_dark": null,
"materialized_app_ids": [],
"reason": "NOT_CONFIGURED_FOR_WORKSPACE"
}
],
"keywords": ["issue-tracking", "project management"],
"interface": {
"short_description": "Plan and track work",
@@ -548,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
assert_eq!(response.plugin.skills[0].path, None);
assert_eq!(response.plugin.skills[0].enabled, false);
assert_eq!(response.plugin.apps.len(), 0);
assert_eq!(
response.plugin.app_templates,
vec![
AppTemplateSummary {
template_id: "templated_apps_GitHubEnterprise".to_string(),
name: "GitHub Enterprise".to_string(),
description: Some("Connect GitHub Enterprise".to_string()),
canonical_connector_id: Some("github_enterprise".to_string()),
logo_url: Some("https://example.com/ghe-light.png".to_string()),
logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()),
materialized_app_ids: vec!["asdk_app_ghe".to_string()],
reason: None,
},
AppTemplateSummary {
template_id: "templated_apps_Databricks".to_string(),
name: "Databricks".to_string(),
description: None,
canonical_connector_id: None,
logo_url: None,
logo_url_dark: None,
materialized_app_ids: Vec::new(),
reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace),
},
]
);
Ok(())
}
@@ -490,6 +490,13 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
"forked",
"user",
);
assert_eq!(
event["event_params"]["forked_from_thread_id"],
thread
.forked_from_id
.as_deref()
.expect("forked thread has a source thread")
);
Ok(())
}
@@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let workspace = TempDir::new()?;
let project_agents = workspace.path().join("AGENTS.md");
std::fs::write(&project_agents, "project instructions")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse {
thread,
instruction_sources,
..
} = to_response::<ThreadStartResponse>(start_resp)?;
let project_agents = AbsolutePathBuf::try_from(project_agents)?;
assert_eq!(instruction_sources, vec![project_agents.clone()]);
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "materialize rollout".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
std::fs::remove_file(project_agents.as_path())?;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse {
instruction_sources,
..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(instruction_sources, vec![project_agents]);
Ok(())
}
#[tokio::test]
async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -348,7 +348,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
.collect::<Vec<_>>();
let expected_instruction_sources = vec![
std::fs::canonicalize(global_agents_path)?,
std::fs::canonicalize(project_agents_path)?,
project_agents_path,
]
.into_iter()
.map(normalize_path_for_comparison)
@@ -359,6 +359,83 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
Ok(())
}
#[tokio::test]
async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let global_agents_path = codex_home.path().join("AGENTS.md");
std::fs::write(&global_agents_path, "global instructions")?;
let workspace = TempDir::new()?;
let project_agents_path = workspace.path().join("AGENTS.md");
std::fs::write(project_agents_path, "")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse {
instruction_sources,
..
} = to_response::<ThreadStartResponse>(response)?;
let instruction_sources = instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize(
global_agents_path,
)?)];
assert_eq!(instruction_sources, expected_instruction_sources);
Ok(())
}
#[tokio::test]
async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?;
let workspace = TempDir::new()?;
std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
environments: Some(Vec::new()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse {
instruction_sources,
..
} = to_response::<ThreadStartResponse>(response)?;
assert!(instruction_sources.is_empty());
Ok(())
}
#[cfg(windows)]
fn normalize_path_for_comparison(path: impl AsRef<Path>) -> PathBuf {
let path = path.as_ref();
+56 -2
View File
@@ -20,6 +20,7 @@ use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -177,9 +178,14 @@ pub async fn run_plugin_list(
.is_none_or(|name| marketplace.name == *name)
})
.collect::<Vec<_>>();
let marketplace_sources = configured_marketplace_sources(&plugins_input);
if args.json {
let output = JsonPluginListOutput::from_marketplaces(marketplaces, args.available);
let output = JsonPluginListOutput::from_marketplaces(
marketplaces,
args.available,
&marketplace_sources,
);
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
@@ -269,13 +275,19 @@ impl JsonPluginListOutput {
fn from_marketplaces(
marketplaces: Vec<codex_core_plugins::ConfiguredMarketplace>,
include_available: bool,
marketplace_sources: &HashMap<String, JsonMarketplaceSource>,
) -> Self {
let mut installed = Vec::new();
let mut available = Vec::new();
for marketplace in marketplaces {
let marketplace_source = marketplace_sources.get(&marketplace.name).cloned();
for plugin in marketplace.plugins {
let entry = JsonPluginListEntry::from_configured_plugin(&marketplace.name, plugin);
let entry = JsonPluginListEntry::from_configured_plugin(
&marketplace.name,
marketplace_source.clone(),
plugin,
);
if entry.installed {
installed.push(entry);
} else if include_available {
@@ -301,6 +313,8 @@ struct JsonPluginListEntry {
installed: bool,
enabled: bool,
source: JsonPluginSource,
#[serde(skip_serializing_if = "Option::is_none")]
marketplace_source: Option<JsonMarketplaceSource>,
install_policy: &'static str,
auth_policy: &'static str,
}
@@ -308,6 +322,7 @@ struct JsonPluginListEntry {
impl JsonPluginListEntry {
fn from_configured_plugin(
marketplace_name: &str,
marketplace_source: Option<JsonMarketplaceSource>,
plugin: codex_core_plugins::ConfiguredMarketplacePlugin,
) -> Self {
let version = plugin.installed_version.or(plugin.local_version);
@@ -319,6 +334,7 @@ impl JsonPluginListEntry {
installed: plugin.installed,
enabled: plugin.enabled,
source: JsonPluginSource::from_marketplace_source(plugin.source),
marketplace_source,
install_policy: install_policy_label(plugin.policy.installation),
auth_policy: auth_policy_label(plugin.policy.authentication),
}
@@ -375,6 +391,44 @@ impl JsonPluginSource {
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonMarketplaceSource {
source_type: String,
source: String,
}
fn configured_marketplace_sources(
plugins_input: &PluginsConfigInput,
) -> HashMap<String, JsonMarketplaceSource> {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return HashMap::new();
};
let Some(marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
return HashMap::new();
};
marketplaces
.iter()
.filter_map(|(marketplace_name, marketplace)| {
let source_type = marketplace
.get("source_type")
.and_then(toml::Value::as_str)?;
let source = marketplace.get("source").and_then(toml::Value::as_str)?;
Some((
marketplace_name.clone(),
JsonMarketplaceSource {
source_type: source_type.to_string(),
source: source.to_string(),
},
))
})
.collect()
}
fn install_policy_label(policy: MarketplacePluginInstallPolicy) -> &'static str {
match policy {
MarketplacePluginInstallPolicy::NotAvailable => "NOT_AVAILABLE",
+70
View File
@@ -2,6 +2,7 @@ use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks;
use predicates::prelude::PredicateBooleanExt;
use predicates::str::contains;
use pretty_assertions::assert_eq;
@@ -495,6 +496,7 @@ async fn plugin_list_prints_plugins_in_a_table() -> Result<()> {
async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let plugin_path = source.path().join("plugins").join("sample");
let source_path = source.path().to_string_lossy().into_owned();
let assert = codex_command(codex_home.path())?
.args(["plugin", "list", "--available", "--json"])
@@ -519,6 +521,69 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()
"source": "local",
"path": plugin_path.display().to_string(),
},
"marketplaceSource": {
"sourceType": "local",
"source": source_path,
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
},
],
})
);
Ok(())
}
#[tokio::test]
async fn plugin_list_json_includes_configured_git_marketplace_source() -> Result<()> {
let codex_home = TempDir::new()?;
let marketplace_root = codex_home
.path()
.join(".tmp")
.join("marketplaces")
.join("debug");
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source(&marketplace_root)?;
let update = MarketplaceConfigUpdate {
last_updated: "2026-06-04T08:39:49Z",
last_revision: Some("abc123"),
source_type: "git",
source: "https://example.com/acme/agent-skills.git",
ref_name: None,
sparse_paths: &[],
};
record_user_marketplace(codex_home.path(), "debug", &update)?;
let plugin_path = marketplace_root.join("plugins").join("sample");
let normalized_plugin_path = canonicalize_existing_preserving_symlinks(&plugin_path)?;
let assert = codex_command(codex_home.path())?
.args(["plugin", "list", "--available", "--json"])
.assert()
.success();
let stdout = assert.get_output().stdout.as_slice();
let actual: serde_json::Value = serde_json::from_slice(stdout)?;
assert_eq!(
actual,
json!({
"installed": [],
"available": [
{
"pluginId": "sample@debug",
"name": "sample",
"marketplaceName": "debug",
"version": "1.2.3",
"installed": false,
"enabled": false,
"source": {
"source": "local",
"path": normalized_plugin_path.display().to_string(),
},
"marketplaceSource": {
"sourceType": "git",
"source": "https://example.com/acme/agent-skills.git",
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
},
@@ -533,6 +598,7 @@ async fn plugin_list_json_prints_available_plugins_when_requested() -> Result<()
async fn plugin_list_json_prints_installed_plugins() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let plugin_path = source.path().join("plugins").join("sample");
let source_path = source.path().to_string_lossy().into_owned();
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
@@ -561,6 +627,10 @@ async fn plugin_list_json_prints_installed_plugins() -> Result<()> {
"source": "local",
"path": plugin_path.display().to_string(),
},
"marketplaceSource": {
"sourceType": "local",
"source": source_path,
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
},
+1
View File
@@ -25,6 +25,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint.
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
@@ -129,6 +129,58 @@ pub(super) fn image_callback(
retval.set(v8::undefined(scope).into());
}
pub(super) fn generated_image_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments,
mut retval: v8::ReturnValue<v8::Value>,
) {
let value = if args.length() == 0 {
v8::undefined(scope).into()
} else {
args.get(0)
};
let output_hint = match generated_image_output_hint(scope, value) {
Ok(output_hint) => output_hint,
Err(error_text) => {
throw_type_error(scope, &error_text);
return;
}
};
let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) {
Ok(image_item) => image_item,
Err(()) => return,
};
if let Some(state) = scope.get_slot::<RuntimeState>() {
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
if let Some(text) = output_hint {
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
FunctionCallOutputContentItem::InputText { text },
));
}
}
retval.set(v8::undefined(scope).into());
}
fn generated_image_output_hint(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Result<Option<String>, String> {
let object = v8::Local::<v8::Object>::try_from(value)
.map_err(|_| "generatedImage expects an image generation result object".to_string())?;
let key = v8::String::new(scope, "output_hint")
.ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?;
let output_hint = object
.get(scope, key.into())
.ok_or_else(|| "failed to read generatedImage output_hint".to_string())?;
if output_hint.is_undefined() {
return Ok(None);
}
if !output_hint.is_string() {
return Err("generatedImage output_hint must be a string when provided".to_string());
}
Ok(Some(output_hint.to_rust_string_lossy(scope)))
}
pub(super) fn store_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments,
@@ -1,6 +1,7 @@
use super::RuntimeState;
use super::callbacks::clear_timeout_callback;
use super::callbacks::exit_callback;
use super::callbacks::generated_image_callback;
use super::callbacks::image_callback;
use super::callbacks::load_callback;
use super::callbacks::notify_callback;
@@ -23,6 +24,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?;
let text = helper_function(scope, "text", text_callback)?;
let image = helper_function(scope, "image", image_callback)?;
let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?;
let store = helper_function(scope, "store", store_callback)?;
let load = helper_function(scope, "load", load_callback)?;
let notify = helper_function(scope, "notify", notify_callback)?;
@@ -35,6 +37,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
set_global(scope, global, "setTimeout", set_timeout.into())?;
set_global(scope, global, "text", text.into())?;
set_global(scope, global, "image", image.into())?;
set_global(scope, global, "generatedImage", generated_image.into())?;
set_global(scope, global, "store", store.into())?;
set_global(scope, global, "load", load.into())?;
set_global(scope, global, "notify", notify.into())?;
+38
View File
@@ -1563,6 +1563,44 @@ image({
);
}
#[tokio::test]
async fn generated_image_helper_appends_image_and_output_hint() {
let service = CodeModeService::new();
let response = execute(
&service,
ExecuteRequest {
source: r#"
generatedImage({
image_url: "https://example.com/image.jpg",
output_hint: "generated image save hint",
});
"#
.to_string(),
yield_time_ms: None,
..execute_request("")
},
)
.await;
assert_eq!(
response,
RuntimeResponse::Result {
cell_id: cell_id("1"),
content_items: vec![
FunctionCallOutputContentItem::InputImage {
image_url: "https://example.com/image.jpg".to_string(),
detail: Some(crate::DEFAULT_IMAGE_DETAIL),
},
FunctionCallOutputContentItem::InputText {
text: "generated image save hint".to_string(),
},
],
error_text: None,
}
);
}
#[tokio::test]
async fn image_helper_second_arg_overrides_explicit_object_detail() {
let service = CodeModeService::new();
+10 -7
View File
@@ -110,12 +110,22 @@ pub enum ResponseEvent {
ModelsEtag(String),
}
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ReasoningContext {
Auto,
CurrentTurn,
AllTurns,
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct Reasoning {
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<ReasoningEffortConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<ReasoningSummaryConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<ReasoningContext>,
}
#[derive(Debug, Serialize, Default, Clone, PartialEq)]
@@ -239,11 +249,6 @@ pub struct ResponseCreateWsRequest {
pub client_metadata: Option<HashMap<String, String>>,
}
#[derive(Debug, Serialize)]
pub struct ResponseProcessedWsRequest {
pub response_id: String,
}
pub fn response_create_client_metadata(
client_metadata: Option<HashMap<String, String>>,
trace: Option<&W3cTraceContext>,
@@ -272,8 +277,6 @@ pub fn response_create_client_metadata(
pub enum ResponsesWsRequest {
#[serde(rename = "response.create")]
ResponseCreate(ResponseCreateWsRequest),
#[serde(rename = "response.processed")]
ResponseProcessed(ResponseProcessedWsRequest),
}
pub fn create_text_param_for_request(
@@ -1,6 +1,5 @@
use crate::auth::SharedAuthProvider;
use crate::common::ResponseEvent;
use crate::common::ResponseProcessedWsRequest;
use crate::common::ResponseStream;
use crate::common::ResponsesWsRequest;
use crate::error::ApiError;
@@ -206,40 +205,6 @@ impl ResponsesWebsocketConnection {
self.stream.lock().await.is_none()
}
#[instrument(
name = "responses_websocket.send_response_processed",
level = "info",
skip_all,
fields(transport = "responses_websocket", api.path = "responses")
)]
#[expect(
clippy::await_holding_invalid_type,
reason = "the guard serializes exclusive use of the websocket while sending a request frame"
)]
pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> {
let request =
ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id });
let request_body = serde_json::to_value(&request).map_err(|err| {
ApiError::Stream(format!("failed to encode websocket request: {err}"))
})?;
let mut guard = self.stream.lock().await;
let Some(ws_stream) = guard.as_mut() else {
return Err(ApiError::Stream(
"websocket connection is closed".to_string(),
));
};
send_websocket_request(
ws_stream,
request_body,
self.idle_timeout,
self.telemetry.as_ref(),
/*connection_reused*/ true,
)
.await
}
#[instrument(
name = "responses_websocket.stream_request",
level = "info",
+1 -1
View File
@@ -30,9 +30,9 @@ pub use crate::common::OpenAiVerbosity;
pub use crate::common::RawMemory;
pub use crate::common::RawMemoryMetadata;
pub use crate::common::Reasoning;
pub use crate::common::ReasoningContext;
pub use crate::common::ResponseCreateWsRequest;
pub use crate::common::ResponseEvent;
pub use crate::common::ResponseProcessedWsRequest;
pub use crate::common::ResponseStream;
pub use crate::common::ResponsesApiRequest;
pub use crate::common::ResponsesWsRequest;
@@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() {
input_modalities: default_input_modalities(),
used_fallback_model_metadata: false,
supports_search_tool: false,
use_responses_lite: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
+9
View File
@@ -25,6 +25,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
if feature.id == codex_features::Feature::Artifact {
continue;
}
if feature.id == codex_features::Feature::CodeMode {
validation.properties.insert(
feature.key.to_string(),
schema_gen.subschema_for::<codex_features::FeatureToml<
codex_features::CodeModeConfigToml,
>>(),
);
continue;
}
if feature.id == codex_features::Feature::MultiAgentV2 {
validation.properties.insert(
feature.key.to_string(),
+1
View File
@@ -26,6 +26,7 @@ pub use codex_config::types::TuiPetAnchor;
pub use codex_config::types::UriBasedFileOpener;
pub use codex_core::CodexThread;
pub use codex_core::ForkSnapshot;
pub use codex_core::LoadedAgentsMd;
pub use codex_core::McpManager;
pub use codex_core::NewThread;
pub use codex_core::StartThreadOptions;
+72
View File
@@ -167,6 +167,27 @@ pub struct RemotePluginDetail {
pub app_manifest: Option<JsonValue>,
pub skills: Vec<RemotePluginSkill>,
pub app_ids: Vec<String>,
pub app_templates: Vec<RemoteAppTemplate>,
pub mcp_servers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteAppTemplate {
pub template_id: String,
pub name: String,
pub description: Option<String>,
pub canonical_connector_id: Option<String>,
pub logo_url: Option<String>,
pub logo_url_dark: Option<String>,
pub materialized_app_ids: Vec<String>,
pub reason: Option<RemoteAppTemplateUnavailableReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum RemoteAppTemplateUnavailableReason {
NotConfiguredForWorkspace,
NoActiveWorkspace,
}
#[derive(Debug, Clone, PartialEq)]
@@ -412,11 +433,38 @@ struct RemotePluginReleaseResponse {
app_ids: Vec<String>,
#[serde(default)]
app_manifest: Option<JsonValue>,
#[serde(default, alias = "unavailable_app_templates")]
app_templates: Vec<RemoteAppTemplateResponse>,
#[serde(default)]
keywords: Vec<String>,
interface: RemotePluginReleaseInterfaceResponse,
#[serde(default)]
skills: Vec<RemotePluginSkillResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
mcp_servers: Vec<RemotePluginMcpServerResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginMcpServerResponse {
key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemoteAppTemplateResponse {
template_id: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
canonical_connector_id: Option<String>,
#[serde(default)]
logo_url: Option<String>,
#[serde(default)]
logo_url_dark: Option<String>,
#[serde(default)]
materialized_app_ids: Vec<String>,
#[serde(default)]
reason: Option<RemoteAppTemplateUnavailableReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
@@ -948,6 +996,14 @@ async fn build_remote_plugin_detail(
enabled: !disabled_skill_names.contains(&skill.name),
})
.collect();
let mut mcp_servers = plugin
.release
.mcp_servers
.iter()
.map(|server| server.key.clone())
.collect::<Vec<_>>();
mcp_servers.sort_unstable();
mcp_servers.dedup();
Ok(RemotePluginDetail {
marketplace_name,
@@ -959,6 +1015,22 @@ async fn build_remote_plugin_detail(
app_manifest: plugin.release.app_manifest,
skills,
app_ids: plugin.release.app_ids,
app_templates: plugin
.release
.app_templates
.into_iter()
.map(|template| RemoteAppTemplate {
template_id: template.template_id,
name: template.name,
description: template.description,
canonical_connector_id: template.canonical_connector_id,
logo_url: template.logo_url,
logo_url_dark: template.logo_url_dark,
materialized_app_ids: template.materialized_app_ids,
reason: template.reason,
})
.collect(),
mcp_servers,
})
}
+127 -14
View File
@@ -1,3 +1,4 @@
use std::fs::File;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
@@ -22,8 +23,11 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str =
"https://chatgpt.com/backend-api/plugins/export/curated";
const OPENAI_PLUGINS_OWNER: &str = "openai";
const OPENAI_PLUGINS_REPO: &str = "plugins";
const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git";
const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_SYNC_LOCK_FILE: &str = ".tmp/plugins.sync.lock";
const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
@@ -78,6 +82,8 @@ fn sync_openai_plugins_repo_with_transport_overrides(
api_base_url: &str,
backup_archive_api_url: &str,
) -> Result<String, String> {
let _file_guard = lock_curated_plugins_startup_sync(codex_home)?;
match sync_openai_plugins_repo_via_git(codex_home, git_binary) {
Ok(remote_sha) => {
emit_curated_plugins_startup_sync_metric("git", "success");
@@ -135,6 +141,22 @@ fn sync_openai_plugins_repo_with_transport_overrides(
}
}
fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result<File, String> {
let lock_path = codex_home.join(CURATED_PLUGINS_SYNC_LOCK_FILE);
std::fs::create_dir_all(codex_home.join(".tmp"))
.map_err(|err| format!("failed to create curated plugins sync directory: {err}"))?;
let lock_file = File::options()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|err| format!("failed to open curated plugins sync lock: {err}"))?;
lock_file
.lock()
.map_err(|err| format!("failed to lock curated plugins sync: {err}"))?;
Ok(lock_file)
}
fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result<String, String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
@@ -146,23 +168,30 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu
}
let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?;
let clone_output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--depth")
.arg("1")
.arg("https://github.com/openai/plugins.git")
.arg(staged_repo_dir.path()),
"git clone curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
run_git_in_repo(
staged_repo_dir.path(),
git_binary,
&["init"],
"git init curated plugins repo",
)?;
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?;
if cloned_sha != remote_sha {
if repo_path.join(".git").is_dir() {
fetch_curated_plugins_commit(&repo_path, &remote_sha, git_binary)?;
fetch_curated_plugins_commit_from_source(
staged_repo_dir.path(),
&repo_path,
CURATED_PLUGINS_FETCH_REF,
git_binary,
)?;
} else {
fetch_curated_plugins_commit(staged_repo_dir.path(), &remote_sha, git_binary)?;
}
reset_curated_plugins_checkout(staged_repo_dir.path(), git_binary)?;
let fetched_sha = git_head_sha(staged_repo_dir.path(), git_binary)?;
if fetched_sha != remote_sha {
return Err(format!(
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
"curated plugins fetch HEAD mismatch: expected {remote_sha}, got {fetched_sha}"
));
}
@@ -172,6 +201,90 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu
Ok(remote_sha)
}
fn fetch_curated_plugins_commit(
repo_path: &Path,
remote_sha: &str,
git_binary: &str,
) -> Result<(), String> {
fetch_curated_plugins_commit_from(
repo_path,
OPENAI_PLUGINS_GIT_URL.as_ref(),
remote_sha,
git_binary,
"git fetch curated plugins repo",
)
}
fn fetch_curated_plugins_commit_from_source(
repo_path: &Path,
source_repo_path: &Path,
remote_sha: &str,
git_binary: &str,
) -> Result<(), String> {
fetch_curated_plugins_commit_from(
repo_path,
source_repo_path,
remote_sha,
git_binary,
"git copy fetched curated plugins commit",
)
}
fn fetch_curated_plugins_commit_from(
repo_path: &Path,
source: &Path,
source_revision: &str,
git_binary: &str,
context: &str,
) -> Result<(), String> {
let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}");
let output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.args(["fetch", "--depth", "1", "--no-tags"])
.arg(source)
.arg(fetch_refspec),
context,
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, context)
}
fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> {
run_git_in_repo(
repo_path,
git_binary,
&["reset", "--hard", CURATED_PLUGINS_FETCH_REF],
"git reset curated plugins repo",
)?;
run_git_in_repo(
repo_path,
git_binary,
&["clean", "-fdx"],
"git clean curated plugins repo",
)
}
fn run_git_in_repo(
repo_path: &Path,
git_binary: &str,
args: &[&str],
context: &str,
) -> Result<(), String> {
let output = run_git_command_with_timeout(
Command::new(git_binary)
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.args(args),
context,
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, context)
}
fn sync_openai_plugins_repo_via_http(
codex_home: &Path,
api_base_url: &str,
+291 -58
View File
@@ -3,6 +3,8 @@ use pretty_assertions::assert_eq;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
#[cfg(unix)]
use std::sync::Barrier;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
@@ -95,6 +97,22 @@ fn write_executable_script(path: &Path, contents: &str) {
}
}
#[cfg(unix)]
fn run_git(repo: &Path, args: &[&str]) -> std::process::Output {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("run git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
output
}
async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) {
Mock::given(method("GET"))
.and(path("/repos/openai/plugins"))
@@ -253,30 +271,42 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() {
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_prefers_git_when_available() {
fn concurrent_syncs_serialize_fetches_without_skipping_remote_checks() {
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let invocation_log = bin_dir.path().join("invocations.log");
let sha = "0123456789abcdef0123456789abcdef01234567";
write_executable_script(
&git_path,
&format!(
r#"#!/bin/sh
printf '%s\n' "$*" >> '{}'
if [ "$1" = "ls-remote" ]; then
sleep 1
printf '%s\tHEAD\n' "{sha}"
exit 0
fi
if [ "$1" = "clone" ]; then
dest="$5"
mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin"
cat > "$dest/.agents/plugins/marketplace.json" <<'EOF'
if [ "$1" = "-C" ] && [ "$3" = "init" ]; then
mkdir -p "$2/.git"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then
mkdir -p "$2/.agents/plugins" "$2/plugins/gmail/.codex-plugin"
cat > "$2/.agents/plugins/marketplace.json" <<'EOF'
{{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}}
EOF
printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json"
printf '%s\n' '{{"name":"gmail"}}' > "$2/plugins/gmail/.codex-plugin/plugin.json"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
@@ -285,23 +315,55 @@ if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
fi
echo "unexpected git invocation: $@" >&2
exit 1
"#
"#,
invocation_log.display()
),
);
let synced_sha = sync_openai_plugins_repo_with_transport_overrides(
tmp.path(),
git_path.to_str().expect("utf8 path"),
"http://127.0.0.1:9",
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
.expect("git sync should succeed");
let barrier = Barrier::new(2);
let results = std::thread::scope(|scope| {
let run_sync = || {
barrier.wait();
sync_openai_plugins_repo_with_transport_overrides(
tmp.path(),
git_path.to_str().expect("utf8 path"),
"http://127.0.0.1:9",
"http://127.0.0.1:9/backend-api/plugins/export/curated",
)
};
let first = scope.spawn(run_sync);
let second = scope.spawn(run_sync);
[
first.join().expect("first sync thread"),
second.join().expect("second sync thread"),
]
});
assert_eq!(synced_sha, sha);
assert_eq!(results, [Ok(sha.to_string()), Ok(sha.to_string())]);
let repo_path = curated_plugins_repo_path(tmp.path());
assert!(repo_path.join(".git").is_dir());
assert_curated_gmail_repo(&repo_path);
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
let invocations = std::fs::read_to_string(invocation_log).expect("read invocation log");
assert_eq!(
invocations
.lines()
.filter(|invocation| invocation.starts_with("ls-remote "))
.count(),
2
);
assert_eq!(
invocations
.lines()
.filter(|invocation| invocation.contains(" fetch --depth 1 --no-tags "))
.count(),
1
);
assert!(
!invocations
.lines()
.any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone"))
);
}
#[cfg(unix)]
@@ -328,36 +390,20 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() {
)
.expect("write plugin manifest");
let init_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("init")
.status()
.expect("run git init");
assert!(init_status.success());
let add_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("add")
.arg(".")
.status()
.expect("run git add");
assert!(add_status.success());
let commit_status = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("-c")
.arg("user.name=Codex Test")
.arg("-c")
.arg("user.email=codex@example.com")
.arg("commit")
.arg("-m")
.arg("init")
.status()
.expect("run git commit");
assert!(commit_status.success());
run_git(&work_repo, &["init"]);
run_git(&work_repo, &["add", "."]);
run_git(
&work_repo,
&[
"-c",
"user.name=Codex Test",
"-c",
"user.email=codex@example.com",
"commit",
"-m",
"init",
],
);
std::fs::create_dir_all(remote_repo.parent().expect("remote parent"))
.expect("create remote parent");
@@ -370,14 +416,7 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() {
.expect("run git clone --bare");
assert!(clone_status.success());
let sha_output = Command::new("git")
.arg("-C")
.arg(&work_repo)
.arg("rev-parse")
.arg("HEAD")
.output()
.expect("run git rev-parse");
assert!(sha_output.status.success());
let sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]);
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
@@ -397,10 +436,12 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() {
.tempdir()
.expect("tempdir");
let git_wrapper = bin_dir.path().join("git");
let invocation_log = bin_dir.path().join("invocations.log");
write_executable_script(
&git_wrapper,
&format!(
"#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n",
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n",
invocation_log.display(),
git_config_path.display()
),
);
@@ -416,6 +457,125 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() {
Some(sha.as_str())
);
assert!(!has_plugins_clone_dirs(tmp.path()));
let first_sync_invocation_count = std::fs::read_to_string(&invocation_log)
.expect("read first sync invocations")
.lines()
.count();
let first_sync_invocations =
std::fs::read_to_string(&invocation_log).expect("read first sync invocations");
assert!(
first_sync_invocations
.lines()
.any(|invocation| invocation.contains(" fetch --depth 1 --no-tags "))
);
assert!(
!first_sync_invocations
.lines()
.any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone"))
);
write_openai_curated_marketplace(&work_repo, &["gmail", "linear"]);
run_git(&work_repo, &["add", "."]);
run_git(
&work_repo,
&[
"-c",
"user.name=Codex Test",
"-c",
"user.email=codex@example.com",
"commit",
"-m",
"update",
],
);
let branch_output = run_git(&work_repo, &["symbolic-ref", "--short", "HEAD"]);
let branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string();
let remote_repo = remote_repo.to_str().expect("utf8 remote repo");
let push_ref = format!("HEAD:refs/heads/{branch}");
run_git(&work_repo, &["push", remote_repo, &push_ref]);
let updated_sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]);
let updated_sha = String::from_utf8_lossy(&updated_sha_output.stdout)
.trim()
.to_string();
let synced_sha =
sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path"))
.expect("incremental git sync should succeed");
assert_eq!(synced_sha, updated_sha);
assert!(
curated_plugins_repo_path(tmp.path())
.join("plugins/linear/.codex-plugin/plugin.json")
.is_file()
);
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(updated_sha.as_str())
);
assert!(
!curated_plugins_repo_path(tmp.path())
.join(".git/objects/info/alternates")
.exists()
);
let invocation_log_contents =
std::fs::read_to_string(&invocation_log).expect("read sync invocations");
let incremental_sync_invocations = invocation_log_contents
.lines()
.skip(first_sync_invocation_count)
.collect::<Vec<_>>();
let curated_repo_path = curated_plugins_repo_path(tmp.path());
assert!(incremental_sync_invocations.iter().any(|invocation| {
invocation.starts_with(&format!("-C {} fetch ", curated_repo_path.display()))
&& invocation.contains(" https://github.com/openai/plugins.git ")
&& invocation.contains(updated_sha.as_str())
&& invocation.ends_with(CURATED_PLUGINS_FETCH_REF)
}));
assert!(incremental_sync_invocations.iter().any(|invocation| {
invocation.contains(" fetch --depth 1 --no-tags ")
&& invocation.contains(&format!(" {} ", curated_repo_path.display()))
&& invocation.ends_with(&format!(
"{CURATED_PLUGINS_FETCH_REF}:{CURATED_PLUGINS_FETCH_REF}"
))
}));
assert!(
incremental_sync_invocations
.iter()
.any(|invocation| invocation.ends_with(" init"))
);
assert!(
!incremental_sync_invocations
.iter()
.any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone"))
);
assert!(!incremental_sync_invocations.iter().any(|invocation| {
invocation.starts_with(&format!("-C {} reset ", curated_repo_path.display()))
|| invocation.starts_with(&format!("-C {} clean ", curated_repo_path.display()))
}));
assert!(!has_plugins_clone_dirs(tmp.path()));
let unchanged_sync_invocation_count = invocation_log_contents.lines().count();
let synced_sha =
sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path"))
.expect("unchanged git sync should succeed");
assert_eq!(synced_sha, updated_sha);
let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations");
let unchanged_sync_invocations = invocation_log
.lines()
.skip(unchanged_sync_invocation_count)
.collect::<Vec<_>>();
assert!(
unchanged_sync_invocations
.iter()
.any(|invocation| invocation.starts_with("ls-remote "))
);
assert!(
!unchanged_sync_invocations
.iter()
.any(|invocation| invocation.contains(" fetch "))
);
}
#[tokio::test]
@@ -482,7 +642,7 @@ exit 1
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() {
fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_fetch_failure() {
let tmp = tempdir().expect("tempdir");
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-partial-fail-")
@@ -499,9 +659,11 @@ if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{sha}"
exit 0
fi
if [ "$1" = "clone" ]; then
dest="$5"
mkdir -p "$dest/.git"
if [ "$1" = "-C" ] && [ "$3" = "init" ]; then
mkdir -p "$2/.git"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then
echo "fatal: early EOF" >&2
exit 128
fi
@@ -518,6 +680,77 @@ exit 1
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[cfg(unix)]
#[test]
fn sync_openai_plugins_repo_via_git_preserves_existing_snapshot_on_validation_failure() {
let tmp = tempdir().expect("tempdir");
let repo_path = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&repo_path, &["gmail"]);
std::fs::create_dir_all(repo_path.join(".git")).expect("create git dir");
write_curated_plugin_sha(tmp.path());
let bin_dir = tempfile::Builder::new()
.prefix("fake-git-invalid-update-")
.tempdir()
.expect("tempdir");
let git_path = bin_dir.path().join("git");
let remote_sha = "fedcba9876543210fedcba9876543210fedcba98";
write_executable_script(
&git_path,
&format!(
r#"#!/bin/sh
if [ "$1" = "ls-remote" ]; then
printf '%s\tHEAD\n' "{remote_sha}"
exit 0
fi
if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "rev-parse" ]; then
printf '%s\n' "{TEST_CURATED_PLUGIN_SHA}"
exit 0
fi
if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "fetch" ]; then
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "init" ]; then
mkdir -p "$2/.git"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then
mkdir -p "$2/plugins/linear/.codex-plugin"
printf '%s\n' '{{"name":"linear"}}' > "$2/plugins/linear/.codex-plugin/plugin.json"
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ]; then
printf '%s\n' "{remote_sha}"
exit 0
fi
echo "unexpected git invocation: $@" >&2
exit 1
"#,
repo_path.display(),
repo_path.display(),
),
);
let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path"))
.expect_err("invalid staged checkout should fail");
assert!(err.contains("curated plugins archive missing marketplace manifest"));
assert_curated_gmail_repo(&repo_path);
assert!(!repo_path.join("plugins/linear").exists());
assert_eq!(
read_curated_plugins_sha(tmp.path()).as_deref(),
Some(TEST_CURATED_PLUGIN_SHA)
);
assert!(!has_plugins_clone_dirs(tmp.path()));
}
#[tokio::test]
async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() {
let tmp = tempdir().expect("tempdir");
+31 -18
View File
@@ -355,6 +355,22 @@
},
"type": "object"
},
"CodeModeConfigToml": {
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"excluded_tool_namespaces": {
"description": "Exact tool namespaces to omit from the code-mode nested tool surface.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"ConfigProfile": {
"additionalProperties": false,
"description": "Collection of common configuration options that a user can define as a unit in `config.toml`.",
@@ -410,7 +426,7 @@
"type": "boolean"
},
"code_mode": {
"type": "boolean"
"$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml"
},
"code_mode_only": {
"type": "boolean"
@@ -556,9 +572,6 @@
"request_rule": {
"type": "boolean"
},
"responses_websocket_response_processed": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
@@ -830,6 +843,16 @@
}
]
},
"FeatureToml_for_CodeModeConfigToml": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/CodeModeConfigToml"
}
]
},
"FeatureToml_for_MultiAgentV2ConfigToml": {
"anyOf": [
{
@@ -2480,15 +2503,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -4533,7 +4549,7 @@
"type": "boolean"
},
"code_mode": {
"type": "boolean"
"$ref": "#/definitions/FeatureToml_for_CodeModeConfigToml"
},
"code_mode_only": {
"type": "boolean"
@@ -4679,9 +4695,6 @@
"request_rule": {
"type": "boolean"
},
"responses_websocket_response_processed": {
"type": "boolean"
},
"responses_websockets": {
"type": "boolean"
},
@@ -5205,4 +5218,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+135 -70
View File
@@ -23,11 +23,9 @@ use codex_config::merge_toml_values;
use codex_config::project_root_markers_from_config;
use codex_exec_server::Environment;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE;
use codex_utils_absolute_path::AbsolutePathBuf;
use dunce::canonicalize as normalize_path;
use std::io;
use toml::Value as TomlValue;
use tracing::error;
@@ -37,8 +35,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
/// Preferred local override for AGENTS.md instructions.
pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
/// When both `Config::instructions` and AGENTS.md docs are present, they will
/// be concatenated with the following separator.
/// When both user and project AGENTS.md docs are present, they will be
/// concatenated with the following separator.
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
/// Resolves AGENTS.md files into model-visible user instructions and source
@@ -47,11 +45,6 @@ pub struct AgentsMdManager<'a> {
config: &'a Config,
}
pub(crate) struct LoadedAgentsMd {
pub(crate) contents: String,
pub(crate) path: AbsolutePathBuf,
}
impl<'a> AgentsMdManager<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
@@ -81,10 +74,7 @@ impl<'a> AgentsMdManager<'a> {
let contents = String::from_utf8_lossy(&data);
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(LoadedAgentsMd {
contents: trimmed.to_string(),
path,
});
return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path));
}
}
None
@@ -94,10 +84,10 @@ impl<'a> AgentsMdManager<'a> {
/// single model-visible instruction string.
pub(crate) async fn user_instructions(
&self,
environment: Option<&Environment>,
environment: &Environment,
startup_warnings: &mut Vec<String>,
) -> Option<String> {
let fs = environment?.get_filesystem();
) -> Option<LoadedAgentsMd> {
let fs = environment.get_filesystem();
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
.await
}
@@ -106,22 +96,13 @@ impl<'a> AgentsMdManager<'a> {
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> Option<String> {
) -> Option<LoadedAgentsMd> {
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
let mut output = String::new();
if let Some(instructions) = self.config.user_instructions.clone() {
output.push_str(&instructions);
}
let mut loaded = self.config.user_instructions.clone().unwrap_or_default();
match agents_md_docs {
Ok(Some(docs)) => {
if !output.is_empty() {
output.push_str(AGENTS_MD_SEPARATOR);
}
output.push_str(&docs);
}
Ok(Some(docs)) => loaded.entries.extend(docs.entries),
Ok(None) => {}
Err(e) => {
error!("error trying to find AGENTS.md docs: {e:#}");
@@ -129,50 +110,26 @@ impl<'a> AgentsMdManager<'a> {
};
if self.config.features.enabled(Feature::ChildAgentsMd) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
loaded.entries.push(InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
});
}
if !output.is_empty() {
Some(output)
} else {
None
}
}
/// Returns all instruction source files included in the current config.
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
let mut global_instruction_warnings = Vec::new();
let mut paths = Self::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&self.config.codex_home),
&mut global_instruction_warnings,
)
.await
.map(|loaded| vec![loaded.path])
.unwrap_or_default();
match self.agents_md_paths(fs).await {
Ok(agents_md_paths) => paths.extend(agents_md_paths),
Err(err) => {
tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources");
}
}
paths
(!loaded.is_empty()).then_some(loaded)
}
/// Attempt to locate and load AGENTS.md documentation.
///
/// On success returns `Ok(Some(contents))` where `contents` is the
/// concatenation of all discovered docs. If no documentation file is found
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
/// `Err` so callers can decide how to handle them.
/// On success returns `Ok(Some(loaded))` where `loaded` contains every
/// discovered doc. If no documentation file is found the function returns
/// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can
/// decide how to handle them.
async fn read_agents_md(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> io::Result<Option<String>> {
) -> io::Result<Option<LoadedAgentsMd>> {
let max_total = self.config.project_doc_max_bytes;
if max_total == 0 {
@@ -185,7 +142,7 @@ impl<'a> AgentsMdManager<'a> {
}
let mut remaining: u64 = max_total as u64;
let mut parts: Vec<String> = Vec::new();
let mut loaded = LoadedAgentsMd::default();
for p in paths {
if remaining == 0 {
@@ -221,15 +178,18 @@ impl<'a> AgentsMdManager<'a> {
let text = String::from_utf8_lossy(&data).to_string();
if !text.trim().is_empty() {
parts.push(text);
loaded.entries.push(InstructionEntry {
contents: text,
provenance: InstructionProvenance::Project(p),
});
remaining = remaining.saturating_sub(data.len() as u64);
}
}
if parts.is_empty() {
if loaded.is_empty() {
Ok(None)
} else {
Ok(Some(parts.join("\n\n")))
Ok(Some(loaded))
}
}
@@ -246,10 +206,7 @@ impl<'a> AgentsMdManager<'a> {
return Ok(Vec::new());
}
let mut dir = self.config.cwd.clone();
if let Ok(canon) = normalize_path(&dir) {
dir = AbsolutePathBuf::try_from(canon)?;
}
let dir = self.config.cwd.clone();
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in self.config.config_layer_stack.get_layers(
@@ -348,6 +305,114 @@ impl<'a> AgentsMdManager<'a> {
}
}
/// Model-visible instructions loaded from AGENTS.md files and internal
/// guidance.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LoadedAgentsMd {
/// Ordered instructions and their provenance.
entries: Vec<InstructionEntry>,
}
impl LoadedAgentsMd {
/// Creates loaded instructions containing one user-level AGENTS.md entry.
pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self {
if contents.trim().is_empty() {
return Self::default();
}
Self {
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::User(path),
}],
}
}
/// Creates source-less user instructions for tests.
///
/// This cannot be gated with `#[cfg(test)]` because integration tests
/// compile `codex-core` as a normal dependency without that configuration.
pub fn from_text_for_testing(contents: impl Into<String>) -> Self {
let contents = contents.into();
if contents.trim().is_empty() {
return Self::default();
}
Self {
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::Internal,
}],
}
}
fn is_empty(&self) -> bool {
self.entries
.iter()
.all(|entry| entry.contents.trim().is_empty())
}
/// Returns the concatenated model-visible instruction text.
pub fn text(&self) -> String {
let mut output = String::new();
let mut previous_provenance: Option<&InstructionProvenance> = None;
for entry in &self.entries {
if let Some(previous_provenance) = previous_provenance {
// The project-doc marker tells the model where workspace-scoped
// instructions begin, so it is only needed on the transition
// from user or internal instructions to project instructions.
let separator = match (previous_provenance, &entry.provenance) {
(
InstructionProvenance::User(_) | InstructionProvenance::Internal,
InstructionProvenance::Project(_),
) => AGENTS_MD_SEPARATOR,
_ => "\n\n",
};
output.push_str(separator);
}
output.push_str(&entry.contents);
previous_provenance = Some(&entry.provenance);
}
output
}
/// Returns the AGENTS.md files that supplied instruction entries.
pub fn sources(&self) -> impl Iterator<Item = &AbsolutePathBuf> {
self.entries
.iter()
.filter_map(|entry| entry.provenance.path())
}
}
/// One model-visible instruction and its provenance.
#[derive(Clone, Debug, PartialEq, Eq)]
struct InstructionEntry {
/// Model-visible instruction text.
contents: String,
/// Origin of the instruction.
provenance: InstructionProvenance,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum InstructionProvenance {
/// User-level instructions, normally loaded from CODEX_HOME.
User(AbsolutePathBuf),
/// Workspace instructions discovered from project AGENTS.md files.
Project(AbsolutePathBuf),
/// Instructions without a file source, including internally defined guidance.
Internal,
}
impl InstructionProvenance {
fn path(&self) -> Option<&AbsolutePathBuf> {
match self {
Self::User(path) | Self::Project(path) => Some(path),
Self::Internal => None,
}
}
}
fn warn_invalid_utf8(
path: &AbsolutePathBuf,
data: &[u8],
+235 -34
View File
@@ -5,6 +5,7 @@ use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
use core_test_support::create_directory_symlink;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
@@ -16,6 +17,7 @@ async fn get_user_instructions(config: &Config) -> Option<String> {
AgentsMdManager::new(config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.map(|loaded| loaded.text())
}
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
@@ -53,7 +55,12 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -
config.cwd = root.abs();
config.project_doc_max_bytes = limit;
config.user_instructions = instructions.map(ToOwned::to_owned);
config.user_instructions = instructions.map(|text| {
LoadedAgentsMd::new_user(
text.to_owned(),
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
)
});
config
}
@@ -96,7 +103,12 @@ async fn make_config_with_project_root_markers(
config.cwd = root.abs();
config.project_doc_max_bytes = limit;
config.user_instructions = instructions.map(ToOwned::to_owned);
config.user_instructions = instructions.map(|text| {
LoadedAgentsMd::new_user(
text.to_owned(),
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
)
});
config
}
@@ -115,17 +127,46 @@ async fn no_doc_file_returns_none() {
assert!(res.is_none(), "Expected None when AGENTS.md is absent");
}
#[tokio::test]
async fn no_environment_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
#[test]
fn empty_loaded_instructions_are_empty() {
let source =
AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path");
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions(/*environment*/ None, &mut warnings)
.await;
assert_eq!(
LoadedAgentsMd::new_user(String::new(), source.clone()),
LoadedAgentsMd::default()
);
assert_eq!(
LoadedAgentsMd::new_user(" \n\t".to_string(), source),
LoadedAgentsMd::default()
);
assert_eq!(
LoadedAgentsMd::from_text_for_testing(String::new()),
LoadedAgentsMd::default()
);
assert_eq!(
LoadedAgentsMd::from_text_for_testing(" \n\t"),
LoadedAgentsMd::default()
);
}
assert_eq!(res, None);
#[test]
fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() {
let empty = LoadedAgentsMd {
entries: vec![InstructionEntry {
contents: String::new(),
provenance: InstructionProvenance::Internal,
}],
};
let whitespace = LoadedAgentsMd {
entries: vec![InstructionEntry {
contents: " \n\t".to_string(),
provenance: InstructionProvenance::Internal,
}],
};
assert!(empty.is_empty());
assert!(whitespace.is_empty());
}
/// Small file within the byte-limit is returned unmodified.
@@ -161,8 +202,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
.await
.expect("global doc expected");
assert_eq!(loaded.contents, "global\u{FFFD} doc");
assert_eq!(loaded.path, path);
assert_eq!(
loaded,
LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone())
);
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
}
@@ -177,11 +220,11 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
let res = AgentsMdManager::new(&config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("doc expected");
.expect("doc expected")
.text();
assert_eq!(res, "project\u{FFFD} doc");
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
assert_invalid_utf8_warning(&warnings, "Project", &canonical_path);
assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path());
}
/// Oversize file is truncated to `project_doc_max_bytes`.
@@ -272,6 +315,30 @@ async fn merges_existing_instructions_with_agents_md() {
assert_eq!(res, expected);
}
#[tokio::test]
async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
"user instructions".to_string(),
));
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
assert_eq!(
loaded.text(),
format!("user instructions{AGENTS_MD_SEPARATOR}project doc")
);
assert_eq!(loaded.sources().collect::<Vec<_>>(), vec![&project_agents]);
}
/// If there are existing system instructions but AGENTS.md docs are
/// missing we expect the original instructions to be returned unchanged.
#[tokio::test]
@@ -310,8 +377,32 @@ async fn concatenates_root_and_cwd_docs() {
let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await;
cfg.cwd = nested.abs();
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("doc expected");
let root_agents = repo.path().join("AGENTS.md").abs();
let crate_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "root doc".to_string(),
provenance: InstructionProvenance::Project(root_agents.clone()),
},
InstructionEntry {
contents: "crate doc".to_string(),
provenance: InstructionProvenance::Project(crate_agents.clone()),
},
],
};
assert_eq!(loaded, expected);
assert_eq!(loaded.text(), "root doc\n\ncrate doc");
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&root_agents, &crate_agents]
);
}
#[tokio::test]
@@ -334,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
cfg.cwd = nested.abs();
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
let expected_parent = AbsolutePathBuf::try_from(
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"),
)
.expect("absolute parent doc path");
let expected_child = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"),
)
.expect("absolute child doc path");
let expected_parent = root.path().join("AGENTS.md").abs();
let expected_child = cfg.cwd.join("AGENTS.md");
assert_eq!(discovery.len(), 2);
assert_eq!(discovery[0], expected_parent);
assert_eq!(discovery[1], expected_child);
@@ -350,6 +435,58 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
assert_eq!(res, "parent doc\n\nchild doc");
}
#[tokio::test]
async fn agents_md_paths_preserve_symlinked_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = tmp.path().join("target");
fs::create_dir(&target).unwrap();
fs::write(target.join("AGENTS.md"), "project doc").unwrap();
let linked_cwd = tmp.path().join("linked");
create_directory_symlink(&target, &linked_cwd);
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.cwd = linked_cwd.abs();
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]);
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "project doc");
}
#[tokio::test]
async fn child_agents_message_after_global_instructions_uses_plain_separator() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents),
},
InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
},
],
};
assert_eq!(loaded, expected);
assert_eq!(
loaded.text(),
format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
);
}
#[tokio::test]
async fn instruction_sources_include_global_before_agents_md_docs() {
let tmp = tempfile::tempdir().expect("tempdir");
@@ -360,15 +497,79 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
fs::create_dir_all(&cfg.codex_home).unwrap();
fs::write(&global_agents, "global doc").unwrap();
let sources = AgentsMdManager::new(&cfg)
.instruction_sources(LOCAL_FS.as_ref())
.await;
let project_agents = AbsolutePathBuf::try_from(
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
)
.expect("absolute project doc path");
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
assert_eq!(sources, vec![global_agents, project_agents]);
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents.clone()),
},
InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
},
],
};
assert_eq!(loaded, expected);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
);
assert_eq!(
loaded.text(),
format!("global doc{AGENTS_MD_SEPARATOR}project doc")
);
}
#[tokio::test]
async fn child_agents_message_after_project_docs_is_not_an_instruction_source() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
fs::create_dir_all(&cfg.codex_home).unwrap();
fs::write(&global_agents, "global doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents.clone()),
},
InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
},
InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
},
],
};
assert_eq!(loaded, expected);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
);
assert_eq!(
loaded.text(),
format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
);
}
/// AGENTS.override.md is preferred over AGENTS.md when both are present.
+13 -21
View File
@@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory;
use codex_api::RealtimeCallClient as ApiRealtimeCallClient;
use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig;
use codex_api::Reasoning;
use codex_api::ReasoningContext;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
use codex_api::ResponseCreateWsRequest;
@@ -100,7 +101,6 @@ use tokio::sync::oneshot::error::TryRecvError;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use tracing::instrument;
use tracing::trace;
use tracing::warn;
@@ -610,6 +610,7 @@ impl ModelClient {
reasoning: effort.map(|effort| Reasoning {
effort: Some(effort),
summary: None,
context: None,
}),
};
@@ -722,12 +723,17 @@ impl ModelClient {
) -> Option<Reasoning> {
if model_info.supports_reasoning_summaries {
Some(Reasoning {
effort: effort.or(model_info.default_reasoning_level),
effort: effort.or_else(|| model_info.default_reasoning_level.clone()),
summary: if summary == ReasoningSummaryConfig::None {
None
} else {
Some(summary)
},
// When Responses Lite is disabled, omit context so Responses uses the default,
// which is currently `current_turn`.
context: model_info
.use_responses_lite
.then_some(ReasoningContext::AllTurns),
})
} else {
None
@@ -776,7 +782,7 @@ impl ModelClient {
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: prompt.parallel_tool_calls,
parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite,
reasoning,
store: provider.is_azure_responses_endpoint(),
stream: true,
@@ -967,18 +973,6 @@ impl ModelClientSession {
.set_connection_reused(/*connection_reused*/ false);
}
pub(crate) async fn send_response_processed(&self, response_id: &str) {
let Some(connection) = self.websocket_session.connection.as_ref() else {
return;
};
if let Err(err) = connection
.send_response_processed(response_id.to_string())
.await
{
debug!("failed to send response.processed websocket request: {err}");
}
}
#[allow(clippy::too_many_arguments)]
/// Builds shared Responses API transport options and request-body options.
///
@@ -1280,7 +1274,7 @@ impl ModelClientSession {
&client_setup.api_provider,
prompt,
model_info,
effort,
effort.clone(),
summary,
service_tier.clone(),
)?;
@@ -1389,7 +1383,7 @@ impl ModelClientSession {
&client_setup.api_provider,
prompt,
model_info,
effort,
effort.clone(),
summary,
service_tier.clone(),
)?;
@@ -1608,7 +1602,7 @@ impl ModelClientSession {
prompt,
model_info,
session_telemetry,
effort,
effort.clone(),
summary,
service_tier.clone(),
turn_metadata_header,
@@ -1672,9 +1666,7 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option<Head
/// Meant to be called just before sending the request over the socket, to capture realistic
/// transport timing.
fn stamp_ws_stream_request_start_ms(request: &mut ResponsesWsRequest) {
let ResponsesWsRequest::ResponseCreate(payload) = request else {
return;
};
let ResponsesWsRequest::ResponseCreate(payload) = request;
payload
.client_metadata
.get_or_insert_with(HashMap::new)
+6
View File
@@ -70,6 +70,7 @@ pub struct ThreadConfigSnapshot {
pub personality: Option<Personality>,
pub collaboration_mode: CollaborationMode,
pub session_source: SessionSource,
pub forked_from_thread_id: Option<ThreadId>,
pub parent_thread_id: Option<ThreadId>,
pub thread_source: Option<ThreadSource>,
}
@@ -548,6 +549,11 @@ impl CodexThread {
self.codex.thread_config_snapshot().await
}
/// Returns the files that supplied the thread's loaded model instructions.
pub async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
self.codex.instruction_sources().await
}
pub async fn config(&self) -> Arc<crate::config::Config> {
self.codex.session.get_config().await
}
+1 -1
View File
@@ -573,7 +573,7 @@ async fn drain_to_completed(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_effort.clone(),
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
+1 -1
View File
@@ -242,7 +242,7 @@ async fn run_remote_compact_task_inner_impl(
&prompt,
&turn_context.model_info,
CompactConversationRequestSettings {
effort: turn_context.reasoning_effort,
effort: turn_context.reasoning_effort.clone(),
summary: turn_context.reasoning_summary,
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
None
+6 -21
View File
@@ -26,7 +26,6 @@ use codex_analytics::CompactionImplementation;
use codex_analytics::CompactionPhase;
use codex_analytics::CompactionReason;
use codex_analytics::CompactionTrigger;
use codex_features::Feature;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
@@ -281,7 +280,6 @@ async fn run_remote_compact_task_inner_impl(
);
let RemoteCompactionV2Output {
compaction_output,
response_id,
token_usage,
} = compaction_output_result?;
if let Some(token_usage) = token_usage {
@@ -314,18 +312,11 @@ async fn run_remote_compact_task_inner_impl(
sess.emit_turn_item_completed(turn_context, compaction_item)
.await;
if turn_context
.features
.enabled(Feature::ResponsesWebsocketResponseProcessed)
{
client_session.send_response_processed(&response_id).await;
}
Ok(())
}
struct RemoteCompactionV2Output {
compaction_output: ResponseItem,
response_id: String,
token_usage: Option<TokenUsage>,
}
@@ -348,7 +339,7 @@ async fn run_remote_compaction_request_v2(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_effort.clone(),
turn_context.reasoning_summary,
turn_context.config.service_tier.clone(),
turn_metadata_header,
@@ -409,7 +400,7 @@ async fn collect_compaction_output(
let mut output_item_count = 0usize;
let mut compaction_count = 0usize;
let mut compaction_output = None;
let mut completed_response_id = None;
let mut saw_completed = false;
let mut completed_token_usage = None;
while let Some(event) = stream.next().await {
match event? {
@@ -422,12 +413,8 @@ async fn collect_compaction_output(
}
}
}
ResponseEvent::Completed {
response_id,
token_usage,
..
} => {
completed_response_id = Some(response_id);
ResponseEvent::Completed { token_usage, .. } => {
saw_completed = true;
completed_token_usage = token_usage;
break;
}
@@ -435,12 +422,12 @@ async fn collect_compaction_output(
}
}
let Some(response_id) = completed_response_id else {
if !saw_completed {
return Err(CodexErr::Stream(
"remote compaction v2 stream closed before response.completed".to_string(),
None,
));
};
}
if compaction_count != 1 {
return Err(CodexErr::Fatal(format!(
@@ -453,7 +440,6 @@ async fn collect_compaction_output(
};
Ok(RemoteCompactionV2Output {
compaction_output,
response_id,
token_usage: completed_token_usage,
})
}
@@ -804,7 +790,6 @@ mod tests {
.expect("compaction should be collected");
assert_eq!(output.compaction_output, compaction);
assert_eq!(output.response_id, "resp-compact");
assert_eq!(
output.token_usage,
Some(TokenUsage {
+43 -9
View File
@@ -208,10 +208,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
#[tokio::test]
async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
let codex_home = tempdir()?;
std::fs::write(
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"\n global instructions \n",
)?;
let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_path, "\n global instructions \n")?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
@@ -221,9 +219,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
.await?;
let _ = config.features.enable(Feature::MemoryTool);
let user_instructions = config
.user_instructions
.as_ref()
.expect("global instructions expected");
assert_eq!(user_instructions.text(), "global instructions");
assert_eq!(
config.user_instructions.as_deref(),
Some("global instructions")
user_instructions.sources().collect::<Vec<_>>(),
vec![&global_agents_path]
);
Ok(())
}
@@ -235,7 +238,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"global instructions",
)?;
let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME);
let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_override_path, "local override instructions")?;
let config = Config::load_from_base_config_with_overrides(
@@ -245,9 +248,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R
)
.await?;
let user_instructions = config
.user_instructions
.as_ref()
.expect("global override instructions expected");
assert_eq!(user_instructions.text(), "local override instructions");
assert_eq!(
config.user_instructions.as_deref(),
Some("local override instructions")
user_instructions.sources().collect::<Vec<_>>(),
vec![&global_agents_override_path]
);
Ok(())
}
@@ -476,6 +484,32 @@ async fn load_config_resolves_experimental_request_user_input_enabled() -> std::
Ok(())
}
#[tokio::test]
async fn load_config_resolves_code_mode_config() -> std::io::Result<()> {
let codex_home = tempdir()?;
let config_toml: ConfigToml = toml::from_str(
r#"
[features.code_mode]
enabled = true
excluded_tool_namespaces = ["mcp__codex_apps", "multi_agent_v1"]
"#,
)
.expect("TOML deserialization should succeed");
let config = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.code_mode.excluded_tool_namespaces,
vec!["mcp__codex_apps".to_string(), "multi_agent_v1".to_string()]
);
assert!(config.features.enabled(Feature::CodeMode));
Ok(())
}
#[test]
fn rejects_provider_auth_with_env_key() {
let err = toml::from_str::<ConfigToml>(
+1 -1
View File
@@ -225,7 +225,7 @@ impl ConfigDocument {
);
mutated |= self.write_optional_value(
&["model_reasoning_effort"],
effort.map(|effort| value(effort.to_string())),
effort.as_ref().map(|effort| value(effort.to_string())),
);
mutated
}),
+32 -3
View File
@@ -1,4 +1,5 @@
use crate::agents_md::AgentsMdManager;
pub use crate::agents_md::LoadedAgentsMd;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::path_utils::normalize_for_native_workdir;
@@ -58,6 +59,7 @@ use codex_core_plugins::PluginsConfigInput;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::CodeModeConfigToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
@@ -644,7 +646,7 @@ pub struct Config {
pub show_raw_agent_reasoning: bool,
/// User-provided instructions from AGENTS.md.
pub user_instructions: Option<String>,
pub user_instructions: Option<LoadedAgentsMd>,
/// Base instructions override.
pub base_instructions: Option<String>,
@@ -977,6 +979,9 @@ pub struct Config {
/// Whether to register the experimental request_user_input tool.
pub experimental_request_user_input_enabled: bool,
/// Configuration for the experimental code-mode tool surface.
pub code_mode: CodeModeConfig,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -1029,6 +1034,11 @@ pub struct Config {
pub otel: codex_config::types::OtelConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct CodeModeConfig {
pub excluded_tool_namespaces: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MultiAgentV2Config {
pub max_concurrent_threads_per_session: usize,
@@ -2290,6 +2300,17 @@ fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) ->
.is_none_or(|config| config.enabled)
}
fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
let base = code_mode_toml_config(config_toml.features.as_ref());
CodeModeConfig {
excluded_tool_namespaces: base
.and_then(|config| config.excluded_tool_namespaces.as_ref())
.cloned()
.unwrap_or_default(),
}
}
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
let default = MultiAgentV2Config::default();
@@ -2372,6 +2393,13 @@ fn resolve_optional_prompt_text(
}
}
fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> {
match features?.code_mode.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> {
match features?.multi_agent_v2.as_ref()? {
FeatureToml::Enabled(_) => None,
@@ -2578,8 +2606,7 @@ impl Config {
Some(&codex_home),
&mut startup_warnings,
)
.await
.map(|loaded| loaded.contents);
.await;
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
@@ -3013,6 +3040,7 @@ impl Config {
let web_search_config = resolve_web_search_config(&cfg);
let experimental_request_user_input_enabled =
resolve_experimental_request_user_input_enabled(&cfg);
let code_mode = resolve_code_mode_config(&cfg);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
@@ -3554,6 +3582,7 @@ impl Config {
web_search_mode: constrained_web_search_mode.value,
web_search_config,
experimental_request_user_input_enabled,
code_mode,
use_experimental_unified_exec_tool,
background_terminal_max_timeout,
ghost_snapshot,
@@ -1,6 +1,27 @@
use super::ContextualUserFragment;
use std::fmt::Display;
/// Maximum size of the extension's model-facing generated-image path hint.
const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024;
/// Returns the extension's model-facing hint, or omits it if the path makes it too large.
pub fn extension_image_generation_output_hint(
image_output_dir: impl Display,
image_output_path: impl Display,
) -> Option<String> {
let hint = image_generation_hint(image_output_dir, image_output_path);
(hint.len() <= MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES).then_some(hint)
}
fn image_generation_hint(
image_output_dir: impl Display,
image_output_path: impl Display,
) -> String {
format!(
"Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ImageGenerationInstructions {
image_output_dir: String,
@@ -30,9 +51,6 @@ impl ContextualUserFragment for ImageGenerationInstructions {
}
fn body(&self) -> String {
format!(
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
self.image_output_dir, self.image_output_path
)
image_generation_hint(&self.image_output_dir, &self.image_output_path)
}
}
+1
View File
@@ -44,6 +44,7 @@ pub(crate) use environment_context::EnvironmentContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
pub use image_generation_instructions::extension_image_generation_output_hint;
pub use internal_model_context::InternalContextSource;
pub use internal_model_context::InternalModelContextFragment;
pub use internal_model_context::InvalidInternalContextSource;
+4 -13
View File
@@ -22,7 +22,6 @@ use codex_execpolicy::blocking_append_network_rule;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxKind;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_shell_command::is_dangerous_command::command_might_be_dangerous;
use codex_shell_command::is_safe_command::is_known_safe_command;
@@ -121,7 +120,6 @@ pub(crate) enum ExecPolicyCommandOrigin {
pub(crate) struct UnmatchedCommandContext<'a> {
pub(crate) approval_policy: AskForApproval,
pub(crate) permission_profile: &'a PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) used_complex_parsing: bool,
@@ -242,7 +240,6 @@ pub(crate) struct ExecApprovalRequest<'a> {
pub(crate) command: &'a [String],
pub(crate) approval_policy: AskForApproval,
pub(crate) permission_profile: PermissionProfile,
pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub(crate) sandbox_cwd: &'a Path,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) prefix_rule: Option<Vec<String>>,
@@ -277,7 +274,6 @@ impl ExecPolicyManager {
command,
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
prefix_rule,
@@ -298,7 +294,6 @@ impl ExecPolicyManager {
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
@@ -636,12 +631,12 @@ pub(crate) fn render_decision_for_unmatched_command(
let UnmatchedCommandContext {
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
sandbox_permissions,
used_complex_parsing,
command_origin,
} = context;
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
let is_known_safe = match command_origin {
ExecPolicyCommandOrigin::Generic => is_known_safe_command(command),
#[cfg(windows)]
@@ -652,12 +647,8 @@ pub(crate) fn render_decision_for_unmatched_command(
// On Windows, ReadOnly sandbox is not a real sandbox, so special-case it
// here.
let environment_lacks_sandbox_protections = cfg!(windows)
&& profile_is_managed_read_only(
permission_profile,
file_system_sandbox_policy,
sandbox_cwd,
);
let environment_lacks_sandbox_protections =
cfg!(windows) && profile_is_managed_read_only(permission_profile, sandbox_cwd);
if is_known_safe
&& !used_complex_parsing
@@ -751,9 +742,9 @@ pub(crate) fn render_decision_for_unmatched_command(
fn profile_is_managed_read_only(
permission_profile: &PermissionProfile,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
sandbox_cwd: &Path,
) -> bool {
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
matches!(permission_profile, PermissionProfile::Managed { .. })
&& matches!(
file_system_sandbox_policy.kind,
+2 -76
View File
@@ -104,31 +104,6 @@ async fn write_project_trust_config(
.await
}
fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
}])
}
fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::workspace_write(
&[],
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
)
}
fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::unrestricted()
}
fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
FileSystemSandboxPolicy::external_sandbox()
}
async fn test_config() -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
let config = ConfigBuilder::without_managed_config_for_tests()
@@ -665,7 +640,6 @@ async fn evaluates_bash_lc_inner_commands() {
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -761,7 +735,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() {
command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -785,7 +758,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -809,7 +781,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec![
"python3".to_string(),
@@ -837,7 +808,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches(
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["python3".to_string()]),
},
@@ -862,7 +832,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() {
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -893,7 +862,6 @@ EOF"#
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -927,7 +895,6 @@ EOF"#
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -967,7 +934,6 @@ prefix_rule(
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -986,7 +952,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() {
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1014,7 +979,6 @@ prefix_rule(pattern=["git"], decision="allow")
command: vec![git_path, "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1048,7 +1012,6 @@ prefix_rule(pattern=["git"], decision="prompt")
command: vec![disallowed_git_path.clone(), "status".to_string()],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1075,7 +1038,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
},
@@ -1098,7 +1060,6 @@ async fn exec_approval_requirement_respects_approval_policy() {
command: vec!["rm".to_string()],
approval_policy: AskForApproval::Never,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1126,7 +1087,6 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
mcp_elicitations: true,
}),
permission_profile: &PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
@@ -1137,9 +1097,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() {
}
#[test]
fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() {
let command = vec!["madeup-cmd".to_string()];
let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
assert_eq!(
Decision::Prompt,
@@ -1147,8 +1106,7 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() {
&command,
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &PermissionProfile::Disabled,
file_system_sandbox_policy: &restricted_file_system_policy,
permission_profile: &PermissionProfile::read_only(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
@@ -1169,7 +1127,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() {
UnmatchedCommandContext {
approval_policy: AskForApproval::OnRequest,
permission_profile: &PermissionProfile::workspace_write(),
file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
used_complex_parsing: false,
@@ -1202,7 +1159,6 @@ fn managed_cwd_write_profile_is_not_read_only() {
assert!(!profile_is_managed_read_only(
&permission_profile,
&file_system_sandbox_policy,
Path::new("/tmp/project")
));
}
@@ -1233,7 +1189,6 @@ fn managed_unresolvable_write_profile_is_still_read_only() {
assert!(profile_is_managed_read_only(
&permission_profile,
&file_system_sandbox_policy,
Path::new("/tmp/project")
));
}
@@ -1250,7 +1205,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
prefix_rule: None,
},
@@ -1273,7 +1227,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re
command: vec!["echo".to_string(), "hello".to_string()],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -1303,7 +1256,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -1329,7 +1281,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
},
@@ -1365,7 +1316,6 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision()
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1403,7 +1353,6 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled(
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: None,
@@ -1428,7 +1377,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1454,7 +1402,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1484,7 +1431,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1514,7 +1460,6 @@ async fn request_rule_uses_prefix_rule() {
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
@@ -1547,7 +1492,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands(
command: &command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::RequireEscalated,
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
@@ -1587,7 +1531,6 @@ async fn heuristics_apply_when_other_commands_match_policy() {
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
@@ -1663,7 +1606,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol
command: command.clone(),
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1683,7 +1625,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
command: vec!["rm".to_string()],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1707,7 +1648,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1737,7 +1677,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri
command,
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1761,7 +1700,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1785,7 +1723,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow()
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1816,7 +1753,6 @@ prefix_rule(pattern=["cat"], decision="allow")
command: command.clone(),
approval_policy,
permission_profile: PermissionProfile::workspace_write(),
file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -1848,7 +1784,6 @@ prefix_rule(pattern=["bash"], decision="allow")
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2014,7 +1949,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() {
command: command.clone(),
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2078,7 +2012,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
command: &sneaky_command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
@@ -2103,7 +2036,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
command: &dangerous_command,
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
@@ -2124,7 +2056,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
command: &dangerous_command,
approval_policy: AskForApproval::Never,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: permissions,
prefix_rule: None,
@@ -2146,7 +2077,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() {
permission_profile: PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2171,7 +2101,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() {
permission_profile: PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
},
file_system_sandbox_policy: external_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -2188,7 +2117,6 @@ struct ExecApprovalRequirementScenario {
command: Vec<String>,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
file_system_sandbox_policy: FileSystemSandboxPolicy,
sandbox_permissions: SandboxPermissions,
prefix_rule: Option<Vec<String>>,
}
@@ -2212,7 +2140,6 @@ async fn exec_approval_requirement_for_command(
command,
approval_policy,
permission_profile,
file_system_sandbox_policy,
sandbox_permissions,
prefix_rule,
} = test;
@@ -2224,7 +2151,6 @@ async fn exec_approval_requirement_for_command(
command: &command,
approval_policy,
permission_profile,
file_system_sandbox_policy: &file_system_sandbox_policy,
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions,
prefix_rule,
@@ -15,7 +15,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() {
],
approval_policy: AskForApproval::Never,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -39,7 +38,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() {
],
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: PermissionProfile::read_only(),
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
@@ -81,7 +79,6 @@ fn unmatched_safe_powershell_words_are_allowed() {
UnmatchedCommandContext {
approval_policy: AskForApproval::UnlessTrusted,
permission_profile: &PermissionProfile::read_only(),
file_system_sandbox_policy: &read_only_file_system_sandbox_policy(),
sandbox_cwd: Path::new("/tmp"),
sandbox_permissions: SandboxPermissions::UseDefault,
used_complex_parsing: false,
@@ -110,7 +107,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() {
],
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::Disabled,
file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(),
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
},
+4 -3
View File
@@ -702,7 +702,7 @@ pub(super) async fn run_guardian_review_session(
.supported_reasoning_efforts
.iter()
.any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low),
Some(preset.default_reasoning_effort),
Some(preset.default_reasoning_effort.clone()),
);
(review_model_id.to_string(), reasoning_effort)
} else {
@@ -712,7 +712,8 @@ pub(super) async fn run_guardian_review_session(
.iter()
.any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low),
turn.reasoning_effort
.or(turn.model_info.default_reasoning_level),
.clone()
.or_else(|| turn.model_info.default_reasoning_level.clone()),
);
(
model_override
@@ -725,7 +726,7 @@ pub(super) async fn run_guardian_review_session(
turn.config.as_ref(),
live_network_config.clone(),
guardian_model.as_str(),
guardian_reasoning_effort,
guardian_reasoning_effort.clone(),
);
let guardian_config = match guardian_config {
Ok(config) => config,
+8 -6
View File
@@ -29,6 +29,7 @@ use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::LoadedAgentsMd;
use crate::codex_delegate::run_codex_thread_interactive;
use crate::config::Config;
use crate::config::Constrained;
@@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey {
permissions: Permissions,
developer_instructions: Option<String>,
base_instructions: Option<String>,
user_instructions: Option<String>,
user_instructions: Option<LoadedAgentsMd>,
compact_prompt: Option<String>,
cwd: AbsolutePathBuf,
mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
@@ -166,7 +167,7 @@ impl GuardianReviewSessionReuseKey {
model_context_window: spawn_config.model_context_window,
model_auto_compact_token_limit: spawn_config.model_auto_compact_token_limit,
model_auto_compact_token_limit_scope: spawn_config.model_auto_compact_token_limit_scope,
model_reasoning_effort: spawn_config.model_reasoning_effort,
model_reasoning_effort: spawn_config.model_reasoning_effort.clone(),
model_reasoning_summary: spawn_config.model_reasoning_summary,
permissions: spawn_config.permissions.clone(),
developer_instructions: spawn_config.developer_instructions.clone(),
@@ -657,7 +658,8 @@ async fn run_review_on_session(
let guardian_reasoning_effort = if model_info.supports_reasoning_summaries {
params
.reasoning_effort
.or(model_info.default_reasoning_level)
.clone()
.or_else(|| model_info.default_reasoning_level.clone())
} else {
None
};
@@ -741,7 +743,7 @@ async fn run_review_on_session(
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: params.model.clone(),
reasoning_effort: params.reasoning_effort,
reasoning_effort: params.reasoning_effort.clone(),
developer_instructions: None,
},
}),
@@ -1106,7 +1108,7 @@ mod tests {
async fn test_review_params() -> GuardianReviewSessionParams {
let (session, turn) = crate::session::tests::make_session_and_context().await;
let model = turn.model_info.slug.clone();
let reasoning_effort = turn.reasoning_effort;
let reasoning_effort = turn.reasoning_effort.clone();
let reasoning_summary = turn.reasoning_summary;
let personality = turn.personality;
#[allow(deprecated)]
@@ -1115,7 +1117,7 @@ mod tests {
turn.config.as_ref(),
/*live_network_config*/ None,
model.as_str(),
reasoning_effort,
reasoning_effort.clone(),
)
.expect("guardian config");
+2
View File
@@ -99,6 +99,7 @@ pub(crate) use skills::manager;
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
pub(crate) use skills::skills_load_input_from_config;
mod stream_events_utils;
pub use stream_events_utils::image_generation_artifact_path;
pub mod test_support;
mod unified_exec;
pub mod windows_sandbox;
@@ -130,6 +131,7 @@ pub(crate) mod agents_md;
pub use agents_md::AgentsMdManager;
pub use agents_md::DEFAULT_AGENTS_MD_FILENAME;
pub use agents_md::LOCAL_AGENTS_MD_FILENAME;
pub use agents_md::LoadedAgentsMd;
mod rollout;
pub(crate) mod safety;
mod session_rollout_init_error;
+9 -1
View File
@@ -129,7 +129,7 @@ fn save_config_resolved_fields(
) -> anyhow::Result<()> {
lock_config.web_search = Some(config.web_search_mode.value());
lock_config.model_provider = Some(config.model_provider_id.clone());
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort.clone();
lock_config.model_verbosity = config.model_verbosity;
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
@@ -251,6 +251,14 @@ mod tests {
spec.key
);
}
assert_eq!(
features.code_mode,
Some(FeatureToml::Enabled(
sc.original_config_do_not_use
.features
.enabled(Feature::CodeMode)
))
);
let multi_agent_v2 = features
.multi_agent_v2

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