Compare commits

...

51 Commits

Author SHA1 Message Date
Michael Bolin
ebaef21d54 feat: migrate to new constraint-based loading strategy 2025-12-17 23:18:50 -08:00
Michael Bolin
02c4fcca80 fix: introduce ConfigBuilder 2025-12-17 18:48:40 -08:00
Michael Bolin
a8797019a1 chore: cleanup Config instantiation codepaths (#8226)
This PR does various types of cleanup before I can proceed with more
ambitious changes to config loading.

First, I noticed duplicated code across these two methods:


774bd9e432/codex-rs/core/src/config/mod.rs (L314-L324)


774bd9e432/codex-rs/core/src/config/mod.rs (L334-L344)

This has now been consolidated in
`load_config_as_toml_with_cli_overrides()`.

Further, I noticed that `Config::load_with_cli_overrides()` took two
similar arguments:


774bd9e432/codex-rs/core/src/config/mod.rs (L308-L311)

The difference between `cli_overrides` and `overrides` was not
immediately obvious to me. At first glance, it appears that one should
be able to be expressed in terms of the other, but it turns out that
some fields of `ConfigOverrides` (such as `cwd` and
`codex_linux_sandbox_exe`) are, by design, not configurable via a
`.toml` file or a command-line `--config` flag.

That said, I discovered that many callers of
`Config::load_with_cli_overrides()` were passing
`ConfigOverrides::default()` for `overrides`, so I created two separate
methods:

- `Config::load_with_cli_overrides(cli_overrides: Vec<(String,
TomlValue)>)`
- `Config::load_with_cli_overrides_and_harness_overrides(cli_overrides:
Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides)`

The latter has a long name, as it is _not_ what should be used in the
common case, so the extra typing is designed to draw attention to this
fact. I tried to update the existing callsites to use the shorter name,
where possible.

Further, in the cases where `ConfigOverrides` is used, usually only a
limited subset of fields are actually set, so I updated the declarations
to leverage `..Default::default()` where possible.
2025-12-17 18:01:17 -08:00
Ahmed Ibrahim
774bd9e432 feat: model picker (#8209)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.
2025-12-17 16:12:35 -08:00
iceweasel-oai
25ecd0c2e4 speed and reliability improvements for setting reads ACLs (#8216)
- Batch read ACL creation for online/offline sandbox user
- creates a new ACL helper process that is long-lived and runs in the
background
- uses a mutex so that only one helper process is running at a time.
2025-12-17 15:27:52 -08:00
Ahmed Ibrahim
927a6acbea Load models from static file (#8153)
- Load models from static file as a fallback
- Make API users use this file directly
- Add tests to make sure updates to the file always serialize
2025-12-17 14:34:13 -08:00
iceweasel-oai
a9a7cf3488 download new windows binaries when staging npm package (#8203) 2025-12-17 13:34:32 -08:00
Shijie Rao
df35189366 feat: make list_models non-blocking (#8198)
### Summary
* Make `app_server.list_models` to be non-blocking and consumers (i.e.
extension) can manage the flow themselves.
* Force config to use remote models and therefore fetch codex-auto model
list.
2025-12-17 12:13:16 -08:00
Michael Bolin
1e9babe178 fix: PathBuf -> AbsolutePathBuf in ConfigToml struct (#8205)
We should not have any `PathBuf` fields in `ConfigToml` or any of the
transitive structs we include, as we should use `AbsolutePathBuf`
instead so that we do not have to keep track of the file from which
`ConfigToml` was loaded such that we need it to resolve relative paths
later when the values of `ConfigToml` are used.

I only found two instances of this: `experimental_instructions_file` and
`experimental_compact_prompt_file`. Incidentally, when these were
specified as relative paths, they were resolved against `cwd` rather
than `config.toml`'s parent, which seems wrong to me. I changed the
behavior so they are resolved against the parent folder of the
`config.toml` being parsed, which we get "for free" due to the
introduction of `AbsolutePathBufGuard ` in
https://github.com/openai/codex/pull/7796.

While it is not great to change the behavior of a released feature,
these fields are prefixed with `experimental_`, which I interpret to
mean we have the liberty to change the contract.

For reference:

- `experimental_instructions_file` was introduced in
https://github.com/openai/codex/pull/1803
- `experimental_compact_prompt_file` was introduced in
https://github.com/openai/codex/pull/5959
2025-12-17 12:08:18 -08:00
jif-oai
3d92b443b0 feat: add config to disable warnings around ghost snapshot (#8178) 2025-12-17 18:50:22 +00:00
jif-oai
167553f00d fix: session downgrade (#8196)
The problem is that the `tokio` task own an `Arc` reference of the
session and that this task only exit with the broadcast channel get
closed. But this never get closed if the session is not dropped. So it's
a snake biting his tail basically

The most notable result was that non of the `Drop` implementation were
triggered (temporary files, shell snapshots, session cleaning etc etc)
when closing the session (through a `/new` for example)

The fix is just to weaken the `Arc` and upgrade it on the fly
2025-12-17 10:44:39 -08:00
jif-oai
9f28c6251d fix: proper skills dir cleanup (#8194) 2025-12-17 18:31:03 +00:00
Shijie Rao
3702793882 chore: update listMcpServerStatus to be non-blocking (#8151)
### Summary
* Update `listMcpServerStatus` to be non-blocking by wrapping it with
tokio:spawn.
2025-12-17 10:11:02 -08:00
jif-oai
a2cc0032e0 chore: move back stuff out of beta program (#8199) 2025-12-17 17:58:47 +00:00
jif-oai
f74e0cda92 feat: unified exec footer (#8117)
# With `unified_exec`
Known tools are correctly casted
<img width="1150" height="312" alt="Screenshot 2025-12-16 at 19 27 28"
src="https://github.com/user-attachments/assets/24150ee5-e88d-461b-a459-483c24784196"
/>
If a session exit the turn, we render it with the "Ran ..."
<img width="1168" height="355" alt="Screenshot 2025-12-16 at 19 27 58"
src="https://github.com/user-attachments/assets/3f00b60c-2d57-4f9d-a201-9cc8388957cb"
/>
If a session does not exit during the turn, it is closed at the end of
the turn but this is not rendered
<img width="642" height="342" alt="Screenshot 2025-12-16 at 19 34 37"
src="https://github.com/user-attachments/assets/c2bd9283-7017-4915-ba73-c52199b0b28e"
/>

# Without `unified_exec`
No changes
<img width="740" height="603" alt="Screenshot 2025-12-16 at 19 31 21"
src="https://github.com/user-attachments/assets/ca5d90fe-a9b2-42ba-bcd7-3e98c4ed22e8"
/>
2025-12-17 17:12:04 +00:00
jif-oai
ac6ba286aa feat: experimental menu (#8071)
This will automatically render any `Stage::Beta` features.

The change only gets applied to the *next session*. This started as a
bug but actually this is a good thing to prevent out of distribution
push

<img width="986" height="288" alt="Screenshot 2025-12-15 at 15 38 35"
src="https://github.com/user-attachments/assets/78b7a71d-0e43-4828-a118-91c5237909c7"
/>


<img width="509" height="109" alt="Screenshot 2025-12-15 at 17 35 44"
src="https://github.com/user-attachments/assets/6933de52-9b66-4abf-b58b-a5f26d5747e2"
/>
2025-12-17 17:08:03 +00:00
gt-oai
9352c6b235 feat: Constrain values for approval_policy (#7778)
Constrain `approval_policy` through new `admin_policy` config.

This PR will:
1. Add a `admin_policy` section to config, with a single field (for now)
`allowed_approval_policies`. This list constrains the set of
user-settable `approval_policy`s.
2. Introduce a new `Constrained<T>` type, which combines a current value
and a validator function. The validator function ensures disallowed
values are not set.
3. Change the type of `approval_policy` on `Config` and
`SessionConfiguration` from `AskForApproval` to
`Constrained<AskForApproval>`. The validator function is set by the
values passed into `allowed_approval_policies`.
4. `GenericDisplayRow`: add a `disabled_reason: Option<String>`. When
set, it disables selection of the value and indicates as such in the
menu. This also makes it unselectable with arrow keys or numbers. This
is used in the `/approvals` menu.

Follow ups are:
1. Do the same thing to `sandbox_policy`.
2. Propagate the allowed set of values through app-server for the
extension (though already this should prevent app-server from setting
this values, it's just that we want to disable UI elements that are
unsettable).

Happy to split this PR up if you prefer, into the logical numbered areas
above. Especially if there are parts we want to gavel on separately
(e.g. admin_policy).

Disabled full access:
<img width="1680" height="380" alt="image"
src="https://github.com/user-attachments/assets/1fb61c8c-1fcb-4dc4-8355-2293edb52ba0"
/>

Disabled `--yolo` on startup:
<img width="749" height="76" alt="image"
src="https://github.com/user-attachments/assets/0a1211a0-6eb1-40d6-a1d7-439c41e94ddb"
/>

CODEX-4087
2025-12-17 16:19:27 +00:00
Michael Bolin
de3fa03e1c feat: change ConfigLayerName into a disjoint union rather than a simple enum (#8095)
This attempts to tighten up the types related to "config layers."
Currently, `ConfigLayerEntry` is defined as follows:


bef36f4ae7/codex-rs/core/src/config_loader/state.rs (L19-L25)

but the `source` field is a bit of a lie, as:

- for `ConfigLayerName::Mdm`, it is
`"com.openai.codex/config_toml_base64"`
- for `ConfigLayerName::SessionFlags`, it is `"--config"`
- for `ConfigLayerName::User`, it is `"config.toml"` (just the file
name, not the path to the `config.toml` on disk that was read)
- for `ConfigLayerName::System`, it seems like it is usually
`/etc/codex/managed_config.toml` in practice, though on Windows, it is
`%CODEX_HOME%/managed_config.toml`:


bef36f4ae7/codex-rs/core/src/config_loader/layer_io.rs (L84-L101)

All that is to say, in three out of the four `ConfigLayerName`, `source`
is a `PathBuf` that is not an absolute path (or even a true path).

This PR tries to uplevel things by eliminating `source` from
`ConfigLayerEntry` and turning `ConfigLayerName` into a disjoint union
named `ConfigLayerSource` that has the appropriate metadata for each
variant, favoring the use of `AbsolutePathBuf` where appropriate:

```rust
pub enum ConfigLayerSource {
    /// Managed preferences layer delivered by MDM (macOS only).
    #[serde(rename_all = "camelCase")]
    #[ts(rename_all = "camelCase")]
    Mdm { domain: String, key: String },
    /// Managed config layer from a file (usually `managed_config.toml`).
    #[serde(rename_all = "camelCase")]
    #[ts(rename_all = "camelCase")]
    System { file: AbsolutePathBuf },
    /// Session-layer overrides supplied via `-c`/`--config`.
    SessionFlags,
    /// User config layer from a file (usually `config.toml`).
    #[serde(rename_all = "camelCase")]
    #[ts(rename_all = "camelCase")]
    User { file: AbsolutePathBuf },
}
```
2025-12-17 08:13:59 -08:00
jif-oai
45c164a982 nit: doc (#8186) 2025-12-17 15:29:29 +00:00
jif-oai
2e7e4f6ea6 nit: drop dead branch with unified_exec tool (#8182) 2025-12-17 13:55:13 +00:00
jif-oai
0abaf1b57c nit: prevent race in event rendering (#8181) 2025-12-17 13:24:02 +00:00
jif-oai
2bf57674d6 fix: flaky test 6 (#8175) 2025-12-17 11:59:13 +00:00
jif-oai
813bdb9010 feat: fallback unified_exec to shell_command (#8075) 2025-12-17 10:29:45 +00:00
xl-openai
4897efcced Add public skills + improve repo skill discovery and error UX (#8098)
1. Adds SkillScope::Public end-to-end (core + protocol) and loads skills
from the public cache directory
2. Improves repo skill discovery by searching upward for the nearest
.codex/skills within a git repo
3. Deduplicates skills by name with deterministic ordering to avoid
duplicates across sources
4. Fixes garbled “Skill errors” overlay rendering by preventing pending
history lines from being injected during the modal
5. Updates the project docs “Skills” intro wording to avoid hardcoded
paths
2025-12-17 01:35:49 -08:00
jif-oai
2041b72da7 chore: dedup review result duplication (#8057) 2025-12-17 09:10:51 +00:00
Ahmed Ibrahim
ebd1099b39 fix the models script (#8163)
look at
[failure](https://github.com/openai/codex/actions/runs/20294685253/job/58285812472)
2025-12-16 23:16:54 -08:00
Dylan Hurd
ae3793eb5d chore(apply-patch) unicode scenario (#8141)
## Summary
Adds a unicode scenario, and fills in files on failing scenarios to
ensure directory state is unchanged, for completeness

## Testing
- [x] only changes tests
2025-12-16 22:40:22 -08:00
Celia Chen
70913effc3 [app-server] add new RawResponseItem v2 event (#8152)
``codex/event/raw_response_item` (v1) -> `rawResponseItem/completed`
(v1).

test client log:
````
< {
<   "method": "codex/event/raw_response_item",
<   "params": {
<     "conversationId": "019b29f7-b089-7140-a535-3fe681562c15",
<     "id": "0",
<     "msg": {
<       "item": {
<         "arguments": "{\"command\":\"sed -n '1,160p' Cargo.toml\",\"workdir\":\"/Users/celia/code/codex/codex-rs\"}",
<         "call_id": "call_DrqbdB2jPxezPWc19YVEEt3h",
<         "name": "shell_command",
<         "type": "function_call"
<       },
<       "type": "raw_response_item"
<     }
<   }
< }
< {
<   "method": "rawResponseItem/completed",
<   "params": {
<     "item": {
<       "arguments": "{\"command\":\"sed -n '1,160p' Cargo.toml\",\"workdir\":\"/Users/celia/code/codex/codex-rs\"}",
<       "call_id": "call_DrqbdB2jPxezPWc19YVEEt3h",
<       "name": "shell_command",
<       "type": "function_call"
<     },
<     "threadId": "019b29f7-b089-7140-a535-3fe681562c15",
<     "turnId": "0"
<   }
< }
```
2025-12-17 02:19:30 +00:00
Eric Traut
42b8f28ee8 Fixed resume matching to respect case insensitivity when using WSL mount points (#8000)
This fixes #7995
2025-12-16 16:27:38 -08:00
Ahmed Ibrahim
14d80c35a9 Add user_agent header (#8149)
add `user_agent` header and remove rust tool chain
2025-12-16 16:23:24 -08:00
iceweasel-oai
3a0d9bca64 include new windows binaries in npm package. (#8140)
The Windows Elevated Sandbox uses two new binaries:

codex-windows-sandbox-setup.exe
codex-command-runner.exe

This PR includes them when installing native deps and packaging for npm
2025-12-16 16:14:33 -08:00
Ahmed Ibrahim
cafcd60ef0 Add a workflow for a hardcoded version of models (#8118)
- Fetch the endpoint
- Make a PR
2025-12-16 15:39:36 -08:00
Shijie Rao
600d01b33a chore: update listMcpServers to listMcpServerStatus (#8114)
### Summary
* rename app server `listMcpServers` to `listMcpServerStatuses`.
2025-12-16 15:28:45 -08:00
Josh McKinney
3fbf379e02 docs: refine tui2 viewport roadmap (#8122)
Update the tui2 viewport/history design doc with current status and a
prioritized roadmap (scroll feel, selection/copy correctness, streaming
wrap polish, terminal integration, and longer-term per-cell
interactivity ideas).
2025-12-16 22:16:50 +00:00
Dylan Hurd
a3b137d093 chore(apply-patch) move invocation tests (#8111)
## Summary:
This PR is a pure copy and paste of tests from lib.rs into
invocation.rs, to colocate logic and tests.

## Testing
- [x] Purely a test refactor
2025-12-16 12:49:06 -08:00
Eric Traut
bbc5675974 Revert "chore: review in read-only (#7593)" (#8127)
This reverts commit 291b54a762.

This commit was intended to prevent the model from making code changes
during `/review`, which is sometimes does. Unfortunately, it has other
unintended side effects that cause `/review` to fail in a variety of
ways. See #8115 and #7815. We've therefore decided to revert this
change.
2025-12-16 12:01:54 -08:00
Conor Branagan
51865695e4 feat(sdk): add xhigh reasoning effort support to TypeScript SDK (#8108)
Add "xhigh" to the ModelReasoningEffort type to match the Rust backend
which already supports this reasoning level for models like
gpt-5.1-codex-max.
2025-12-16 11:32:27 -08:00
Koichi Shiraishi
3a32716e1c fix tui2 compile error (#8124)
I'm not sure if this fix is ​​correct for the intended change in #7601,
but at least the compilation error is fixed.

regression: #7601

```
error[E0004]: non-exhaustive patterns: `TuiEvent::Mouse(_)` not covered
   --> tui2/src/update_prompt.rs:57:19
    |
 57 |             match event {
    |                   ^^^^^ pattern `TuiEvent::Mouse(_)` not covered
    |
note: `TuiEvent` defined here
   --> tui2/src/tui.rs:122:10
    |
122 | pub enum TuiEvent {
    |          ^^^^^^^^
...
126 |     Mouse(crossterm::event::MouseEvent),
    |     ----- not covered
    = note: the matched value is of type `TuiEvent`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
    |
 64 ~                 },
 65 +                 TuiEvent::Mouse(_) => todo!()
    |
```

Signed-off-by: Koichi Shiraishi <zchee.io@gmail.com>
2025-12-16 11:31:55 -08:00
Salman Chishti
5ceeaa96b8 Upgrade GitHub Actions for Node 24 compatibility (#8102)
## Summary

Upgrade GitHub Actions to their latest versions to ensure compatibility
with Node 24, as Node 20 will reach end-of-life in April 2026.

## Changes

| Action | Old Version(s) | New Version | Release | Files |
|--------|---------------|-------------|---------|-------|
| `actions/setup-node` |
[`v5`](https://github.com/actions/setup-node/releases/tag/v5) |
[`v6`](https://github.com/actions/setup-node/releases/tag/v6) |
[Release](https://github.com/actions/setup-node/releases/tag/v6) |
ci.yml, rust-release.yml, sdk.yml, shell-tool-mcp-ci.yml,
shell-tool-mcp.yml |

## Context

Per [GitHub's
announcement](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/),
Node 20 is being deprecated and runners will begin using Node 24 by
default starting March 4th, 2026.

### Why this matters

- **Node 20 EOL**: April 2026
- **Node 24 default**: March 4th, 2026
- **Action**: Update to latest action versions that support Node 24

### Security Note

Actions that were previously pinned to commit SHAs remain pinned to SHAs
(updated to the latest release SHA) to maintain the security benefits of
immutable references.

### Testing

These changes only affect CI/CD workflow configurations and should not
impact application functionality. The workflows should be tested by
running them on a branch before merging.
2025-12-16 11:31:25 -08:00
Shijie Rao
b27c702e83 chore: mac codesign refactor (#8085)
### Summary
Similar to our linux and windows codesign, moving mac codesign logic
into its own files.
2025-12-16 11:20:44 -08:00
Dylan Hurd
e290d48264 chore(apply-patch) move invocation parsing (#8110)
lib.rs has grown quite large, and mixes two responsibilities:
1. executing patch operations
2. parsing apply_patch invocations via a shell command

This PR splits out (2) into its own file, so we can work with it more
easily. We are explicitly NOT moving tests in this PR, to ensure
behavior stays the same and we can avoid losing coverage via merge
conflicts. Tests are moved in a subsequent PR.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/8110).
* #8111
* __->__ #8110
2025-12-16 10:30:59 -08:00
iceweasel-oai
3d14da9728 bug fixes and perf improvements for elevated sandbox setup (#8094)
a few fixes based on testing feedback:
* ensure cap_sid file is always written by elevated setup.
* always log to same file whether using elevated sandbox or not
* process potentially slow ACE write operations in parallel
* dedupe write roots so we don't double process any
* don't try to create read/write ACEs on the same directories, due to
race condition
2025-12-16 09:48:29 -08:00
jif-oai
b53889aed5 Revert "feat: unified exec footer" (#8109)
Reverts openai/codex#8067
2025-12-16 17:03:19 +00:00
jif-oai
d7482510b1 nit: trace span for regular task (#8053)
Logs are too spammy

---------

Co-authored-by: Anton Panasenko <apanasenko@openai.com>
2025-12-16 16:53:15 +00:00
jif-oai
021c9a60e5 feat: unified exec footer (#8067)
<img width="452" height="205" alt="Screenshot 2025-12-15 at 17 54 44"
src="https://github.com/user-attachments/assets/9ece0b1c-8387-4dfc-b883-c6a68ea1b663"
/>
2025-12-16 16:52:36 +00:00
jif-oai
c9f5b9a6df feat: do not compact on last user turn (#8060) 2025-12-16 15:36:33 +00:00
jif-oai
ae57e18947 feat: close unified_exec at end of turn (#8052) 2025-12-16 12:16:43 +00:00
sayan-oai
cf44511e77 refactor TUI event loop to enable dropping + recreating crossterm event stream (#7961)
Introduces an `EventBroker` between the crossterm `EventStream` source
and the consumers in the TUI. This enables dropping + recreating the
`crossterm_events` without invalidating the consumer.

Dropping and recreating the crossterm event stream enables us to fully
relinquish `stdin` while the app keeps running. If the stream is not
dropped, it will continue to read from `stdin` even when it is not
actively being polled, potentially stealing input from other processes.
See
[here](https://www.reddit.com/r/rust/comments/1f3o33u/myterious_crossterm_input_after_running_vim/?utm_source=chatgpt.com)
and [here](https://ratatui.rs/recipes/apps/spawn-vim/) for details.

### Tests
Added tests for new `EventBroker` setup, existing tests pass, tested
locally.
2025-12-16 01:14:03 -08:00
Michael Bolin
bef36f4ae7 feat: if .codex is a sub-folder of a writable root, then make it read-only to the sandbox (#8088)
In preparation for in-repo configuration support, this updates
`WritableRoot::get_writable_roots_with_cwd()` to include the `.codex`
subfolder in `WritableRoot.read_only_subpaths`, if it exists, as we
already do for `.git`.

As noted, currently, like `.git`, `.codex` will only be read-only under
macOS Seatbelt, but we plan to bring support to other OSes, as well.

Updated the integration test in `seatbelt.rs` so that it actually
attempts to run the generated Seatbelt commands, verifying that:

- trying to write to `.codex/config.toml` in a writable root fails
- trying to write to `.git/hooks/pre-commit` in a writable root fails
- trying to write to the writable root containing the `.codex` and
`.git` subfolders succeeds
2025-12-15 22:54:43 -08:00
Josh McKinney
f074e5706b refactor(tui2): make transcript line metadata explicit (#8089)
This is a pure refactor only change.

Replace the flattened transcript line metadata from `Option<(usize,
usize)>` to an explicit
`TranscriptLineMeta::{CellLine { cell_index, line_in_cell }, Spacer}`
enum.

This makes spacer rows unambiguous, removes “tuple semantics” from call
sites, and keeps the
scroll anchoring model clearer and aligned with the viewport/history
design notes.

Changes:
- Introduce `TranscriptLineMeta` and update `TranscriptScroll` helpers
to consume it.
- Update `App::build_transcript_lines` and downstream consumers
(scrolling, row classification, ANSI rendering).
- Refresh scrolling module docs to describe anchors + spacer semantics
in context.
- Add tests and docs about the behavior

Tests:
- just fmt
- cargo test -p codex-tui2 tui::scrolling

Manual testing:
- Scroll the inline transcript with mouse wheel + PgUp/PgDn/Home/End,
then resize the terminal while staying scrolled up; verify the same
anchored content stays in view and you don’t jump to bottom
unexpectedly.
- Create a gap case (multiple non-continuation cells) and scroll so a
blank spacer row is at/near the top; verify scrolling doesn’t get stuck
on spacers and still anchors to nearby real lines.
- Start a selection while the assistant is streaming; verify the view
stops auto-following, the selection stays on the intended content, and
subsequent scrolling still behaves normally.
- Exit the TUI and confirm scrollback rendering still styles user rows
as blocks (background padding) and non-user rows as expected.
2025-12-16 05:27:47 +00:00
Dylan Hurd
b9d1a087ee chore(shell_command) fix freeform timeout output (#7791)
## Summary
Adding an additional integration test for timeout_ms

## Testing
- [x] these are tests
2025-12-15 19:26:39 -08:00
171 changed files with 8284 additions and 2782 deletions

View File

@@ -0,0 +1,212 @@
name: macos-code-sign
description: Configure, sign, notarize, and clean up macOS code signing artifacts.
inputs:
target:
description: Rust compilation target triple (e.g. aarch64-apple-darwin).
required: true
apple-certificate:
description: Base64-encoded Apple signing certificate (P12).
required: true
apple-certificate-password:
description: Password for the signing certificate.
required: true
apple-notarization-key-p8:
description: Base64-encoded Apple notarization key (P8).
required: true
apple-notarization-key-id:
description: Apple notarization key ID.
required: true
apple-notarization-issuer-id:
description: Apple notarization issuer ID.
required: true
runs:
using: composite
steps:
- name: Configure Apple code signing
shell: bash
env:
KEYCHAIN_PASSWORD: actions
APPLE_CERTIFICATE: ${{ inputs.apple-certificate }}
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.apple-certificate-password }}
run: |
set -euo pipefail
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
echo "APPLE_CERTIFICATE is required for macOS signing"
exit 1
fi
if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then
echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing"
exit 1
fi
cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12"
echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path"
keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
keychain_args=()
cleanup_keychain() {
if ((${#keychain_args[@]} > 0)); then
security list-keychains -s "${keychain_args[@]}" || true
security default-keychain -s "${keychain_args[0]}" || true
else
security list-keychains -s || true
fi
if [[ -f "$keychain_path" ]]; then
security delete-keychain "$keychain_path" || true
fi
}
while IFS= read -r keychain; do
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
if ((${#keychain_args[@]} > 0)); then
security list-keychains -s "$keychain_path" "${keychain_args[@]}"
else
security list-keychains -s "$keychain_path"
fi
security default-keychain -s "$keychain_path"
security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null
codesign_hashes=()
while IFS= read -r hash; do
[[ -n "$hash" ]] && codesign_hashes+=("$hash")
done < <(security find-identity -v -p codesigning "$keychain_path" \
| sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \
| sort -u)
if ((${#codesign_hashes[@]} == 0)); then
echo "No signing identities found in $keychain_path"
cleanup_keychain
rm -f "$cert_path"
exit 1
fi
if ((${#codesign_hashes[@]} > 1)); then
echo "Multiple signing identities found in $keychain_path:"
printf ' %s\n' "${codesign_hashes[@]}"
cleanup_keychain
rm -f "$cert_path"
exit 1
fi
APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}"
rm -f "$cert_path"
echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV"
echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
echo "::add-mask::$APPLE_CODESIGN_IDENTITY"
- name: Sign macOS binaries
shell: bash
run: |
set -euo pipefail
if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then
echo "APPLE_CODESIGN_IDENTITY is required for macOS signing"
exit 1
fi
keychain_args=()
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
for binary in codex codex-responses-api-proxy; do
path="codex-rs/target/${{ inputs.target }}/release/${binary}"
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
done
- name: Notarize macOS binaries
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }}
run: |
set -euo pipefail
for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do
if [[ -z "${!var:-}" ]]; then
echo "$var is required for notarization"
exit 1
fi
done
notary_key_path="${RUNNER_TEMP}/notarytool.key.p8"
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path"
cleanup_notary() {
rm -f "$notary_key_path"
}
trap cleanup_notary EXIT
notarize_binary() {
local binary="$1"
local source_path="codex-rs/target/${{ inputs.target }}/release/${binary}"
local archive_path="${RUNNER_TEMP}/${binary}.zip"
if [[ ! -f "$source_path" ]]; then
echo "Binary $source_path not found"
exit 1
fi
rm -f "$archive_path"
ditto -c -k --keepParent "$source_path" "$archive_path"
submission_json=$(xcrun notarytool submit "$archive_path" \
--key "$notary_key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--output-format json \
--wait)
status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"')
submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""')
if [[ -z "$submission_id" ]]; then
echo "Failed to retrieve submission ID for $binary"
exit 1
fi
echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}"
if [[ "$status" != "Accepted" ]]; then
echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})"
exit 1
fi
}
notarize_binary "codex"
notarize_binary "codex-responses-api-proxy"
- name: Remove signing keychain
if: ${{ always() }}
shell: bash
env:
APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }}
run: |
set -euo pipefail
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then
keychain_args=()
while IFS= read -r keychain; do
[[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
if ((${#keychain_args[@]} > 0)); then
security list-keychains -s "${keychain_args[@]}"
security default-keychain -s "${keychain_args[0]}"
fi
if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then
security delete-keychain "$APPLE_CODESIGN_KEYCHAIN"
fi
fi

View File

@@ -55,6 +55,30 @@
"path": "codex-responses-api-proxy.exe"
}
}
},
"codex-command-runner": {
"platforms": {
"windows-x86_64": {
"regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-command-runner.exe"
},
"windows-aarch64": {
"regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-command-runner.exe"
}
}
},
"codex-windows-sandbox-setup": {
"platforms": {
"windows-x86_64": {
"regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-windows-sandbox-setup.exe"
},
"windows-aarch64": {
"regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$",
"path": "codex-windows-sandbox-setup.exe"
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
@@ -36,7 +36,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
CODEX_VERSION=0.40.0
# Use a rust-release version that includes all native binaries.
CODEX_VERSION=0.74.0-alpha.3
OUTPUT_DIR="${RUNNER_TEMP}"
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \

View File

@@ -28,9 +28,11 @@ jobs:
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA='${{ github.event.pull_request.base.sha }}'
HEAD_SHA='${{ github.event.pull_request.head.sha }}'
echo "Base SHA: $BASE_SHA"
# List files changed between base and current HEAD (merge-base aware)
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA"...HEAD)
echo "Head SHA: $HEAD_SHA"
# List files changed between base and PR head
mapfile -t files < <(git diff --name-only --no-renames "$BASE_SHA" "$HEAD_SHA")
else
# On push / manual runs, default to running everything
files=("codex-rs/force" ".github/force")

View File

@@ -0,0 +1,51 @@
name: rust-release-prepare
on:
workflow_dispatch:
schedule:
- cron: "0 */4 * * *"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
prepare:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- name: Update models.json
env:
OPENAI_API_KEY: ${{ secrets.CODEX_OPENAI_API_KEY }}
run: |
set -euo pipefail
client_version="99.99.99"
terminal_info="github-actions"
user_agent="codex_cli_rs/99.99.99 (Linux $(uname -r); $(uname -m)) ${terminal_info}"
base_url="${OPENAI_BASE_URL:-https://chatgpt.com/backend-api/codex}"
headers=(
-H "Authorization: Bearer ${OPENAI_API_KEY}"
-H "User-Agent: ${user_agent}"
)
url="${base_url%/}/models?client_version=${client_version}"
curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json
- name: Open pull request (if changed)
uses: peter-evans/create-pull-request@v7
with:
commit-message: "Update models.json"
title: "Update models.json"
body: "Automated update of models.json."
branch: "bot/update-models-json"
reviewers: "pakrym-oai,aibrahim-oai"
delete-branch: true

View File

@@ -129,173 +129,15 @@ jobs:
certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }}
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
name: Configure Apple code signing
shell: bash
env:
KEYCHAIN_PASSWORD: actions
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
echo "APPLE_CERTIFICATE is required for macOS signing"
exit 1
fi
if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then
echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing"
exit 1
fi
cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12"
echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path"
keychain_path="${RUNNER_TEMP}/codex-signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
keychain_args=()
cleanup_keychain() {
if ((${#keychain_args[@]} > 0)); then
security list-keychains -s "${keychain_args[@]}" || true
security default-keychain -s "${keychain_args[0]}" || true
else
security list-keychains -s || true
fi
if [[ -f "$keychain_path" ]]; then
security delete-keychain "$keychain_path" || true
fi
}
while IFS= read -r keychain; do
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
if ((${#keychain_args[@]} > 0)); then
security list-keychains -s "$keychain_path" "${keychain_args[@]}"
else
security list-keychains -s "$keychain_path"
fi
security default-keychain -s "$keychain_path"
security import "$cert_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null
codesign_hashes=()
while IFS= read -r hash; do
[[ -n "$hash" ]] && codesign_hashes+=("$hash")
done < <(security find-identity -v -p codesigning "$keychain_path" \
| sed -n 's/.*\([0-9A-F]\{40\}\).*/\1/p' \
| sort -u)
if ((${#codesign_hashes[@]} == 0)); then
echo "No signing identities found in $keychain_path"
cleanup_keychain
rm -f "$cert_path"
exit 1
fi
if ((${#codesign_hashes[@]} > 1)); then
echo "Multiple signing identities found in $keychain_path:"
printf ' %s\n' "${codesign_hashes[@]}"
cleanup_keychain
rm -f "$cert_path"
exit 1
fi
APPLE_CODESIGN_IDENTITY="${codesign_hashes[0]}"
rm -f "$cert_path"
echo "APPLE_CODESIGN_IDENTITY=$APPLE_CODESIGN_IDENTITY" >> "$GITHUB_ENV"
echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV"
echo "::add-mask::$APPLE_CODESIGN_IDENTITY"
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
name: Sign macOS binaries
shell: bash
run: |
set -euo pipefail
if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then
echo "APPLE_CODESIGN_IDENTITY is required for macOS signing"
exit 1
fi
keychain_args=()
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
for binary in codex codex-responses-api-proxy; do
path="target/${{ matrix.target }}/release/${binary}"
codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path"
done
- if: ${{ matrix.runner == 'macos-15-xlarge' }}
name: Notarize macOS binaries
shell: bash
env:
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
run: |
set -euo pipefail
for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do
if [[ -z "${!var:-}" ]]; then
echo "$var is required for notarization"
exit 1
fi
done
notary_key_path="${RUNNER_TEMP}/notarytool.key.p8"
echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path"
cleanup_notary() {
rm -f "$notary_key_path"
}
trap cleanup_notary EXIT
notarize_binary() {
local binary="$1"
local source_path="target/${{ matrix.target }}/release/${binary}"
local archive_path="${RUNNER_TEMP}/${binary}.zip"
if [[ ! -f "$source_path" ]]; then
echo "Binary $source_path not found"
exit 1
fi
rm -f "$archive_path"
ditto -c -k --keepParent "$source_path" "$archive_path"
submission_json=$(xcrun notarytool submit "$archive_path" \
--key "$notary_key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--output-format json \
--wait)
status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"')
submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""')
if [[ -z "$submission_id" ]]; then
echo "Failed to retrieve submission ID for $binary"
exit 1
fi
echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}"
if [[ "$status" != "Accepted" ]]; then
echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})"
exit 1
fi
}
notarize_binary "codex"
notarize_binary "codex-responses-api-proxy"
name: MacOS code signing
uses: ./.github/actions/macos-code-sign
with:
target: ${{ matrix.target }}
apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }}
apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
- name: Stage artifacts
shell: bash
@@ -380,29 +222,6 @@ jobs:
zstd "${zstd_args[@]}" "$dest/$base"
done
- name: Remove signing keychain
if: ${{ always() && matrix.runner == 'macos-15-xlarge' }}
shell: bash
env:
APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }}
run: |
set -euo pipefail
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" ]]; then
keychain_args=()
while IFS= read -r keychain; do
[[ "$keychain" == "$APPLE_CODESIGN_KEYCHAIN" ]] && continue
[[ -n "$keychain" ]] && keychain_args+=("$keychain")
done < <(security list-keychains | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/"//g')
if ((${#keychain_args[@]} > 0)); then
security list-keychains -s "${keychain_args[@]}"
security default-keychain -s "${keychain_args[0]}"
fi
if [[ -f "$APPLE_CODESIGN_KEYCHAIN" ]]; then
security delete-keychain "$APPLE_CODESIGN_KEYCHAIN"
fi
fi
- uses: actions/upload-artifact@v6
with:
name: ${{ matrix.target }}
@@ -487,7 +306,7 @@ jobs:
run_install: false
- name: Setup Node.js for npm packaging
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
@@ -538,7 +357,7 @@ jobs:
steps:
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
registry-url: "https://registry.npmjs.org"

View File

@@ -19,7 +19,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm

View File

@@ -30,7 +30,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"

View File

@@ -280,7 +280,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
@@ -376,7 +376,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org

View File

@@ -75,6 +75,7 @@ If you dont have the tool:
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
- Avoid mutating process environment in tests; prefer passing environment-derived flags or dependencies from above.
### Integration tests (core)

View File

@@ -20,9 +20,14 @@ PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
"codex-sdk": ["codex"],
}
WINDOWS_ONLY_COMPONENTS: dict[str, list[str]] = {
"codex": ["codex-windows-sandbox-setup", "codex-command-runner"],
}
COMPONENT_DEST_DIR: dict[str, str] = {
"codex": "codex",
"codex-responses-api-proxy": "codex-responses-api-proxy",
"codex-windows-sandbox-setup": "codex",
"codex-command-runner": "codex",
"rg": "path",
}
@@ -103,7 +108,7 @@ def main() -> int:
"pointing to a directory containing pre-installed binaries."
)
copy_native_binaries(vendor_src, staging_dir, native_components)
copy_native_binaries(vendor_src, staging_dir, package, native_components)
if release_version:
staging_dir_str = str(staging_dir)
@@ -232,7 +237,12 @@ def stage_codex_sdk_sources(staging_dir: Path) -> None:
shutil.copy2(license_src, staging_dir / "LICENSE")
def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[str]) -> None:
def copy_native_binaries(
vendor_src: Path,
staging_dir: Path,
package: str,
components: list[str],
) -> None:
vendor_src = vendor_src.resolve()
if not vendor_src.exists():
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
@@ -250,6 +260,9 @@ def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[s
if not target_dir.is_dir():
continue
if "windows" in target_dir.name:
components_set.update(WINDOWS_ONLY_COMPONENTS.get(package, []))
dest_target_dir = vendor_dest / target_dir.name
dest_target_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -36,8 +36,11 @@ class BinaryComponent:
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
dest_dir: str # directory under vendor/<target>/ where the binary is installed
binary_basename: str # executable name inside dest_dir (before optional .exe)
targets: tuple[str, ...] | None = None # limit installation to specific targets
WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target)
BINARY_COMPONENTS = {
"codex": BinaryComponent(
artifact_prefix="codex",
@@ -49,6 +52,18 @@ BINARY_COMPONENTS = {
dest_dir="codex-responses-api-proxy",
binary_basename="codex-responses-api-proxy",
),
"codex-windows-sandbox-setup": BinaryComponent(
artifact_prefix="codex-windows-sandbox-setup",
dest_dir="codex",
binary_basename="codex-windows-sandbox-setup",
targets=WINDOWS_TARGETS,
),
"codex-command-runner": BinaryComponent(
artifact_prefix="codex-command-runner",
dest_dir="codex",
binary_basename="codex-command-runner",
targets=WINDOWS_TARGETS,
),
}
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
@@ -79,7 +94,8 @@ def parse_args() -> argparse.Namespace:
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
help=(
"Limit installation to the specified components."
" May be repeated. Defaults to 'codex' and 'rg'."
" May be repeated. Defaults to codex, codex-windows-sandbox-setup,"
" codex-command-runner, and rg."
),
)
parser.add_argument(
@@ -101,7 +117,12 @@ def main() -> int:
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
vendor_dir.mkdir(parents=True, exist_ok=True)
components = args.components or ["codex", "rg"]
components = args.components or [
"codex",
"codex-windows-sandbox-setup",
"codex-command-runner",
"rg",
]
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
if not workflow_url:
@@ -116,8 +137,7 @@ def main() -> int:
install_binary_components(
artifacts_dir,
vendor_dir,
BINARY_TARGETS,
[name for name in components if name in BINARY_COMPONENTS],
[BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS],
)
if "rg" in components:
@@ -206,23 +226,19 @@ def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
def install_binary_components(
artifacts_dir: Path,
vendor_dir: Path,
targets: Iterable[str],
component_names: Sequence[str],
selected_components: Sequence[BinaryComponent],
) -> None:
selected_components = [BINARY_COMPONENTS[name] for name in component_names if name in BINARY_COMPONENTS]
if not selected_components:
return
targets = list(targets)
if not targets:
return
for component in selected_components:
component_targets = list(component.targets or BINARY_TARGETS)
print(
f"Installing {component.binary_basename} binaries for targets: "
+ ", ".join(targets)
+ ", ".join(component_targets)
)
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
@@ -232,7 +248,7 @@ def install_binary_components(
target,
component,
): target
for target in targets
for target in component_targets
}
for future in as_completed(futures):
installed_path = future.result()

3
codex-rs/Cargo.lock generated
View File

@@ -1000,6 +1000,7 @@ dependencies = [
"codex-login",
"codex-protocol",
"codex-rmcp-client",
"codex-utils-absolute-path",
"codex-utils-json-to-toml",
"core_test_support",
"mcp-types",
@@ -1701,7 +1702,6 @@ dependencies = [
"anyhow",
"arboard",
"assert_matches",
"async-stream",
"base64",
"chrono",
"clap",
@@ -6910,6 +6910,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]

View File

@@ -144,9 +144,9 @@ client_request_definitions! {
response: v2::McpServerOauthLoginResponse,
},
McpServersList => "mcpServers/list" {
params: v2::ListMcpServersParams,
response: v2::ListMcpServersResponse,
McpServerStatusList => "mcpServerStatus/list" {
params: v2::ListMcpServerStatusParams,
response: v2::ListMcpServerStatusResponse,
},
LoginAccount => "account/login/start" {
@@ -525,6 +525,8 @@ server_notification_definitions! {
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
ItemStarted => "item/started" (v2::ItemStartedNotification),
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
/// This event is internal-only. Used by Codex Cloud.
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),

View File

@@ -208,14 +208,68 @@ v2_enum_from_core!(
}
);
// TODO(mbolin): Support in-repo layer.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum ConfigLayerName {
Mdm,
System,
pub enum ConfigLayerSource {
/// Managed preferences layer delivered by MDM (macOS only).
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Mdm {
domain: String,
key: String,
},
/// Managed config layer from a file (usually `managed_config.toml`).
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
System {
file: AbsolutePathBuf,
},
/// User config layer from $CODEX_HOME/config.toml. This layer is special
/// in that it is expected to be:
/// - writable by the user
/// - generally outside the workspace directory
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
User {
file: AbsolutePathBuf,
},
/// Session-layer overrides supplied via `-c`/`--config`.
SessionFlags,
User,
/// `managed_config.toml` was designed to be a config that was loaded
/// as the last layer on top of everything else. This scheme did not quite
/// work out as intended, but we keep this variant as a "best effort" while
/// we phase out `managed_config.toml` in favor of `requirements.toml`.
LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf,
},
LegacyManagedConfigTomlFromMdm,
}
impl ConfigLayerSource {
pub fn precedence(&self) -> i16 {
match self {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
}
}
}
impl PartialOrd for ConfigLayerSource {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.precedence().cmp(&other.precedence()))
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
@@ -288,8 +342,7 @@ pub struct Config {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigLayerMetadata {
pub name: ConfigLayerName,
pub source: String,
pub name: ConfigLayerSource,
pub version: String,
}
@@ -297,8 +350,7 @@ pub struct ConfigLayerMetadata {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ConfigLayer {
pub name: ConfigLayerName,
pub source: String,
pub name: ConfigLayerSource,
pub version: String,
pub config: JsonValue,
}
@@ -335,7 +387,7 @@ pub struct ConfigWriteResponse {
pub status: WriteStatus,
pub version: String,
/// Canonical path to the config file that was written.
pub file_path: String,
pub file_path: AbsolutePathBuf,
pub overridden_metadata: Option<OverriddenMetadata>,
}
@@ -348,6 +400,7 @@ pub enum ConfigWriteErrorCode {
ConfigValidationError,
ConfigPathNotFound,
ConfigSchemaUnknownKey,
UserLayerNotFound,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -761,7 +814,7 @@ pub struct ModelListResponse {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ListMcpServersParams {
pub struct ListMcpServerStatusParams {
/// Opaque pagination cursor returned by a previous call.
pub cursor: Option<String>,
/// Optional page size; defaults to a server-defined value.
@@ -771,7 +824,7 @@ pub struct ListMcpServersParams {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpServer {
pub struct McpServerStatus {
pub name: String,
pub tools: std::collections::HashMap<String, McpTool>,
pub resources: Vec<McpResource>,
@@ -782,8 +835,8 @@ pub struct McpServer {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ListMcpServersResponse {
pub data: Vec<McpServer>,
pub struct ListMcpServerStatusResponse {
pub data: Vec<McpServerStatus>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// If None, there are no more items to return.
pub next_cursor: Option<String>,
@@ -860,6 +913,12 @@ pub struct ThreadStartParams {
pub config: Option<HashMap<String, JsonValue>>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
/// (TODO): Figure out a better way to categorize internal / experimental events & protocols.
#[serde(default)]
pub experimental_raw_events: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -981,6 +1040,7 @@ pub struct SkillsListResponse {
pub enum SkillScope {
User,
Repo,
Public,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1026,6 +1086,7 @@ impl From<CoreSkillScope> for SkillScope {
match value {
CoreSkillScope::User => Self::User,
CoreSkillScope::Repo => Self::Repo,
CoreSkillScope::Public => Self::Public,
}
}
}
@@ -1581,6 +1642,15 @@ pub struct ItemCompletedNotification {
pub turn_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RawResponseItemCompletedNotification {
pub thread_id: String,
pub turn_id: String,
pub item: ResponseItem,
}
// Item-specific progress notifications
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]

View File

@@ -27,6 +27,7 @@ codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-feedback = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true, features = ["derive"] }

View File

@@ -3,6 +3,7 @@
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt).
## Table of Contents
- [Protocol](#protocol)
- [Message Schema](#message-schema)
- [Core Primitives](#core-primitives)
@@ -28,6 +29,7 @@ codex app-server generate-json-schema --out DIR
## Core Primitives
The API exposes three top level primitives representing an interaction between a user and Codex:
- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns.
- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items.
- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc.
@@ -49,13 +51,23 @@ Clients must send a single `initialize` request before invoking any other method
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
Example (from OpenAI's official VSCode extension):
```json
{ "method": "initialize", "id": 0, "params": {
"clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" }
} }
{
"method": "initialize",
"id": 0,
"params": {
"clientInfo": {
"name": "codex-vscode",
"title": "Codex VS Code Extension",
"version": "0.1.0"
}
}
}
```
## API Overview
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
@@ -67,7 +79,7 @@ Example (from OpenAI's official VSCode extension):
- `model/list` — list available models (with reasoning effort options).
- `skills/list` — list skills for one or more `cwd` values.
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering.
@@ -108,6 +120,7 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev
### Example: List threads (with pagination & filters)
`thread/list` lets you render a history UI. Pass any combination of:
- `cursor` — opaque string from a prior response; omit for the first page.
- `limit` — server defaults to a reasonable page size if unset.
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
@@ -228,22 +241,32 @@ Codex streams the usual `turn/started` notification followed by an `item/started
with an `enteredReviewMode` item so clients can show progress:
```json
{ "method": "item/started", "params": { "item": {
"type": "enteredReviewMode",
"id": "turn_900",
"review": "current changes"
} } }
{
"method": "item/started",
"params": {
"item": {
"type": "enteredReviewMode",
"id": "turn_900",
"review": "current changes"
}
}
}
```
When the reviewer finishes, the server emits `item/started` and `item/completed`
containing an `exitedReviewMode` item with the final review text:
```json
{ "method": "item/completed", "params": { "item": {
"type": "exitedReviewMode",
"id": "turn_900",
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
} } }
{
"method": "item/completed",
"params": {
"item": {
"type": "exitedReviewMode",
"id": "turn_900",
"review": "Looks solid overall...\n\n- Prefer Stylize helpers — app.rs:10-20\n ..."
}
}
}
```
The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client.
@@ -263,6 +286,7 @@ Run a standalone command (argv vector) in the servers sandbox without creatin
```
Notes:
- Empty `command` arrays are rejected.
- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags).
- When omitted, `timeoutMs` falls back to the server default.
@@ -285,6 +309,7 @@ Today both notifications carry an empty `items` array even when item events were
#### Items
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:
- `userMessage``{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`).
- `agentMessage``{id, text}` containing the accumulated agent reply.
- `reasoning``{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
@@ -298,37 +323,48 @@ Today both notifications carry an empty `items` array even when item events were
- `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically.
All items emit two shared lifecycle events:
- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas.
- `item/completed` — sends the final `item` once that work finishes (e.g., after a tool call or message completes); treat this as the authoritative state.
There are additional item-specific events:
#### agentMessage
- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply.
#### reasoning
- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens.
- `item/reasoning/summaryPartAdded` — marks the boundary between reasoning summary sections for an `itemId`; subsequent `summaryTextDelta` entries share the same `summaryIndex`.
- `item/reasoning/textDelta` — streams raw reasoning text (only applicable for e.g. open source models); use `contentIndex` to group deltas that belong together before showing them in the UI.
#### commandExecution
- `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item.
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded.
#### fileChange
- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call.
### Errors
`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
- `ContextWindowExceeded`
- `UsageLimitExceeded`
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
- `BadRequest`
- `Unauthorized`
- `SandboxError`
- `InternalServerError`
- `Other`: all unclassified errors
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
- `ContextWindowExceeded`
- `UsageLimitExceeded`
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
- `BadRequest`
- `Unauthorized`
- `SandboxError`
- `InternalServerError`
- `Other`: all unclassified errors
When an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
@@ -342,6 +378,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap
### Command execution approvals
Order of messages:
1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `reason` or `risk`, plus `parsedCmd` for friendly display.
3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`.
@@ -350,6 +387,7 @@ Order of messages:
### File change approvals
Order of messages:
1. `item/started` — emits a `fileChange` item with `changes` (diff chunk summaries) and `status: "inProgress"`. Show the proposed edits and paths to the user.
2. `item/fileChange/requestApproval` (request) — includes `itemId`, `threadId`, `turnId`, and an optional `reason`.
3. Client response — `{ "decision": "accept" }` or `{ "decision": "decline" }`.
@@ -362,6 +400,7 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives.
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
### API Overview
- `account/read` — fetch current account info; optionally refresh tokens.
- `account/login/start` — begin login (`apiKey` or `chatgpt`).
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
@@ -375,11 +414,13 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
### 1) Check auth state
Request:
```json
{ "method": "account/read", "id": 1, "params": { "refreshToken": false } }
```
Response examples:
```json
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
@@ -388,6 +429,7 @@ Response examples:
```
Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
@@ -395,7 +437,11 @@ Field notes:
1. Send:
```json
{ "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } }
{
"method": "account/login/start",
"id": 2,
"params": { "type": "apiKey", "apiKey": "sk-…" }
}
```
2. Expect:
```json
@@ -445,6 +491,7 @@ Field notes:
```
Field notes:
- `usedPercent` is current usage within the OpenAI quota window.
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.

View File

@@ -31,6 +31,7 @@ use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
use codex_app_server_protocol::RawResponseItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ReasoningTextDeltaNotification;
@@ -451,6 +452,16 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
EventMsg::RawResponseItem(raw_response_item_event) => {
maybe_emit_raw_response_item_completed(
api_version,
conversation_id,
&event_turn_id,
raw_response_item_event.item,
outgoing.as_ref(),
)
.await;
}
EventMsg::PatchApplyBegin(patch_begin_event) => {
// Until we migrate the core to be aware of a first class FileChangeItem
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
@@ -820,6 +831,27 @@ async fn complete_command_execution_item(
.await;
}
async fn maybe_emit_raw_response_item_completed(
api_version: ApiVersion,
conversation_id: ConversationId,
turn_id: &str,
item: codex_protocol::models::ResponseItem,
outgoing: &OutgoingMessageSender,
) {
let ApiVersion::V2 = api_version else {
return;
};
let notification = RawResponseItemCompletedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.to_string(),
item,
};
outgoing
.send_server_notification(ServerNotification::RawResponseItemCompleted(notification))
.await;
}
async fn find_and_remove_turn_summary(
conversation_id: ConversationId,
turn_summary_store: &TurnSummaryStore,

View File

@@ -46,8 +46,8 @@ use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::ListConversationsResponse;
use codex_app_server_protocol::ListMcpServersParams;
use codex_app_server_protocol::ListMcpServersResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LoginApiKeyResponse;
@@ -55,10 +55,10 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::LogoutChatGptResponse;
use codex_app_server_protocol::McpServer;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginParams;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::NewConversationParams;
@@ -282,7 +282,7 @@ impl CodexMessageProcessor {
}
async fn load_latest_config(&self) -> Result<Config, JSONRPCErrorError> {
Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default())
Config::load_with_cli_overrides(self.cli_overrides.clone())
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
@@ -393,13 +393,20 @@ impl CodexMessageProcessor {
self.handle_list_conversations(request_id, params).await;
}
ClientRequest::ModelList { request_id, params } => {
self.list_models(request_id, params).await;
let outgoing = self.outgoing.clone();
let conversation_manager = self.conversation_manager.clone();
let config = self.config.clone();
tokio::spawn(async move {
Self::list_models(outgoing, conversation_manager, config, request_id, params)
.await;
});
}
ClientRequest::McpServerOauthLogin { request_id, params } => {
self.mcp_server_oauth_login(request_id, params).await;
}
ClientRequest::McpServersList { request_id, params } => {
self.list_mcp_servers(request_id, params).await;
ClientRequest::McpServerStatusList { request_id, params } => {
self.list_mcp_server_status(request_id, params).await;
}
ClientRequest::LoginAccount { request_id, params } => {
self.login_v2(request_id, params).await;
@@ -1373,9 +1380,13 @@ impl CodexMessageProcessor {
};
// Auto-attach a conversation listener when starting a thread.
// Use the same behavior as the v1 API with experimental_raw_events=false.
// Use the same behavior as the v1 API, with opt-in support for raw item events.
if let Err(err) = self
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
.attach_conversation_listener(
conversation_id,
params.experimental_raw_events,
ApiVersion::V2,
)
.await
{
tracing::warn!(
@@ -1892,9 +1903,17 @@ impl CodexMessageProcessor {
Ok((items, next_cursor))
}
async fn list_models(&self, request_id: RequestId, params: ModelListParams) {
async fn list_models(
outgoing: Arc<OutgoingMessageSender>,
conversation_manager: Arc<ConversationManager>,
config: Arc<Config>,
request_id: RequestId,
params: ModelListParams,
) {
let ModelListParams { limit, cursor } = params;
let models = supported_models(self.conversation_manager.clone(), &self.config).await;
let mut config = (*config).clone();
config.features.enable(Feature::RemoteModels);
let models = supported_models(conversation_manager, &config).await;
let total = models.len();
if total == 0 {
@@ -1902,7 +1921,7 @@ impl CodexMessageProcessor {
data: Vec::new(),
next_cursor: None,
};
self.outgoing.send_response(request_id, response).await;
outgoing.send_response(request_id, response).await;
return;
}
@@ -1917,7 +1936,7 @@ impl CodexMessageProcessor {
message: format!("invalid cursor: {cursor}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
outgoing.send_error(request_id, error).await;
return;
}
},
@@ -1930,7 +1949,7 @@ impl CodexMessageProcessor {
message: format!("cursor {start} exceeds total models {total}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
outgoing.send_error(request_id, error).await;
return;
}
@@ -1945,7 +1964,7 @@ impl CodexMessageProcessor {
data: items,
next_cursor,
};
self.outgoing.send_response(request_id, response).await;
outgoing.send_response(request_id, response).await;
}
async fn mcp_server_oauth_login(
@@ -2052,7 +2071,12 @@ impl CodexMessageProcessor {
}
}
async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) {
async fn list_mcp_server_status(
&self,
request_id: RequestId,
params: ListMcpServerStatusParams,
) {
let outgoing = Arc::clone(&self.outgoing);
let config = match self.load_latest_config().await {
Ok(config) => config,
Err(error) => {
@@ -2061,6 +2085,17 @@ impl CodexMessageProcessor {
}
};
tokio::spawn(async move {
Self::list_mcp_server_status_task(outgoing, request_id, params, config).await;
});
}
async fn list_mcp_server_status_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
params: ListMcpServerStatusParams,
config: Config,
) {
let snapshot = collect_mcp_snapshot(&config).await;
let tools_by_server = group_tools_by_server(&snapshot.tools);
@@ -2088,7 +2123,7 @@ impl CodexMessageProcessor {
message: format!("invalid cursor: {cursor}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
outgoing.send_error(request_id, error).await;
return;
}
},
@@ -2101,15 +2136,15 @@ impl CodexMessageProcessor {
message: format!("cursor {start} exceeds total MCP servers {total}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
outgoing.send_error(request_id, error).await;
return;
}
let end = start.saturating_add(effective_limit).min(total);
let data: Vec<McpServer> = server_names[start..end]
let data: Vec<McpServerStatus> = server_names[start..end]
.iter()
.map(|name| McpServer {
.map(|name| McpServerStatus {
name: name.clone(),
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
resources: snapshot.resources.get(name).cloned().unwrap_or_default(),
@@ -2133,9 +2168,9 @@ impl CodexMessageProcessor {
None
};
let response = ListMcpServersResponse { data, next_cursor };
let response = ListMcpServerStatusResponse { data, next_cursor };
self.outgoing.send_response(request_id, response).await;
outgoing.send_response(request_id, response).await;
}
async fn handle_resume_conversation(
@@ -3313,7 +3348,7 @@ fn errors_to_info(
async fn derive_config_from_params(
overrides: ConfigOverrides,
cli_overrides: Option<std::collections::HashMap<String, serde_json::Value>>,
cli_overrides: Option<HashMap<String, serde_json::Value>>,
) -> std::io::Result<Config> {
let cli_overrides = cli_overrides
.unwrap_or_default()
@@ -3321,7 +3356,7 @@ async fn derive_config_from_params(
.map(|(k, v)| (k, json_to_toml(v)))
.collect();
Config::load_with_cli_overrides(cli_overrides, overrides).await
Config::load_with_cli_overrides_and_harness_overrides(cli_overrides, overrides).await
}
async fn read_summary_from_rollout(

View File

@@ -2,7 +2,6 @@
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::path::PathBuf;
@@ -81,12 +80,11 @@ pub async fn run_main(
format!("error parsing -c overrides: {e}"),
)
})?;
let config =
Config::load_with_cli_overrides(cli_kv_overrides.clone(), ConfigOverrides::default())
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
let config = Config::load_with_cli_overrides(cli_kv_overrides.clone())
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
})?;
let feedback = CodexFeedback::new();

View File

@@ -42,6 +42,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
}
}
// todo(aibrahim): fix the priorities to be the opposite here.
/// Write a models_cache.json file to the codex home directory.
/// This prevents ModelsManager from making network requests to refresh models.
/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network.

View File

@@ -25,12 +25,13 @@ async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> {
.await??;
let os_info = os_info::get();
let originator = codex_core::default_client::originator().value.as_str();
let os_type = os_info.os_type();
let os_version = os_info.version();
let architecture = os_info.architecture().unwrap_or("unknown");
let terminal_ua = codex_core::terminal::user_agent();
let user_agent = format!(
"codex_cli_rs/0.0.0 ({} {}; {}) {} (codex-app-server-tests; 0.1.0)",
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
codex_core::terminal::user_agent()
"{originator}/0.0.0 ({os_type} {os_version}; {architecture}) {terminal_ua} (codex-app-server-tests; 0.1.0)"
);
let received: GetUserAgentResponse = to_response(response)?;

View File

@@ -6,7 +6,7 @@ use app_test_support::to_response;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigLayerName;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
@@ -18,6 +18,7 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ToolsV2;
use codex_app_server_protocol::WriteStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
@@ -42,6 +43,8 @@ model = "gpt-user"
sandbox_mode = "workspace-write"
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -65,12 +68,13 @@ sandbox_mode = "workspace-write"
assert_eq!(config.model.as_deref(), Some("gpt-user"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerName::User
ConfigLayerSource::User {
file: user_file.clone(),
}
);
let layers = layers.expect("layers present");
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerName::SessionFlags);
assert_eq!(layers[1].name, ConfigLayerName::User);
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].name, ConfigLayerSource::User { file: user_file });
Ok(())
}
@@ -88,6 +92,8 @@ web_search = true
view_image = false
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -118,17 +124,20 @@ view_image = false
);
assert_eq!(
origins.get("tools.web_search").expect("origin").name,
ConfigLayerName::User
ConfigLayerSource::User {
file: user_file.clone(),
}
);
assert_eq!(
origins.get("tools.view_image").expect("origin").name,
ConfigLayerName::User
ConfigLayerSource::User {
file: user_file.clone(),
}
);
let layers = layers.expect("layers present");
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerName::SessionFlags);
assert_eq!(layers[1].name, ConfigLayerName::User);
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].name, ConfigLayerSource::User { file: user_file });
Ok(())
}
@@ -153,8 +162,11 @@ network_access = true
serde_json::json!(user_dir)
),
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let managed_path = codex_home.path().join("managed_config.toml");
let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
std::fs::write(
&managed_path,
format!(
@@ -197,19 +209,25 @@ writable_roots = [{}]
assert_eq!(config.model.as_deref(), Some("gpt-system"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerName::System
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
assert_eq!(config.approval_policy, Some(AskForApproval::Never));
assert_eq!(
origins.get("approval_policy").expect("origin").name,
ConfigLayerName::System
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
assert_eq!(config.sandbox_mode, Some(SandboxMode::WorkspaceWrite));
assert_eq!(
origins.get("sandbox_mode").expect("origin").name,
ConfigLayerName::User
ConfigLayerSource::User {
file: user_file.clone(),
}
);
let sandbox = config
@@ -222,7 +240,9 @@ writable_roots = [{}]
.get("sandbox_workspace_write.writable_roots.0")
.expect("origin")
.name,
ConfigLayerName::System
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
assert!(sandbox.network_access);
@@ -231,29 +251,34 @@ writable_roots = [{}]
.get("sandbox_workspace_write.network_access")
.expect("origin")
.name,
ConfigLayerName::User
ConfigLayerSource::User {
file: user_file.clone(),
}
);
let layers = layers.expect("layers present");
assert_eq!(layers.len(), 3);
assert_eq!(layers[0].name, ConfigLayerName::System);
assert_eq!(layers[1].name, ConfigLayerName::SessionFlags);
assert_eq!(layers[2].name, ConfigLayerName::User);
assert_eq!(layers.len(), 2);
assert_eq!(
layers[0].name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_replaces_value() -> Result<()> {
let codex_home = TempDir::new()?;
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(
&codex_home,
&temp_dir,
r#"
model = "gpt-old"
"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let read_id = mcp
@@ -284,13 +309,7 @@ model = "gpt-old"
)
.await??;
let write: ConfigWriteResponse = to_response(write_resp)?;
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?;
assert_eq!(write.status, WriteStatus::Ok);
assert_eq!(write.file_path, expected_file_path);
@@ -353,16 +372,17 @@ model = "gpt-old"
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, "")?;
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let writable_root = test_tmp_path_buf();
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
file_path: Some(codex_home.join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "sandbox_mode".to_string(),
@@ -388,13 +408,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
.await??;
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
assert_eq!(batch_write.status, WriteStatus::Ok);
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?;
assert_eq!(batch_write.file_path, expected_file_path);
let read_id = mcp

View File

@@ -48,51 +48,61 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
let expected_models = vec![
Model {
id: "gpt-5.1-codex-max".to_string(),
model: "gpt-5.1-codex-max".to_string(),
display_name: "gpt-5.1-codex-max".to_string(),
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
id: "gpt-5.1".to_string(),
model: "gpt-5.1".to_string(),
display_name: "gpt-5.1".to_string(),
description: "Broad world knowledge with strong general reasoning.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning".to_string(),
description: "Balances speed with some reasoning; useful for straightforward \
queries and short explanations"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks"
description: "Provides a solid balance of reasoning depth and latency for \
general-purpose tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: true,
},
Model {
id: "gpt-5.1-codex".to_string(),
model: "gpt-5.1-codex".to_string(),
display_name: "gpt-5.1-codex".to_string(),
description: "Optimized for codex.".to_string(),
id: "gpt-5.2".to_string(),
model: "gpt-5.2".to_string(),
display_name: "gpt-5.2".to_string(),
description:
"Latest frontier model with improvements across knowledge, reasoning and coding"
.to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning".to_string(),
description: "Balances speed with some reasoning; useful for straightforward \
queries and short explanations"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
description: "Provides a solid balance of reasoning depth and latency for \
general-purpose tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
@@ -117,61 +127,78 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
is_default: false,
},
Model {
id: "gpt-5.2".to_string(),
model: "gpt-5.2".to_string(),
display_name: "gpt-5.2".to_string(),
description:
"Latest frontier model with improvements across knowledge, reasoning and coding"
.to_string(),
id: "gpt-5.1-codex".to_string(),
model: "gpt-5.1-codex".to_string(),
display_name: "gpt-5.1-codex".to_string(),
description: "Optimized for codex.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Balances speed with some reasoning; useful for straightforward \
queries and short explanations"
.to_string(),
description: "Fastest responses with limited reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Provides a solid balance of reasoning depth and latency for \
general-purpose tasks"
.to_string(),
description: "Dynamically adjusts reasoning based on the task".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex or ambiguous problems"
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.1".to_string(),
model: "gpt-5.1".to_string(),
display_name: "gpt-5.1".to_string(),
description: "Broad world knowledge with strong general reasoning.".to_string(),
id: "gpt-5.1-codex-max".to_string(),
model: "gpt-5.1-codex-max".to_string(),
display_name: "gpt-5.1-codex-max".to_string(),
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Balances speed with some reasoning; useful for straightforward \
queries and short explanations"
.to_string(),
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Provides a solid balance of reasoning depth and latency for \
general-purpose tasks"
description: "Balances speed and reasoning depth for everyday tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "caribou".to_string(),
model: "caribou".to_string(),
display_name: "caribou".to_string(),
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks"
.to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
@@ -210,7 +237,7 @@ async fn list_models_pagination_works() -> Result<()> {
} = to_response::<ModelListResponse>(first_response)?;
assert_eq!(first_items.len(), 1);
assert_eq!(first_items[0].id, "gpt-5.1-codex-max");
assert_eq!(first_items[0].id, "gpt-5.1");
let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?;
let second_request = mcp
@@ -232,7 +259,7 @@ async fn list_models_pagination_works() -> Result<()> {
} = to_response::<ModelListResponse>(second_response)?;
assert_eq!(second_items.len(), 1);
assert_eq!(second_items[0].id, "gpt-5.1-codex");
assert_eq!(second_items[0].id, "gpt-5.2");
let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?;
let third_request = mcp
@@ -276,7 +303,7 @@ async fn list_models_pagination_works() -> Result<()> {
} = to_response::<ModelListResponse>(fourth_response)?;
assert_eq!(fourth_items.len(), 1);
assert_eq!(fourth_items[0].id, "gpt-5.2");
assert_eq!(fourth_items[0].id, "gpt-5.1-codex");
let fifth_cursor = fourth_cursor.ok_or_else(|| anyhow!("cursor for fifth page"))?;
let fifth_request = mcp
@@ -298,8 +325,30 @@ async fn list_models_pagination_works() -> Result<()> {
} = to_response::<ModelListResponse>(fifth_response)?;
assert_eq!(fifth_items.len(), 1);
assert_eq!(fifth_items[0].id, "gpt-5.1");
assert!(fifth_cursor.is_none());
assert_eq!(fifth_items[0].id, "gpt-5.1-codex-max");
let sixth_cursor = fifth_cursor.ok_or_else(|| anyhow!("cursor for sixth page"))?;
let sixth_request = mcp
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(sixth_cursor.clone()),
})
.await?;
let sixth_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(sixth_request)),
)
.await??;
let ModelListResponse {
data: sixth_items,
next_cursor: sixth_cursor,
} = to_response::<ModelListResponse>(sixth_response)?;
assert_eq!(sixth_items.len(), 1);
assert_eq!(sixth_items[0].id, "caribou");
assert!(sixth_cursor.is_none());
Ok(())
}

View File

@@ -0,0 +1,813 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use tree_sitter::Parser;
use tree_sitter::Query;
use tree_sitter::QueryCursor;
use tree_sitter::StreamingIterator;
use tree_sitter_bash::LANGUAGE as BASH;
use crate::ApplyPatchAction;
use crate::ApplyPatchArgs;
use crate::ApplyPatchError;
use crate::ApplyPatchFileChange;
use crate::ApplyPatchFileUpdate;
use crate::IoError;
use crate::MaybeApplyPatchVerified;
use crate::parser::Hunk;
use crate::parser::ParseError;
use crate::parser::parse_patch;
use crate::unified_diff_from_chunks;
use std::str::Utf8Error;
use tree_sitter::LanguageError;
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApplyPatchShell {
Unix,
PowerShell,
Cmd,
}
#[derive(Debug, PartialEq)]
pub enum MaybeApplyPatch {
Body(ApplyPatchArgs),
ShellParseError(ExtractHeredocError),
PatchParseError(ParseError),
NotApplyPatch,
}
#[derive(Debug, PartialEq)]
pub enum ExtractHeredocError {
CommandDidNotStartWithApplyPatch,
FailedToLoadBashGrammar(LanguageError),
HeredocNotUtf8(Utf8Error),
FailedToParsePatchIntoAst,
FailedToFindHeredocBody,
}
fn classify_shell_name(shell: &str) -> Option<String> {
std::path::Path::new(shell)
.file_stem()
.and_then(|name| name.to_str())
.map(str::to_ascii_lowercase)
}
fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
classify_shell_name(shell).and_then(|name| match name.as_str() {
"bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
"pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
Some(ApplyPatchShell::PowerShell)
}
"cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
_ => None,
})
}
fn can_skip_flag(shell: &str, flag: &str) -> bool {
classify_shell_name(shell).is_some_and(|name| {
matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
})
}
fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
match argv {
[shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
let script = script.as_str();
(shell_type, script)
}),
[shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
classify_shell(shell, flag).map(|shell_type| {
let script = script.as_str();
(shell_type, script)
})
}
_ => None,
}
}
fn extract_apply_patch_from_shell(
shell: ApplyPatchShell,
script: &str,
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
match shell {
ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
extract_apply_patch_from_bash(script)
}
}
}
// TODO: make private once we remove tests in lib.rs
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
match argv {
// Direct invocation: apply_patch <patch>
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
_ => match parse_shell_script(argv) {
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
Ok((body, workdir)) => match parse_patch(&body) {
Ok(mut source) => {
source.workdir = workdir;
MaybeApplyPatch::Body(source)
}
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
MaybeApplyPatch::NotApplyPatch
}
Err(e) => MaybeApplyPatch::ShellParseError(e),
},
None => MaybeApplyPatch::NotApplyPatch,
},
}
}
/// cwd must be an absolute path so that we can resolve relative paths in the
/// patch.
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
// Detect a raw patch body passed directly as the command or as the body of a shell
// script. In these cases, report an explicit error rather than applying the patch.
if let [body] = argv
&& parse_patch(body).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
if let Some((_, script)) = parse_shell_script(argv)
&& parse_patch(script).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
match maybe_parse_apply_patch(argv) {
MaybeApplyPatch::Body(ApplyPatchArgs {
patch,
hunks,
workdir,
}) => {
let effective_cwd = workdir
.as_ref()
.map(|dir| {
let path = Path::new(dir);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
})
.unwrap_or_else(|| cwd.to_path_buf());
let mut changes = HashMap::new();
for hunk in hunks {
let path = hunk.resolve_path(&effective_cwd);
match hunk {
Hunk::AddFile { contents, .. } => {
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
}
Hunk::DeleteFile { .. } => {
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
ApplyPatchError::IoError(IoError {
context: format!("Failed to read {}", path.display()),
source: e,
}),
);
}
};
changes.insert(path, ApplyPatchFileChange::Delete { content });
}
Hunk::UpdateFile {
move_path, chunks, ..
} => {
let ApplyPatchFileUpdate {
unified_diff,
content: contents,
} = match unified_diff_from_chunks(&path, &chunks) {
Ok(diff) => diff,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(e);
}
};
changes.insert(
path,
ApplyPatchFileChange::Update {
unified_diff,
move_path: move_path.map(|p| effective_cwd.join(p)),
new_content: contents,
},
);
}
}
}
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes,
patch,
cwd: effective_cwd,
})
}
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
}
}
/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
/// that invokes the apply_patch tool using a heredoc.
///
/// Supported toplevel forms (must be the only toplevel statement):
/// - `apply_patch <<'EOF'\n...\nEOF`
/// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
///
/// Notes about matching:
/// - Parsed with Treesitter Bash and a strict query that uses anchors so the
/// heredocredirected statement is the only toplevel statement.
/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
/// strings, no second argument).
/// - The apply command is validated inquery via `#any-of?` to allow `apply_patch`
/// or `applypatch`.
/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
///
/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
/// `(heredoc_body, None)` for the direct form. Errors are returned if the script
/// cannot be parsed or does not match the allowed patterns.
fn extract_apply_patch_from_bash(
src: &str,
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
// This function uses a Tree-sitter query to recognize one of two
// whole-script forms, each expressed as a single top-level statement:
//
// 1. apply_patch <<'EOF'\n...\nEOF
// 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
//
// Key ideas when reading the query:
// - dots (`.`) between named nodes enforces adjacency among named children and
// anchor to the start/end of the expression.
// - we match a single redirected_statement directly under program with leading
// and trailing anchors (`.`). This ensures it is the only top-level statement
// (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
//
// Overall, we want to be conservative and only match the intended forms, as other
// forms are likely to be model errors, or incorrectly interpreted by later code.
//
// If you're editing this query, it's helpful to start by creating a debugging binary
// which will let you see the AST of an arbitrary bash script passed in, and optionally
// also run an arbitrary query against the AST. This is useful for understanding
// how tree-sitter parses the script and whether the query syntax is correct. Be sure
// to test both positive and negative cases.
static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
let language = BASH.into();
#[expect(clippy::expect_used)]
Query::new(
&language,
r#"
(
program
. (redirected_statement
body: (command
name: (command_name (word) @apply_name) .)
(#any-of? @apply_name "apply_patch" "applypatch")
redirect: (heredoc_redirect
. (heredoc_start)
. (heredoc_body) @heredoc
. (heredoc_end)
.))
.)
(
program
. (redirected_statement
body: (list
. (command
name: (command_name (word) @cd_name) .
argument: [
(word) @cd_path
(string (string_content) @cd_path)
(raw_string) @cd_raw_string
] .)
"&&"
. (command
name: (command_name (word) @apply_name))
.)
(#eq? @cd_name "cd")
(#any-of? @apply_name "apply_patch" "applypatch")
redirect: (heredoc_redirect
. (heredoc_start)
. (heredoc_body) @heredoc
. (heredoc_end)
.))
.)
"#,
)
.expect("valid bash query")
});
let lang = BASH.into();
let mut parser = Parser::new();
parser
.set_language(&lang)
.map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
let tree = parser
.parse(src, None)
.ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
let bytes = src.as_bytes();
let root = tree.root_node();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
while let Some(m) = matches.next() {
let mut heredoc_text: Option<String> = None;
let mut cd_path: Option<String> = None;
for capture in m.captures.iter() {
let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
match name {
"heredoc" => {
let text = capture
.node
.utf8_text(bytes)
.map_err(ExtractHeredocError::HeredocNotUtf8)?
.trim_end_matches('\n')
.to_string();
heredoc_text = Some(text);
}
"cd_path" => {
let text = capture
.node
.utf8_text(bytes)
.map_err(ExtractHeredocError::HeredocNotUtf8)?
.to_string();
cd_path = Some(text);
}
"cd_raw_string" => {
let raw = capture
.node
.utf8_text(bytes)
.map_err(ExtractHeredocError::HeredocNotUtf8)?;
let trimmed = raw
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.unwrap_or(raw);
cd_path = Some(trimmed.to_string());
}
_ => {}
}
}
if let Some(heredoc) = heredoc_text {
return Ok((heredoc, cd_path));
}
}
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
}
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::PathBuf;
use std::string::ToString;
use tempfile::tempdir;
/// Helper to construct a patch with the given body.
fn wrap_patch(body: &str) -> String {
format!("*** Begin Patch\n{body}\n*** End Patch")
}
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
strs.iter().map(ToString::to_string).collect()
}
// Test helpers to reduce repetition when building bash -lc heredoc scripts
fn args_bash(script: &str) -> Vec<String> {
strs_to_strings(&["bash", "-lc", script])
}
fn args_powershell(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-Command", script])
}
fn args_powershell_no_profile(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
}
fn args_pwsh(script: &str) -> Vec<String> {
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
}
fn args_cmd(script: &str) -> Vec<String> {
strs_to_strings(&["cmd.exe", "/c", script])
}
fn heredoc_script(prefix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
)
}
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
)
}
fn expected_single_add() -> Vec<Hunk> {
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string(),
}]
}
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir.as_deref(), expected_workdir);
assert_eq!(hunks, expected_single_add());
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
fn assert_match(script: &str, expected_workdir: Option<&str>) {
let args = args_bash(script);
assert_match_args(args, expected_workdir);
}
fn assert_not_match(script: &str) {
let args = args_bash(script);
assert_matches!(
maybe_parse_apply_patch(&args),
MaybeApplyPatch::NotApplyPatch
);
}
#[test]
fn test_implicit_patch_single_arg_is_error() {
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
let args = vec![patch];
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_implicit_patch_bash_script_is_error() {
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
let args = args_bash(script);
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_literal() {
let args = strs_to_strings(&[
"apply_patch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_literal_applypatch() {
let args = strs_to_strings(&[
"applypatch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_heredoc() {
assert_match(&heredoc_script(""), None);
}
#[test]
fn test_heredoc_non_login_shell() {
let script = heredoc_script("");
let args = strs_to_strings(&["bash", "-c", &script]);
assert_match_args(args, None);
}
#[test]
fn test_heredoc_applypatch() {
let args = strs_to_strings(&[
"bash",
"-lc",
r#"applypatch <<'PATCH'
*** Begin Patch
*** Add File: foo
+hi
*** End Patch
PATCH"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_powershell_heredoc() {
let script = heredoc_script("");
assert_match_args(args_powershell(&script), None);
}
#[test]
fn test_powershell_heredoc_no_profile() {
let script = heredoc_script("");
assert_match_args(args_powershell_no_profile(&script), None);
}
#[test]
fn test_pwsh_heredoc() {
let script = heredoc_script("");
assert_match_args(args_pwsh(&script), None);
}
#[test]
fn test_cmd_heredoc_with_cd() {
let script = heredoc_script("cd foo && ");
assert_match_args(args_cmd(&script), Some("foo"));
}
#[test]
fn test_heredoc_with_leading_cd() {
assert_match(&heredoc_script("cd foo && "), Some("foo"));
}
#[test]
fn test_cd_with_semicolon_is_ignored() {
assert_not_match(&heredoc_script("cd foo; "));
}
#[test]
fn test_cd_or_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar || "));
}
#[test]
fn test_cd_pipe_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar | "));
}
#[test]
fn test_cd_single_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
}
#[test]
fn test_cd_double_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
}
#[test]
fn test_echo_and_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("echo foo && "));
}
#[test]
fn test_apply_patch_with_arg_is_ignored() {
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
assert_not_match(script);
}
#[test]
fn test_double_cd_then_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd foo && cd bar && "));
}
#[test]
fn test_cd_two_args_is_ignored() {
assert_not_match(&heredoc_script("cd foo bar && "));
}
#[test]
fn test_cd_then_apply_patch_then_extra_is_ignored() {
let script = heredoc_script_ps("cd bar && ", " && echo done");
assert_not_match(&script);
}
#[test]
fn test_echo_then_cd_and_apply_patch_is_ignored() {
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
assert_not_match(&heredoc_script("echo foo; cd bar && "));
}
#[test]
fn test_unified_diff_last_line_replacement() {
// Replace the very last line of the file.
let dir = tempdir().unwrap();
let path = dir.path().join("last.txt");
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
foo
bar
-baz
+BAZ
"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
};
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
let expected_diff = r#"@@ -2,2 +2,2 @@
bar
-baz
+BAZ
"#;
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nBAZ\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
fn test_unified_diff_insert_at_eof() {
// Insert a new line at endoffile.
let dir = tempdir().unwrap();
let path = dir.path().join("insert.txt");
fs::write(&path, "foo\nbar\nbaz\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {}
@@
+quux
*** End of File
"#,
path.display()
));
let patch = parse_patch(&patch).unwrap();
let chunks = match patch.hunks.as_slice() {
[Hunk::UpdateFile { chunks, .. }] => chunks,
_ => panic!("Expected a single UpdateFile hunk"),
};
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
let expected_diff = r#"@@ -3 +3,2 @@
baz
+quux
"#;
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nbaz\nquux\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
let session_dir = tempdir().unwrap();
let relative_path = "source.txt";
// Note that we need this file to exist for the patch to be "verified"
// and parsed correctly.
let session_file_path = session_dir.path().join(relative_path);
fs::write(&session_file_path, "session directory content\n").unwrap();
let argv = vec![
"apply_patch".to_string(),
r#"*** Begin Patch
*** Update File: source.txt
@@
-session directory content
+updated session directory content
*** End Patch"#
.to_string(),
];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
// Verify the patch contents - as otherwise we may have pulled contents
// from the wrong file (as we're using relative paths)
assert_eq!(
result,
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes: HashMap::from([(
session_dir.path().join(relative_path),
ApplyPatchFileChange::Update {
unified_diff: r#"@@ -1 +1 @@
-session directory content
+updated session directory content
"#
.to_string(),
move_path: None,
new_content: "updated session directory content\n".to_string(),
},
)]),
patch: argv[1].clone(),
cwd: session_dir.path().to_path_buf(),
})
);
}
#[test]
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
let session_dir = tempdir().unwrap();
let worktree_rel = "alt";
let worktree_dir = session_dir.path().join(worktree_rel);
fs::create_dir_all(&worktree_dir).unwrap();
let source_name = "old.txt";
let dest_name = "renamed.txt";
let source_path = worktree_dir.join(source_name);
fs::write(&source_path, "before\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {source_name}
*** Move to: {dest_name}
@@
-before
+after"#
));
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
let argv = vec!["bash".into(), "-lc".into(), shell_script];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
let action = match result {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected verified body, got {other:?}"),
};
assert_eq!(action.cwd, worktree_dir);
let change = action
.changes()
.get(&worktree_dir.join(source_name))
.expect("source file change present");
match change {
ApplyPatchFileChange::Update { move_path, .. } => {
assert_eq!(
move_path.as_deref(),
Some(worktree_dir.join(dest_name).as_path())
);
}
other => panic!("expected update change, got {other:?}"),
}
}
}

View File

@@ -1,3 +1,4 @@
mod invocation;
mod parser;
mod seek_sequence;
mod standalone_executable;
@@ -5,8 +6,6 @@ mod standalone_executable;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::str::Utf8Error;
use std::sync::LazyLock;
use anyhow::Context;
use anyhow::Result;
@@ -17,27 +16,15 @@ use parser::UpdateFileChunk;
pub use parser::parse_patch;
use similar::TextDiff;
use thiserror::Error;
use tree_sitter::LanguageError;
use tree_sitter::Parser;
use tree_sitter::Query;
use tree_sitter::QueryCursor;
use tree_sitter::StreamingIterator;
use tree_sitter_bash::LANGUAGE as BASH;
pub use invocation::maybe_parse_apply_patch_verified;
pub use standalone_executable::main;
use crate::invocation::ExtractHeredocError;
/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool.
pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md");
const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ApplyPatchShell {
Unix,
PowerShell,
Cmd,
}
#[derive(Debug, Error, PartialEq)]
pub enum ApplyPatchError {
#[error(transparent)]
@@ -86,14 +73,6 @@ impl PartialEq for IoError {
}
}
#[derive(Debug, PartialEq)]
pub enum MaybeApplyPatch {
Body(ApplyPatchArgs),
ShellParseError(ExtractHeredocError),
PatchParseError(ParseError),
NotApplyPatch,
}
/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument
/// parsed into hunks.
#[derive(Debug, PartialEq)]
@@ -103,84 +82,6 @@ pub struct ApplyPatchArgs {
pub workdir: Option<String>,
}
fn classify_shell_name(shell: &str) -> Option<String> {
std::path::Path::new(shell)
.file_stem()
.and_then(|name| name.to_str())
.map(str::to_ascii_lowercase)
}
fn classify_shell(shell: &str, flag: &str) -> Option<ApplyPatchShell> {
classify_shell_name(shell).and_then(|name| match name.as_str() {
"bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
"pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
Some(ApplyPatchShell::PowerShell)
}
"cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
_ => None,
})
}
fn can_skip_flag(shell: &str, flag: &str) -> bool {
classify_shell_name(shell).is_some_and(|name| {
matches!(name.as_str(), "pwsh" | "powershell") && flag.eq_ignore_ascii_case("-noprofile")
})
}
fn parse_shell_script(argv: &[String]) -> Option<(ApplyPatchShell, &str)> {
match argv {
[shell, flag, script] => classify_shell(shell, flag).map(|shell_type| {
let script = script.as_str();
(shell_type, script)
}),
[shell, skip_flag, flag, script] if can_skip_flag(shell, skip_flag) => {
classify_shell(shell, flag).map(|shell_type| {
let script = script.as_str();
(shell_type, script)
})
}
_ => None,
}
}
fn extract_apply_patch_from_shell(
shell: ApplyPatchShell,
script: &str,
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
match shell {
ApplyPatchShell::Unix | ApplyPatchShell::PowerShell | ApplyPatchShell::Cmd => {
extract_apply_patch_from_bash(script)
}
}
}
pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
match argv {
// Direct invocation: apply_patch <patch>
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
_ => match parse_shell_script(argv) {
Some((shell, script)) => match extract_apply_patch_from_shell(shell, script) {
Ok((body, workdir)) => match parse_patch(&body) {
Ok(mut source) => {
source.workdir = workdir;
MaybeApplyPatch::Body(source)
}
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch) => {
MaybeApplyPatch::NotApplyPatch
}
Err(e) => MaybeApplyPatch::ShellParseError(e),
},
None => MaybeApplyPatch::NotApplyPatch,
},
}
}
#[derive(Debug, PartialEq)]
pub enum ApplyPatchFileChange {
Add {
@@ -269,256 +170,6 @@ impl ApplyPatchAction {
}
}
/// cwd must be an absolute path so that we can resolve relative paths in the
/// patch.
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
// Detect a raw patch body passed directly as the command or as the body of a shell
// script. In these cases, report an explicit error rather than applying the patch.
if let [body] = argv
&& parse_patch(body).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
if let Some((_, script)) = parse_shell_script(argv)
&& parse_patch(script).is_ok()
{
return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation);
}
match maybe_parse_apply_patch(argv) {
MaybeApplyPatch::Body(ApplyPatchArgs {
patch,
hunks,
workdir,
}) => {
let effective_cwd = workdir
.as_ref()
.map(|dir| {
let path = Path::new(dir);
if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
})
.unwrap_or_else(|| cwd.to_path_buf());
let mut changes = HashMap::new();
for hunk in hunks {
let path = hunk.resolve_path(&effective_cwd);
match hunk {
Hunk::AddFile { contents, .. } => {
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
}
Hunk::DeleteFile { .. } => {
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
ApplyPatchError::IoError(IoError {
context: format!("Failed to read {}", path.display()),
source: e,
}),
);
}
};
changes.insert(path, ApplyPatchFileChange::Delete { content });
}
Hunk::UpdateFile {
move_path, chunks, ..
} => {
let ApplyPatchFileUpdate {
unified_diff,
content: contents,
} = match unified_diff_from_chunks(&path, &chunks) {
Ok(diff) => diff,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(e);
}
};
changes.insert(
path,
ApplyPatchFileChange::Update {
unified_diff,
move_path: move_path.map(|p| effective_cwd.join(p)),
new_content: contents,
},
);
}
}
}
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes,
patch,
cwd: effective_cwd,
})
}
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch,
}
}
/// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script
/// that invokes the apply_patch tool using a heredoc.
///
/// Supported toplevel forms (must be the only toplevel statement):
/// - `apply_patch <<'EOF'\n...\nEOF`
/// - `cd <path> && apply_patch <<'EOF'\n...\nEOF`
///
/// Notes about matching:
/// - Parsed with Treesitter Bash and a strict query that uses anchors so the
/// heredocredirected statement is the only toplevel statement.
/// - The connector between `cd` and `apply_patch` must be `&&` (not `|` or `||`).
/// - Exactly one positional `word` argument is allowed for `cd` (no flags, no quoted
/// strings, no second argument).
/// - The apply command is validated inquery via `#any-of?` to allow `apply_patch`
/// or `applypatch`.
/// - Preceding or trailing commands (e.g., `echo ...;` or `... && echo done`) do not match.
///
/// Returns `(heredoc_body, Some(path))` when the `cd` variant matches, or
/// `(heredoc_body, None)` for the direct form. Errors are returned if the script
/// cannot be parsed or does not match the allowed patterns.
fn extract_apply_patch_from_bash(
src: &str,
) -> std::result::Result<(String, Option<String>), ExtractHeredocError> {
// This function uses a Tree-sitter query to recognize one of two
// whole-script forms, each expressed as a single top-level statement:
//
// 1. apply_patch <<'EOF'\n...\nEOF
// 2. cd <path> && apply_patch <<'EOF'\n...\nEOF
//
// Key ideas when reading the query:
// - dots (`.`) between named nodes enforces adjacency among named children and
// anchor to the start/end of the expression.
// - we match a single redirected_statement directly under program with leading
// and trailing anchors (`.`). This ensures it is the only top-level statement
// (so prefixes like `echo ...;` or suffixes like `... && echo done` do not match).
//
// Overall, we want to be conservative and only match the intended forms, as other
// forms are likely to be model errors, or incorrectly interpreted by later code.
//
// If you're editing this query, it's helpful to start by creating a debugging binary
// which will let you see the AST of an arbitrary bash script passed in, and optionally
// also run an arbitrary query against the AST. This is useful for understanding
// how tree-sitter parses the script and whether the query syntax is correct. Be sure
// to test both positive and negative cases.
static APPLY_PATCH_QUERY: LazyLock<Query> = LazyLock::new(|| {
let language = BASH.into();
#[expect(clippy::expect_used)]
Query::new(
&language,
r#"
(
program
. (redirected_statement
body: (command
name: (command_name (word) @apply_name) .)
(#any-of? @apply_name "apply_patch" "applypatch")
redirect: (heredoc_redirect
. (heredoc_start)
. (heredoc_body) @heredoc
. (heredoc_end)
.))
.)
(
program
. (redirected_statement
body: (list
. (command
name: (command_name (word) @cd_name) .
argument: [
(word) @cd_path
(string (string_content) @cd_path)
(raw_string) @cd_raw_string
] .)
"&&"
. (command
name: (command_name (word) @apply_name))
.)
(#eq? @cd_name "cd")
(#any-of? @apply_name "apply_patch" "applypatch")
redirect: (heredoc_redirect
. (heredoc_start)
. (heredoc_body) @heredoc
. (heredoc_end)
.))
.)
"#,
)
.expect("valid bash query")
});
let lang = BASH.into();
let mut parser = Parser::new();
parser
.set_language(&lang)
.map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
let tree = parser
.parse(src, None)
.ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
let bytes = src.as_bytes();
let root = tree.root_node();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&APPLY_PATCH_QUERY, root, bytes);
while let Some(m) = matches.next() {
let mut heredoc_text: Option<String> = None;
let mut cd_path: Option<String> = None;
for capture in m.captures.iter() {
let name = APPLY_PATCH_QUERY.capture_names()[capture.index as usize];
match name {
"heredoc" => {
let text = capture
.node
.utf8_text(bytes)
.map_err(ExtractHeredocError::HeredocNotUtf8)?
.trim_end_matches('\n')
.to_string();
heredoc_text = Some(text);
}
"cd_path" => {
let text = capture
.node
.utf8_text(bytes)
.map_err(ExtractHeredocError::HeredocNotUtf8)?
.to_string();
cd_path = Some(text);
}
"cd_raw_string" => {
let raw = capture
.node
.utf8_text(bytes)
.map_err(ExtractHeredocError::HeredocNotUtf8)?;
let trimmed = raw
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.unwrap_or(raw);
cd_path = Some(trimmed.to_string());
}
_ => {}
}
}
if let Some(heredoc) = heredoc_text {
return Ok((heredoc, cd_path));
}
}
Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch)
}
#[derive(Debug, PartialEq)]
pub enum ExtractHeredocError {
CommandDidNotStartWithApplyPatch,
FailedToLoadBashGrammar(LanguageError),
HeredocNotUtf8(Utf8Error),
FailedToParsePatchIntoAst,
FailedToFindHeredocBody,
}
/// Applies the patch and prints the result to stdout/stderr.
pub fn apply_patch(
patch: &str,
@@ -894,7 +545,6 @@ pub fn print_summary(
#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
use std::fs;
use std::string::ToString;
@@ -905,270 +555,6 @@ mod tests {
format!("*** Begin Patch\n{body}\n*** End Patch")
}
fn strs_to_strings(strs: &[&str]) -> Vec<String> {
strs.iter().map(ToString::to_string).collect()
}
// Test helpers to reduce repetition when building bash -lc heredoc scripts
fn args_bash(script: &str) -> Vec<String> {
strs_to_strings(&["bash", "-lc", script])
}
fn args_powershell(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-Command", script])
}
fn args_powershell_no_profile(script: &str) -> Vec<String> {
strs_to_strings(&["powershell.exe", "-NoProfile", "-Command", script])
}
fn args_pwsh(script: &str) -> Vec<String> {
strs_to_strings(&["pwsh", "-NoProfile", "-Command", script])
}
fn args_cmd(script: &str) -> Vec<String> {
strs_to_strings(&["cmd.exe", "/c", script])
}
fn heredoc_script(prefix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH"
)
}
fn heredoc_script_ps(prefix: &str, suffix: &str) -> String {
format!(
"{prefix}apply_patch <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH{suffix}"
)
}
fn expected_single_add() -> Vec<Hunk> {
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string(),
}]
}
fn assert_match_args(args: Vec<String>, expected_workdir: Option<&str>) {
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir.as_deref(), expected_workdir);
assert_eq!(hunks, expected_single_add());
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
fn assert_match(script: &str, expected_workdir: Option<&str>) {
let args = args_bash(script);
assert_match_args(args, expected_workdir);
}
fn assert_not_match(script: &str) {
let args = args_bash(script);
assert_matches!(
maybe_parse_apply_patch(&args),
MaybeApplyPatch::NotApplyPatch
);
}
#[test]
fn test_implicit_patch_single_arg_is_error() {
let patch = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch".to_string();
let args = vec![patch];
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_implicit_patch_bash_script_is_error() {
let script = "*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch";
let args = args_bash(script);
let dir = tempdir().unwrap();
assert_matches!(
maybe_parse_apply_patch_verified(&args, dir.path()),
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
);
}
#[test]
fn test_literal() {
let args = strs_to_strings(&[
"apply_patch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_literal_applypatch() {
let args = strs_to_strings(&[
"applypatch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End Patch
"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, .. }) => {
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_heredoc() {
assert_match(&heredoc_script(""), None);
}
#[test]
fn test_heredoc_non_login_shell() {
let script = heredoc_script("");
let args = strs_to_strings(&["bash", "-c", &script]);
assert_match_args(args, None);
}
#[test]
fn test_heredoc_applypatch() {
let args = strs_to_strings(&[
"bash",
"-lc",
r#"applypatch <<'PATCH'
*** Begin Patch
*** Add File: foo
+hi
*** End Patch
PATCH"#,
]);
match maybe_parse_apply_patch(&args) {
MaybeApplyPatch::Body(ApplyPatchArgs { hunks, workdir, .. }) => {
assert_eq!(workdir, None);
assert_eq!(
hunks,
vec![Hunk::AddFile {
path: PathBuf::from("foo"),
contents: "hi\n".to_string()
}]
);
}
result => panic!("expected MaybeApplyPatch::Body got {result:?}"),
}
}
#[test]
fn test_powershell_heredoc() {
let script = heredoc_script("");
assert_match_args(args_powershell(&script), None);
}
#[test]
fn test_powershell_heredoc_no_profile() {
let script = heredoc_script("");
assert_match_args(args_powershell_no_profile(&script), None);
}
#[test]
fn test_pwsh_heredoc() {
let script = heredoc_script("");
assert_match_args(args_pwsh(&script), None);
}
#[test]
fn test_cmd_heredoc_with_cd() {
let script = heredoc_script("cd foo && ");
assert_match_args(args_cmd(&script), Some("foo"));
}
#[test]
fn test_heredoc_with_leading_cd() {
assert_match(&heredoc_script("cd foo && "), Some("foo"));
}
#[test]
fn test_cd_with_semicolon_is_ignored() {
assert_not_match(&heredoc_script("cd foo; "));
}
#[test]
fn test_cd_or_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar || "));
}
#[test]
fn test_cd_pipe_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd bar | "));
}
#[test]
fn test_cd_single_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd 'foo bar' && "), Some("foo bar"));
}
#[test]
fn test_cd_double_quoted_path_with_spaces() {
assert_match(&heredoc_script("cd \"foo bar\" && "), Some("foo bar"));
}
#[test]
fn test_echo_and_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("echo foo && "));
}
#[test]
fn test_apply_patch_with_arg_is_ignored() {
let script = "apply_patch foo <<'PATCH'\n*** Begin Patch\n*** Add File: foo\n+hi\n*** End Patch\nPATCH";
assert_not_match(script);
}
#[test]
fn test_double_cd_then_apply_patch_is_ignored() {
assert_not_match(&heredoc_script("cd foo && cd bar && "));
}
#[test]
fn test_cd_two_args_is_ignored() {
assert_not_match(&heredoc_script("cd foo bar && "));
}
#[test]
fn test_cd_then_apply_patch_then_extra_is_ignored() {
let script = heredoc_script_ps("cd bar && ", " && echo done");
assert_not_match(&script);
}
#[test]
fn test_echo_then_cd_and_apply_patch_is_ignored() {
// Ensure preceding commands before the `cd && apply_patch <<...` sequence do not match.
assert_not_match(&heredoc_script("echo foo; cd bar && "));
}
#[test]
fn test_add_file_hunk_creates_file_with_contents() {
let dir = tempdir().unwrap();
@@ -1657,99 +1043,6 @@ g
);
}
#[test]
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
let session_dir = tempdir().unwrap();
let relative_path = "source.txt";
// Note that we need this file to exist for the patch to be "verified"
// and parsed correctly.
let session_file_path = session_dir.path().join(relative_path);
fs::write(&session_file_path, "session directory content\n").unwrap();
let argv = vec![
"apply_patch".to_string(),
r#"*** Begin Patch
*** Update File: source.txt
@@
-session directory content
+updated session directory content
*** End Patch"#
.to_string(),
];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
// Verify the patch contents - as otherwise we may have pulled contents
// from the wrong file (as we're using relative paths)
assert_eq!(
result,
MaybeApplyPatchVerified::Body(ApplyPatchAction {
changes: HashMap::from([(
session_dir.path().join(relative_path),
ApplyPatchFileChange::Update {
unified_diff: r#"@@ -1 +1 @@
-session directory content
+updated session directory content
"#
.to_string(),
move_path: None,
new_content: "updated session directory content\n".to_string(),
},
)]),
patch: argv[1].clone(),
cwd: session_dir.path().to_path_buf(),
})
);
}
#[test]
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
let session_dir = tempdir().unwrap();
let worktree_rel = "alt";
let worktree_dir = session_dir.path().join(worktree_rel);
fs::create_dir_all(&worktree_dir).unwrap();
let source_name = "old.txt";
let dest_name = "renamed.txt";
let source_path = worktree_dir.join(source_name);
fs::write(&source_path, "before\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {source_name}
*** Move to: {dest_name}
@@
-before
+after"#
));
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
let argv = vec!["bash".into(), "-lc".into(), shell_script];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
let action = match result {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected verified body, got {other:?}"),
};
assert_eq!(action.cwd, worktree_dir);
let change = action
.changes()
.get(&worktree_dir.join(source_name))
.expect("source file change present");
match change {
ApplyPatchFileChange::Update { move_path, .. } => {
assert_eq!(
move_path.as_deref(),
Some(worktree_dir.join(dest_name).as_path())
);
}
other => panic!("expected update change, got {other:?}"),
}
}
#[test]
fn test_apply_patch_fails_on_write_error() {
let dir = tempdir().unwrap();

View File

@@ -0,0 +1 @@
stable

View File

@@ -0,0 +1,3 @@
line1
naïve café ✅
line3

View File

@@ -0,0 +1,3 @@
line1
naïve café
line3

View File

@@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: foo.txt
@@
line1
-naïve café
+naïve café ✅
*** End Patch

View File

@@ -3,7 +3,6 @@ use std::path::PathBuf;
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use crate::get_task::GetTaskResponse;
@@ -28,7 +27,6 @@ pub async fn run_apply_command(
.config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
ConfigOverrides::default(),
)
.await?;

View File

@@ -109,7 +109,7 @@ async fn run_command_under_sandbox(
log_denials: bool,
) -> anyhow::Result<()> {
let sandbox_mode = create_sandbox_mode(full_auto);
let config = Config::load_with_cli_overrides(
let config = Config::load_with_cli_overrides_and_harness_overrides(
config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,

View File

@@ -6,7 +6,6 @@ use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::logout;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::ServerOptions;
use codex_login::run_device_code_login;
use codex_login::run_login_server;
@@ -210,8 +209,7 @@ async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config
}
};
let config_overrides = ConfigOverrides::default();
match Config::load_with_cli_overrides(cli_overrides, config_overrides).await {
match Config::load_with_cli_overrides(cli_overrides).await {
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {e}");

View File

@@ -410,7 +410,7 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str {
use codex_core::features::Stage;
match stage {
Stage::Experimental => "experimental",
Stage::Beta => "beta",
Stage::Beta { .. } => "beta",
Stage::Stable => "stable",
Stage::Deprecated => "deprecated",
Stage::Removed => "removed",
@@ -631,7 +631,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
..Default::default()
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
let config = Config::load_with_cli_overrides_and_harness_overrides(
cli_kv_overrides,
overrides,
)
.await?;
for def in codex_core::features::FEATURES.iter() {
let name = def.key;
let stage = stage_str(def.stage);

View File

@@ -8,7 +8,6 @@ use clap::ArgGroup;
use codex_common::CliConfigOverrides;
use codex_common::format_env_display::format_env_display;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_core::config::load_global_mcp_servers;
@@ -200,7 +199,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
@@ -349,7 +348,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
@@ -392,7 +391,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
@@ -421,7 +420,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
@@ -678,7 +677,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default())
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;

View File

@@ -5,7 +5,6 @@ use chrono::Utc;
use reqwest::header::HeaderMap;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::AuthManager;
pub fn set_user_agent_suffix(suffix: &str) {
@@ -62,9 +61,7 @@ pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
pub async fn load_auth_manager() -> Option<AuthManager> {
// TODO: pass in cli overrides once cloud tasks properly support them.
let config = Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default())
.await
.ok()?;
let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?;
Some(AuthManager::new(
config.codex_home,
false,

View File

@@ -32,6 +32,7 @@ pub struct ResponsesOptions {
pub store_override: Option<bool>,
pub conversation_id: Option<String>,
pub session_source: Option<SessionSource>,
pub extra_headers: HeaderMap,
}
impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
@@ -58,7 +59,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
self.stream(request.body, request.headers).await
}
#[instrument(skip_all, err)]
#[instrument(level = "trace", skip_all, err)]
pub async fn stream_prompt(
&self,
model: &str,
@@ -73,6 +74,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
store_override,
conversation_id,
session_source,
extra_headers,
} = options;
let request = ResponsesRequestBuilder::new(model, &prompt.instructions, &prompt.input)
@@ -85,6 +87,7 @@ impl<T: HttpTransport, A: AuthProvider> ResponsesClient<T, A> {
.conversation(conversation_id)
.session_source(session_source)
.store_override(store_override)
.extra_headers(extra_headers)
.build(self.streaming.provider())?;
self.stream_request(request).await

View File

@@ -181,7 +181,7 @@ mod tests {
use opentelemetry::trace::TracerProvider;
use opentelemetry_sdk::propagation::TraceContextPropagator;
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing::info_span;
use tracing::trace_span;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
@@ -195,7 +195,7 @@ mod tests {
tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer));
let _guard = subscriber.set_default();
let span = info_span!("client_request");
let span = trace_span!("client_request");
let _entered = span.enter();
let span_context = span.context().span().span_context().clone();

View File

@@ -9,7 +9,7 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta
("workdir", config.cwd.display().to_string()),
("model", model.to_string()),
("provider", config.model_provider_id.clone()),
("approval", config.approval_policy.to_string()),
("approval", config.approval_policy.value().to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if config.model_provider.wire_api == WireApi::Responses {

395
codex-rs/core/models.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -45,6 +45,7 @@ use crate::config::Config;
use crate::default_client::build_reqwest_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::features::FEATURES;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
@@ -261,6 +262,7 @@ impl ModelClient {
store_override: None,
conversation_id: Some(conversation_id.clone()),
session_source: Some(session_source.clone()),
extra_headers: beta_feature_headers(&self.config),
};
let stream_result = client
@@ -396,6 +398,27 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec<Value
}
}
fn beta_feature_headers(config: &Config) -> ApiHeaderMap {
let enabled = FEATURES
.iter()
.filter_map(|spec| {
if spec.stage.beta_menu_description().is_some() && config.features.enabled(spec.id) {
Some(spec.key)
} else {
None
}
})
.collect::<Vec<_>>();
let value = enabled.join(",");
let mut headers = ApiHeaderMap::new();
if !value.is_empty()
&& let Ok(header_value) = HeaderValue::from_str(value.as_str())
{
headers.insert("x-codex-beta-features", header_value);
}
headers
}
fn map_response_stream<S>(api_stream: S, otel_manager: OtelManager) -> ResponseStream
where
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>

View File

@@ -66,8 +66,8 @@ use tracing::debug;
use tracing::error;
use tracing::field;
use tracing::info;
use tracing::info_span;
use tracing::instrument;
use tracing::trace_span;
use tracing::warn;
use crate::ModelProviderInfo;
@@ -77,6 +77,9 @@ use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::compact::collect_user_messages;
use crate::config::Config;
use crate::config::Constrained;
use crate::config::ConstraintError;
use crate::config::ConstraintResult;
use crate::config::GhostSnapshotConfig;
use crate::config::types::ShellEnvironmentPolicy;
use crate::context_manager::ContextManager;
@@ -96,6 +99,7 @@ use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
use crate::protocol::BackgroundEventEvent;
use crate::protocol::DeprecationNoticeEvent;
use crate::protocol::ErrorEvent;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecApprovalRequestEvent;
@@ -260,7 +264,7 @@ impl Codex {
user_instructions,
base_instructions: config.base_instructions.clone(),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.approval_policy,
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
@@ -411,7 +415,7 @@ pub(crate) struct SessionConfiguration {
compact_prompt: Option<String>,
/// When to escalate for approval for execution
approval_policy: AskForApproval,
approval_policy: Constrained<AskForApproval>,
/// How to sandbox commands executed in the system
sandbox_policy: SandboxPolicy,
@@ -434,7 +438,7 @@ pub(crate) struct SessionConfiguration {
}
impl SessionConfiguration {
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> Self {
pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult<Self> {
let mut next_configuration = self.clone();
if let Some(model) = updates.model.clone() {
next_configuration.model = model;
@@ -446,7 +450,7 @@ impl SessionConfiguration {
next_configuration.model_reasoning_summary = summary;
}
if let Some(approval_policy) = updates.approval_policy {
next_configuration.approval_policy = approval_policy;
next_configuration.approval_policy.set(approval_policy)?;
}
if let Some(sandbox_policy) = updates.sandbox_policy.clone() {
next_configuration.sandbox_policy = sandbox_policy;
@@ -454,7 +458,7 @@ impl SessionConfiguration {
if let Some(cwd) = updates.cwd.clone() {
next_configuration.cwd = cwd;
}
next_configuration
Ok(next_configuration)
}
}
@@ -523,7 +527,7 @@ impl Session {
base_instructions: session_configuration.base_instructions.clone(),
compact_prompt: session_configuration.compact_prompt.clone(),
user_instructions: session_configuration.user_instructions.clone(),
approval_policy: session_configuration.approval_policy,
approval_policy: session_configuration.approval_policy.value(),
sandbox_policy: session_configuration.sandbox_policy.clone(),
shell_environment_policy: per_turn_config.shell_environment_policy.clone(),
tools_config,
@@ -640,7 +644,7 @@ impl Session {
config.model_reasoning_summary,
config.model_context_window,
config.model_auto_compact_token_limit,
config.approval_policy,
config.approval_policy.value(),
config.sandbox_policy.clone(),
config.mcp_servers.keys().map(String::as_str).collect(),
config.active_profile.clone(),
@@ -690,7 +694,7 @@ impl Session {
session_id: conversation_id,
model: session_configuration.model.clone(),
model_provider_id: config.model_provider_id.clone(),
approval_policy: session_configuration.approval_policy,
approval_policy: session_configuration.approval_policy.value(),
sandbox_policy: session_configuration.sandbox_policy.clone(),
cwd: session_configuration.cwd.clone(),
reasoning_effort: session_configuration.model_reasoning_effort,
@@ -729,6 +733,30 @@ impl Session {
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
sess.record_initial_history(initial_history).await;
if sess.enabled(Feature::Skills) {
let mut rx = sess
.services
.skills_manager
.subscribe_skills_update_notifications();
let sess = Arc::downgrade(&sess);
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(()) => {
let Some(sess) = sess.upgrade() else {
break;
};
let turn_context = sess.new_default_turn().await;
sess.send_event(turn_context.as_ref(), EventMsg::SkillsUpdateAvailable)
.await;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
}
Ok(sess)
}
@@ -762,7 +790,7 @@ impl Session {
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_turn(SessionSettingsUpdate::default()).await;
let turn_context = self.new_default_turn().await;
match conversation_history {
InitialHistory::New => {
// Build and record initial items (user instructions + environment context)
@@ -821,30 +849,76 @@ impl Session {
}
}
pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) {
pub(crate) async fn update_settings(
&self,
updates: SessionSettingsUpdate,
) -> ConstraintResult<()> {
let mut state = self.state.lock().await;
state.session_configuration = state.session_configuration.apply(&updates);
}
pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc<TurnContext> {
let sub_id = self.next_internal_sub_id();
self.new_turn_with_sub_id(sub_id, updates).await
match state.session_configuration.apply(&updates) {
Ok(updated) => {
state.session_configuration = updated;
Ok(())
}
Err(err) => {
let wrapped = ConstraintError {
message: format!("Could not update config: {err}"),
};
warn!(%wrapped, "rejected session settings update");
Err(wrapped)
}
}
}
pub(crate) async fn new_turn_with_sub_id(
&self,
sub_id: String,
updates: SessionSettingsUpdate,
) -> Arc<TurnContext> {
) -> ConstraintResult<Arc<TurnContext>> {
let (session_configuration, sandbox_policy_changed) = {
let mut state = self.state.lock().await;
let session_configuration = state.session_configuration.clone().apply(&updates);
let sandbox_policy_changed =
state.session_configuration.sandbox_policy != session_configuration.sandbox_policy;
state.session_configuration = session_configuration.clone();
(session_configuration, sandbox_policy_changed)
match state.session_configuration.clone().apply(&updates) {
Ok(next) => {
let sandbox_policy_changed =
state.session_configuration.sandbox_policy != next.sandbox_policy;
state.session_configuration = next.clone();
(next, sandbox_policy_changed)
}
Err(err) => {
drop(state);
let wrapped = ConstraintError {
message: format!("Could not update config: {err}"),
};
self.send_event_raw(Event {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: wrapped.to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
return Err(wrapped);
}
}
};
Ok(self
.new_turn_from_configuration(
sub_id,
session_configuration,
updates.final_output_json_schema,
sandbox_policy_changed,
)
.await)
}
async fn new_turn_from_configuration(
&self,
sub_id: String,
session_configuration: SessionConfiguration,
final_output_json_schema: Option<Option<Value>>,
sandbox_policy_changed: bool,
) -> Arc<TurnContext> {
let per_turn_config = Self::build_per_turn_config(&session_configuration);
if sandbox_policy_changed {
@@ -880,12 +954,26 @@ impl Session {
self.conversation_id,
sub_id,
);
if let Some(final_schema) = updates.final_output_json_schema {
if let Some(final_schema) = final_output_json_schema {
turn_context.final_output_json_schema = final_schema;
}
Arc::new(turn_context)
}
pub(crate) async fn new_default_turn(&self) -> Arc<TurnContext> {
self.new_default_turn_with_sub_id(self.next_internal_sub_id())
.await
}
pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc<TurnContext> {
let session_configuration = {
let state = self.state.lock().await;
state.session_configuration.clone()
};
self.new_turn_from_configuration(sub_id, session_configuration, None, false)
.await
}
fn build_environment_update_item(
&self,
previous: Option<&Arc<TurnContext>>,
@@ -1529,8 +1617,7 @@ impl Session {
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
// Seed with context in case there is an OverrideTurnContext first.
let mut previous_context: Option<Arc<TurnContext>> =
Some(sess.new_turn(SessionSettingsUpdate::default()).await);
let mut previous_context: Option<Arc<TurnContext>> = Some(sess.new_default_turn().await);
// To break out of this loop, send Op::Shutdown.
while let Ok(sub) = rx_sub.recv().await {
@@ -1549,6 +1636,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
} => {
handlers::override_turn_context(
&sess,
sub.id.clone(),
SessionSettingsUpdate {
cwd,
approval_policy,
@@ -1584,8 +1672,8 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::ListCustomPrompts => {
handlers::list_custom_prompts(&sess, sub.id.clone()).await;
}
Op::ListSkills { cwds } => {
handlers::list_skills(&sess, sub.id.clone(), cwds).await;
Op::ListSkills { cwds, force_reload } => {
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
}
Op::Undo => {
handlers::undo(&sess, sub.id.clone()).await;
@@ -1666,8 +1754,21 @@ mod handlers {
sess.interrupt_task().await;
}
pub async fn override_turn_context(sess: &Session, updates: SessionSettingsUpdate) {
sess.update_settings(updates).await;
pub async fn override_turn_context(
sess: &Session,
sub_id: String,
updates: SessionSettingsUpdate,
) {
if let Err(err) = sess.update_settings(updates).await {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: err.to_string(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
}),
})
.await;
}
}
pub async fn user_input_or_turn(
@@ -1702,7 +1803,10 @@ mod handlers {
_ => unreachable!(),
};
let current_context = sess.new_turn_with_sub_id(sub_id, updates).await;
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else {
// new_turn_with_sub_id already emits the error event.
return;
};
current_context
.client
.get_otel_manager()
@@ -1729,9 +1833,7 @@ mod handlers {
command: String,
previous_context: &mut Option<Arc<TurnContext>>,
) {
let turn_context = sess
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
.await;
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(
Arc::clone(&turn_context),
Vec::new(),
@@ -1885,7 +1987,12 @@ mod handlers {
sess.send_event_raw(event).await;
}
pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>) {
pub async fn list_skills(
sess: &Session,
sub_id: String,
cwds: Vec<PathBuf>,
force_reload: bool,
) {
let cwds = if cwds.is_empty() {
let state = sess.state.lock().await;
vec![state.session_configuration.cwd.clone()]
@@ -1896,7 +2003,7 @@ mod handlers {
let skills_manager = &sess.services.skills_manager;
cwds.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd(&cwd);
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
let errors = super::errors_to_info(&outcome.errors);
let skills = super::skills_to_info(&outcome.skills);
SkillsListEntry {
@@ -1923,17 +2030,13 @@ mod handlers {
}
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
.await;
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(turn_context, Vec::new(), UndoTask::new())
.await;
}
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess
.new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default())
.await;
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(
Arc::clone(&turn_context),
@@ -1987,9 +2090,7 @@ mod handlers {
sub_id: String,
review_request: ReviewRequest,
) {
let turn_context = sess
.new_turn_with_sub_id(sub_id.clone(), SessionSettingsUpdate::default())
.await;
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
match resolve_review_request(review_request, config.cwd.as_path()) {
Ok(resolved) => {
spawn_review_thread(
@@ -2150,6 +2251,16 @@ pub(crate) async fn run_task(
if input.is_empty() {
return None;
}
let auto_compact_limit = turn_context
.client
.get_model_family()
.auto_compact_token_limit()
.unwrap_or(i64::MAX);
let total_usage_tokens = sess.get_total_token_usage().await;
if total_usage_tokens >= auto_compact_limit {
run_auto_compact(&sess, &turn_context).await;
}
let event = EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: turn_context.client.get_model_context_window(),
});
@@ -2232,25 +2343,12 @@ pub(crate) async fn run_task(
needs_follow_up,
last_agent_message: turn_last_agent_message,
} = turn_output;
let limit = turn_context
.client
.get_model_family()
.auto_compact_token_limit()
.unwrap_or(i64::MAX);
let total_usage_tokens = sess.get_total_token_usage().await;
let token_limit_reached = total_usage_tokens >= limit;
let token_limit_reached = total_usage_tokens >= auto_compact_limit;
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached {
if should_use_remote_compact_task(
sess.as_ref(),
&turn_context.client.get_provider(),
) {
run_inline_remote_auto_compact_task(sess.clone(), turn_context.clone())
.await;
} else {
run_inline_auto_compact_task(sess.clone(), turn_context.clone()).await;
}
if token_limit_reached && needs_follow_up {
run_auto_compact(&sess, &turn_context).await;
continue;
}
@@ -2292,7 +2390,15 @@ pub(crate) async fn run_task(
last_agent_message
}
#[instrument(
async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
if should_use_remote_compact_task(sess.as_ref(), &turn_context.client.get_provider()) {
run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await;
} else {
run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await;
}
}
#[instrument(level = "trace",
skip_all,
fields(
turn_id = %turn_context.sub_id,
@@ -2432,7 +2538,7 @@ async fn drain_in_flight(
}
#[allow(clippy::too_many_arguments)]
#[instrument(
#[instrument(level = "trace",
skip_all,
fields(
turn_id = %turn_context.sub_id,
@@ -2461,7 +2567,7 @@ async fn try_run_turn(
.client
.clone()
.stream(prompt)
.instrument(info_span!("stream_request"))
.instrument(trace_span!("stream_request"))
.or_cancel(&cancellation_token)
.await??;
@@ -2477,9 +2583,9 @@ async fn try_run_turn(
let mut last_agent_message: Option<String> = None;
let mut active_item: Option<TurnItem> = None;
let mut should_emit_turn_diff = false;
let receiving_span = info_span!("receiving_stream");
let receiving_span = trace_span!("receiving_stream");
let outcome: CodexResult<TurnRunResult> = loop {
let handle_responses = info_span!(
let handle_responses = trace_span!(
parent: &receiving_span,
"handle_responses",
otel.name = field::Empty,
@@ -2489,7 +2595,7 @@ async fn try_run_turn(
let event = match stream
.next()
.instrument(info_span!(parent: &handle_responses, "receiving"))
.instrument(trace_span!(parent: &handle_responses, "receiving"))
.or_cancel(&cancellation_token)
.await
{
@@ -2774,7 +2880,7 @@ mod tests {
user_instructions: config.user_instructions.clone(),
base_instructions: config.base_instructions.clone(),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.approval_policy,
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
@@ -2846,7 +2952,7 @@ mod tests {
user_instructions: config.user_instructions.clone(),
base_instructions: config.base_instructions.clone(),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.approval_policy,
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
@@ -3050,7 +3156,7 @@ mod tests {
user_instructions: config.user_instructions.clone(),
base_instructions: config.base_instructions.clone(),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.approval_policy,
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
@@ -3141,7 +3247,7 @@ mod tests {
user_instructions: config.user_instructions.clone(),
base_instructions: config.base_instructions.clone(),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.approval_policy,
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),

View File

@@ -0,0 +1,227 @@
use std::fmt;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
#[error("{message}")]
pub struct ConstraintError {
pub message: String,
}
impl ConstraintError {
pub fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> Self {
Self {
message: format!(
"value `{}` is not in the allowed set {}",
candidate.into(),
allowed.into()
),
}
}
}
pub type ConstraintResult<T> = Result<T, ConstraintError>;
impl From<ConstraintError> for std::io::Error {
fn from(err: ConstraintError) -> Self {
std::io::Error::new(std::io::ErrorKind::InvalidInput, err)
}
}
type ConstraintValidator<T> = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync;
#[derive(Clone)]
pub struct Constrained<T> {
value: T,
validator: Arc<ConstraintValidator<T>>,
}
impl<T: Send + Sync> Constrained<T> {
pub fn new(
initial_value: T,
validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static,
) -> ConstraintResult<Self> {
let validator: Arc<ConstraintValidator<T>> = Arc::new(validator);
validator(&initial_value)?;
Ok(Self {
value: initial_value,
validator,
})
}
pub fn allow_any(initial_value: T) -> Self {
Self {
value: initial_value,
validator: Arc::new(|_| Ok(())),
}
}
pub fn allow_only(value: T) -> Self
where
T: PartialEq + Send + Sync + fmt::Debug + Clone + 'static,
{
#[expect(clippy::expect_used)]
Self::new(value.clone(), move |candidate| {
if *candidate == value {
Ok(())
} else {
Err(ConstraintError::invalid_value(
format!("{candidate:?}"),
format!("{value:?}"),
))
}
})
.expect("initial value should always be valid")
}
/// Allow any value of T, using T's Default as the initial value.
pub fn allow_any_from_default() -> Self
where
T: Default,
{
Self::allow_any(T::default())
}
pub fn allow_values(initial_value: T, allowed: Vec<T>) -> ConstraintResult<Self>
where
T: PartialEq + Send + Sync + fmt::Debug + 'static,
{
Self::new(initial_value, move |candidate| {
if allowed.contains(candidate) {
Ok(())
} else {
Err(ConstraintError::invalid_value(
format!("{candidate:?}"),
format!("{allowed:?}"),
))
}
})
}
pub fn get(&self) -> &T {
&self.value
}
pub fn value(&self) -> T
where
T: Copy,
{
self.value
}
pub fn can_set(&self, candidate: &T) -> ConstraintResult<()> {
(self.validator)(candidate)
}
pub fn set(&mut self, value: T) -> ConstraintResult<()> {
(self.validator)(&value)?;
self.value = value;
Ok(())
}
}
impl<T> std::ops::Deref for Constrained<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T: fmt::Debug> fmt::Debug for Constrained<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Constrained")
.field("value", &self.value)
.finish()
}
}
impl<T: PartialEq> PartialEq for Constrained<T> {
fn eq(&self, other: &Self) -> bool {
self.value == other.value
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn constrained_allow_any_accepts_any_value() {
let mut constrained = Constrained::allow_any(5);
constrained.set(-10).expect("allow any accepts all values");
assert_eq!(constrained.value(), -10);
}
#[test]
fn constrained_allow_any_default_uses_default_value() {
let constrained = Constrained::<i32>::allow_any_from_default();
assert_eq!(constrained.value(), 0);
}
#[test]
fn constrained_new_rejects_invalid_initial_value() {
let result = Constrained::new(0, |value| {
if *value > 0 {
Ok(())
} else {
Err(ConstraintError::invalid_value(
value.to_string(),
"positive values",
))
}
});
assert_eq!(
result,
Err(ConstraintError::invalid_value("0", "positive values"))
);
}
#[test]
fn constrained_set_rejects_invalid_value_and_leaves_previous() {
let mut constrained = Constrained::new(1, |value| {
if *value > 0 {
Ok(())
} else {
Err(ConstraintError::invalid_value(
value.to_string(),
"positive values",
))
}
})
.expect("initial value should be accepted");
let err = constrained
.set(-5)
.expect_err("negative values should be rejected");
assert_eq!(err, ConstraintError::invalid_value("-5", "positive values"));
assert_eq!(constrained.value(), 1);
}
#[test]
fn constrained_can_set_allows_probe_without_setting() {
let constrained = Constrained::new(1, |value| {
if *value > 0 {
Ok(())
} else {
Err(ConstraintError::invalid_value(
value.to_string(),
"positive values",
))
}
})
.expect("initial value should be accepted");
constrained
.can_set(&2)
.expect("can_set should accept positive value");
let err = constrained
.can_set(&-1)
.expect_err("can_set should reject negative value");
assert_eq!(err, ConstraintError::invalid_value("-1", "positive values"));
assert_eq!(constrained.value(), 1);
}
}

View File

@@ -12,6 +12,8 @@ use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
use crate::config::types::Tui;
use crate::config::types::UriBasedFileOpener;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::features::Feature;
use crate::features::FeatureOverrides;
@@ -26,7 +28,6 @@ use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::util::resolve_path;
use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -54,10 +55,14 @@ use crate::config::profile::ConfigProfile;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
mod constraint;
pub mod edit;
pub mod profile;
pub mod service;
pub mod types;
pub use constraint::Constrained;
pub use constraint::ConstraintError;
pub use constraint::ConstraintResult;
pub use service::ConfigService;
pub use service::ConfigServiceError;
@@ -106,7 +111,7 @@ pub struct Config {
pub model_provider: ModelProviderInfo,
/// Approval policy for executing commands.
pub approval_policy: AskForApproval,
pub approval_policy: Constrained<AskForApproval>,
pub sandbox_policy: SandboxPolicy,
@@ -301,41 +306,108 @@ pub struct Config {
pub otel: crate::config::types::OtelConfig,
}
impl Config {
pub async fn load_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
overrides: ConfigOverrides,
) -> std::io::Result<Self> {
let codex_home = find_codex_home()?;
#[derive(Debug, Clone, Default)]
pub struct ConfigBuilder {
codex_home: Option<PathBuf>,
cli_overrides: Option<Vec<(String, TomlValue)>>,
harness_overrides: Option<ConfigOverrides>,
loader_overrides: Option<LoaderOverrides>,
}
let root_value = load_resolved_config(
&codex_home,
impl ConfigBuilder {
pub fn codex_home(mut self, codex_home: PathBuf) -> Self {
self.codex_home = Some(codex_home);
self
}
pub fn cli_overrides(mut self, cli_overrides: Vec<(String, TomlValue)>) -> Self {
self.cli_overrides = Some(cli_overrides);
self
}
pub fn harness_overrides(mut self, harness_overrides: ConfigOverrides) -> Self {
self.harness_overrides = Some(harness_overrides);
self
}
pub fn loader_overrides(mut self, loader_overrides: LoaderOverrides) -> Self {
self.loader_overrides = Some(loader_overrides);
self
}
pub async fn build(self) -> std::io::Result<Config> {
let Self {
codex_home,
cli_overrides,
crate::config_loader::LoaderOverrides::default(),
harness_overrides,
loader_overrides,
} = self;
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
let cli_overrides = cli_overrides.unwrap_or_default();
let harness_overrides = harness_overrides.unwrap_or_default();
let loader_overrides = loader_overrides.unwrap_or_default();
let config_layer_stack =
load_config_layers_state(&codex_home, &cli_overrides, loader_overrides).await?;
let merged_toml = config_layer_stack.effective_config();
// Note that each layer in ConfigLayerStack should have resolved
// relative paths to absolute paths based on the parent folder of the
// respective config file, so we should be safe to deserialize without
// AbsolutePathBufGuard here.
let config_toml: ConfigToml = merged_toml
.try_into()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Config::load_config_with_requirements(
config_toml,
harness_overrides,
codex_home,
config_layer_stack.requirements().clone(),
)
.await?;
let cfg = deserialize_config_toml_with_base(root_value, &codex_home).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
Self::load_from_base_config_with_overrides(cfg, overrides, codex_home)
}
}
impl Config {
/// This is the preferred way to create an instance of [Config].
pub async fn load_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<Self> {
ConfigBuilder::default()
.cli_overrides(cli_overrides)
.build()
.await
}
/// This is a secondary way of creating [Config], which is appropriate when
/// the harness is meant to be used with a specific configuration that
/// ignores user settings. For example, the `codex exec` subcommand is
/// designed to use [AskForApproval::Never] exclusively.
///
/// Further, [ConfigOverrides] contains some options that are not supported
/// in [ConfigToml], such as `cwd` and `codex_linux_sandbox_exe`.
pub async fn load_with_cli_overrides_and_harness_overrides(
cli_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
) -> std::io::Result<Self> {
ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides)
.build()
.await
}
}
/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working
/// with [ConfigToml] directly means that [ConfigRequirements] have not been
/// applied yet, which risks failing to enforce required constraints.
pub async fn load_config_as_toml_with_cli_overrides(
codex_home: &Path,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
let root_value = load_resolved_config(
codex_home,
cli_overrides,
crate::config_loader::LoaderOverrides::default(),
)
.await?;
let config_layer_stack =
load_config_layers_state(codex_home, &cli_overrides, LoaderOverrides::default()).await?;
let cfg = deserialize_config_toml_with_base(root_value, codex_home).map_err(|e| {
let merged_toml = config_layer_stack.effective_config();
let cfg = deserialize_config_toml_with_base(merged_toml, codex_home).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
@@ -343,15 +415,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
Ok(cfg)
}
async fn load_resolved_config(
codex_home: &Path,
cli_overrides: Vec<(String, TomlValue)>,
overrides: crate::config_loader::LoaderOverrides,
) -> std::io::Result<TomlValue> {
let layers = load_config_layers_state(codex_home, &cli_overrides, overrides).await?;
Ok(layers.effective_config())
}
fn deserialize_config_toml_with_base(
root_value: TomlValue,
config_base_dir: &Path,
@@ -367,13 +430,18 @@ fn deserialize_config_toml_with_base(
pub async fn load_global_mcp_servers(
codex_home: &Path,
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
let root_value = load_resolved_config(
codex_home,
Vec::new(),
crate::config_loader::LoaderOverrides::default(),
)
.await?;
let Some(servers_value) = root_value.get("mcp_servers") else {
// In general, Config::load_with_cli_overrides() should be used to load the
// full config with requirements.toml applied, but in this case, we need
// access to the raw TOML in order to warn the user about deprecated fields.
//
// Note that a more precise way to do this would be to audit the individual
// config layers for deprecated fields rather than reporting on the merged
// result.
let cli_overrides = Vec::<(String, TomlValue)>::new();
let config_layer_stack =
load_config_layers_state(codex_home, &cli_overrides, LoaderOverrides::default()).await?;
let merged_toml = config_layer_stack.effective_config();
let Some(servers_value) = merged_toml.get("mcp_servers") else {
return Ok(BTreeMap::new());
};
@@ -688,8 +756,8 @@ pub struct ConfigToml {
pub notice: Option<Notice>,
/// Legacy, now use features
pub experimental_instructions_file: Option<PathBuf>,
pub experimental_compact_prompt_file: Option<PathBuf>,
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
@@ -762,9 +830,11 @@ pub struct GhostSnapshotToml {
#[serde(alias = "ignore_untracked_files_over_bytes")]
pub ignore_large_untracked_files: Option<i64>,
/// Ignore untracked directories that contain this many files or more.
/// (Still emits a warning.)
/// (Still emits a warning unless warnings are disabled.)
#[serde(alias = "large_untracked_dir_warning_threshold")]
pub ignore_large_untracked_dirs: Option<i64>,
/// Disable all ghost snapshot warning events.
pub disable_warnings: Option<bool>,
}
#[derive(Debug, PartialEq, Eq)]
@@ -922,12 +992,23 @@ pub fn resolve_oss_provider(
}
impl Config {
/// Meant to be used exclusively for tests: `load_with_overrides()` should
/// be used in all other cases.
/// Meant to be used exclusively for tests. For new tests, prefer using
/// [ConfigBuilder::build()], if possible, so ultimately we can make this
/// method private to this file.
pub fn load_from_base_config_with_overrides(
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: PathBuf,
) -> std::io::Result<Self> {
let requirements = ConfigRequirements::default();
Self::load_config_with_requirements(cfg, overrides, codex_home, requirements)
}
fn load_config_with_requirements(
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: PathBuf,
requirements: ConfigRequirements,
) -> std::io::Result<Self> {
let user_instructions = Self::load_instructions(Some(&codex_home));
@@ -1026,15 +1107,15 @@ impl Config {
.or(cfg.approval_policy)
.unwrap_or_else(|| {
if active_project.is_trusted() {
// If no explicit approval policy is set, but we trust cwd, default to OnRequest
AskForApproval::OnRequest
} else if active_project.is_untrusted() {
// If project is explicitly marked untrusted, require approval for non-safe commands
AskForApproval::UnlessTrusted
} else {
AskForApproval::default()
}
});
// TODO(dylan): We should be able to leverage ConfigLayerStack so that
// we can reliably check this at every config level.
let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override
.is_some()
|| config_profile.approval_policy.is_some()
@@ -1084,6 +1165,11 @@ impl Config {
config.ignore_large_untracked_dirs =
if threshold > 0 { Some(threshold) } else { None };
}
if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref()
&& let Some(disable_warnings) = ghost_snapshot.disable_warnings
{
config.disable_warnings = disable_warnings;
}
config
};
@@ -1122,9 +1208,8 @@ impl Config {
.experimental_instructions_file
.as_ref()
.or(cfg.experimental_instructions_file.as_ref());
let file_base_instructions = Self::load_override_from_file(
let file_base_instructions = Self::try_read_non_empty_file(
experimental_instructions_path,
&resolved_cwd,
"experimental instructions file",
)?;
let base_instructions = base_instructions.or(file_base_instructions);
@@ -1134,9 +1219,8 @@ impl Config {
.experimental_compact_prompt_file
.as_ref()
.or(cfg.experimental_compact_prompt_file.as_ref());
let file_compact_prompt = Self::load_override_from_file(
let file_compact_prompt = Self::try_read_non_empty_file(
experimental_compact_prompt_path,
&resolved_cwd,
"experimental compact prompt file",
)?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
@@ -1148,6 +1232,16 @@ impl Config {
let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true);
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
let ConfigRequirements {
approval_policy: mut constrained_approval_policy,
} = requirements;
constrained_approval_policy
.set(approval_policy)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
let config = Self {
model,
review_model,
@@ -1156,7 +1250,7 @@ impl Config {
model_provider_id,
model_provider,
cwd: resolved_cwd,
approval_policy,
approval_policy: constrained_approval_policy,
sandbox_policy,
did_user_set_custom_approval_policy_or_sandbox_mode,
forced_auto_mode_downgraded_on_windows,
@@ -1268,21 +1362,21 @@ impl Config {
None
}
fn load_override_from_file(
path: Option<&PathBuf>,
cwd: &Path,
description: &str,
/// If `path` is `Some`, attempts to read the file at the given path and
/// returns its contents as a trimmed `String`. If the file is empty, or
/// is `Some` but cannot be read, returns an `Err`.
fn try_read_non_empty_file(
path: Option<&AbsolutePathBuf>,
context: &str,
) -> std::io::Result<Option<String>> {
let Some(p) = path else {
let Some(path) = path else {
return Ok(None);
};
let full_path = resolve_path(cwd, p);
let contents = std::fs::read_to_string(&full_path).map_err(|e| {
let contents = std::fs::read_to_string(path).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("failed to read {description} {}: {e}", full_path.display()),
format!("failed to read {context} {}: {e}", path.display()),
)
})?;
@@ -1290,7 +1384,7 @@ impl Config {
if s.is_empty() {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{description} is empty: {}", full_path.display()),
format!("{context} is empty: {}", path.display()),
))
} else {
Ok(Some(s))
@@ -1847,18 +1941,22 @@ trust_level = "trusted"
std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?;
std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?;
let overrides = crate::config_loader::LoaderOverrides {
let overrides = LoaderOverrides {
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
};
let root_value = load_resolved_config(codex_home.path(), Vec::new(), overrides).await?;
let cfg =
deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
let config_layer_stack =
load_config_layers_state(codex_home.path(), &Vec::new(), overrides).await?;
let cfg = deserialize_config_toml_with_base(
config_layer_stack.effective_config(),
codex_home.path(),
)
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(
cfg.mcp_oauth_credentials_store,
Some(OAuthCredentialsStoreMode::Keyring),
@@ -1962,24 +2060,27 @@ trust_level = "trusted"
)?;
std::fs::write(&managed_path, "model = \"managed_config\"\n")?;
let overrides = crate::config_loader::LoaderOverrides {
let overrides = LoaderOverrides {
managed_config_path: Some(managed_path),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
};
let root_value = load_resolved_config(
let config_layer_stack = load_config_layers_state(
codex_home.path(),
vec![("model".to_string(), TomlValue::String("cli".to_string()))],
&[("model".to_string(), TomlValue::String("cli".to_string()))],
overrides,
)
.await?;
let cfg =
deserialize_config_toml_with_base(root_value, codex_home.path()).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
let cfg = deserialize_config_toml_with_base(
config_layer_stack.effective_config(),
codex_home.path(),
)
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
assert_eq!(cfg.model.as_deref(), Some("managed_config"));
Ok(())
@@ -2794,7 +2895,9 @@ model = "gpt-5.1-codex"
std::fs::write(&prompt_path, " summarize differently ")?;
let cfg = ConfigToml {
experimental_compact_prompt_file: Some(PathBuf::from("compact_prompt.txt")),
experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(
prompt_path,
)?),
..Default::default()
};
@@ -2945,7 +3048,7 @@ model_verbosity = "high"
model_auto_compact_token_limit: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::Never,
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
@@ -3020,7 +3123,7 @@ model_verbosity = "high"
model_auto_compact_token_limit: None,
model_provider_id: "openai-chat-completions".to_string(),
model_provider: fixture.openai_chat_completions_provider.clone(),
approval_policy: AskForApproval::UnlessTrusted,
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
@@ -3110,7 +3213,7 @@ model_verbosity = "high"
model_auto_compact_token_limit: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::OnFailure,
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
@@ -3186,7 +3289,7 @@ model_verbosity = "high"
model_auto_compact_token_limit: None,
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::OnFailure,
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: SandboxPolicy::new_read_only_policy(),
did_user_set_custom_approval_policy_or_sandbox_mode: true,
forced_auto_mode_downgraded_on_windows: false,
@@ -3500,26 +3603,21 @@ trust_level = "untrusted"
}
#[test]
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> std::io::Result<()> {
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let test_project_dir = TempDir::new()?;
let test_path = test_project_dir.path();
let mut projects = std::collections::HashMap::new();
projects.insert(
test_path.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
);
let cfg = ConfigToml {
projects: Some(projects),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigToml {
projects: Some(HashMap::from([(
test_path.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Untrusted),
},
)])),
..Default::default()
},
ConfigOverrides {
cwd: Some(test_path.to_path_buf()),
..Default::default()
@@ -3529,7 +3627,7 @@ trust_level = "untrusted"
// Verify that untrusted projects get UnlessTrusted approval policy
assert_eq!(
config.approval_policy,
config.approval_policy.value(),
AskForApproval::UnlessTrusted,
"Expected UnlessTrusted approval policy for untrusted project"
);

View File

@@ -1,5 +1,5 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::path::PathBuf;
use crate::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningSummary;
@@ -21,8 +21,8 @@ pub struct ConfigProfile {
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub chatgpt_base_url: Option<String>,
pub experimental_instructions_file: Option<PathBuf>,
pub experimental_compact_prompt_file: Option<PathBuf>,
pub experimental_instructions_file: Option<AbsolutePathBuf>,
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
pub include_apply_patch_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,

View File

@@ -2,15 +2,15 @@ use super::CONFIG_TOML_FILE;
use super::ConfigToml;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::config_loader::merge_toml_values;
use crate::path_utils;
use codex_app_server_protocol::Config as ApiConfig;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerName;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
@@ -19,6 +19,7 @@ use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::OverriddenMetadata;
use codex_app_server_protocol::WriteStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::path::Path;
use std::path::PathBuf;
@@ -145,7 +146,13 @@ impl ConfigService {
Ok(ConfigReadResponse {
config,
origins: layers.origins(),
layers: params.include_layers.then(|| layers.layers_high_to_low()),
layers: params.include_layers.then(|| {
layers
.layers_high_to_low()
.iter()
.map(|layer| layer.as_layer())
.collect()
}),
})
}
@@ -193,11 +200,14 @@ impl ConfigService {
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigServiceError> {
let allowed_path = self.codex_home.join(CONFIG_TOML_FILE);
let provided_path = file_path
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| allowed_path.clone());
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, &self.codex_home)
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?;
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?,
None => allowed_path.clone(),
};
if !paths_match(&allowed_path, &provided_path) {
return Err(ConfigServiceError::write(
@@ -210,9 +220,16 @@ impl ConfigService {
.load_layers_state()
.await
.map_err(|err| ConfigServiceError::io("failed to load configuration", err))?;
let user_layer = layers.get_user_layer().ok_or_else(|| {
// TODO(mbolin): Support creating the user layer if it does not exist.
ConfigServiceError::write(
ConfigWriteErrorCode::UserLayerNotFound,
"user layer not found and we do not support creating it yet",
)
})?;
if let Some(expected) = expected_version.as_deref()
&& expected != layers.user.version
&& expected != user_layer.version
{
return Err(ConfigServiceError::write(
ConfigWriteErrorCode::ConfigVersionConflict,
@@ -220,7 +237,7 @@ impl ConfigService {
));
}
let mut user_config = layers.user.config.clone();
let mut user_config = user_layer.config.clone();
let mut parsed_segments = Vec::new();
let mut config_edits = Vec::new();
@@ -272,7 +289,7 @@ impl ConfigService {
)
})?;
let updated_layers = layers.with_user_config(user_config.clone());
let updated_layers = layers.with_user_config(&provided_path, user_config.clone());
let effective = updated_layers.effective_config();
validate_config(&effective).map_err(|err| {
ConfigServiceError::write(
@@ -295,16 +312,19 @@ impl ConfigService {
.map(|_| WriteStatus::OkOverridden)
.unwrap_or(WriteStatus::Ok);
let file_path = provided_path
.canonicalize()
.unwrap_or(provided_path.clone())
.display()
.to_string();
Ok(ConfigWriteResponse {
status,
version: updated_layers.user.version.clone(),
file_path,
version: updated_layers
.get_user_layer()
.ok_or_else(|| {
ConfigServiceError::write(
ConfigWriteErrorCode::UserLayerNotFound,
"user layer not found in updated layers",
)
})?
.version
.clone(),
file_path: provided_path,
overridden_metadata: overridden,
})
}
@@ -469,14 +489,15 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
Ok(())
}
fn paths_match(expected: &Path, provided: &Path) -> bool {
if let (Ok(expanded_expected), Ok(expanded_provided)) =
(expected.canonicalize(), provided.canonicalize())
{
return expanded_expected == expanded_provided;
fn paths_match<P: AsRef<Path>, Q: AsRef<Path>>(expected: P, provided: Q) -> bool {
if let (Ok(expanded_expected), Ok(expanded_provided)) = (
path_utils::normalize_for_path_comparison(&expected),
path_utils::normalize_for_path_comparison(&provided),
) {
expanded_expected == expanded_provided
} else {
expected.as_ref() == provided.as_ref()
}
expected == provided
}
fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
@@ -497,12 +518,27 @@ fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a Tom
Some(current)
}
fn override_message(layer: &ConfigLayerName) -> String {
fn override_message(layer: &ConfigLayerSource) -> String {
match layer {
ConfigLayerName::Mdm => "Overridden by managed policy (mdm)".to_string(),
ConfigLayerName::System => "Overridden by managed config (system)".to_string(),
ConfigLayerName::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerName::User => "Overridden by user config".to_string(),
ConfigLayerSource::Mdm { domain, key: _ } => {
format!("Overridden by managed policy (MDM): {domain}")
}
ConfigLayerSource::System { file } => {
format!("Overridden by managed config (system): {}", file.display())
}
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file } => {
format!("Overridden by user config: {}", file.display())
}
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
format!(
"Overridden by legacy managed_config.toml: {}",
file.display()
)
}
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {
"Overridden by legacy managed configuration from MDM".to_string()
}
}
}
@@ -511,7 +547,10 @@ fn compute_override_metadata(
effective: &TomlValue,
segments: &[String],
) -> Option<OverriddenMetadata> {
let user_value = value_at_path(&layers.user.config, segments);
let user_value = match layers.get_user_layer() {
Some(user_layer) => value_at_path(&user_layer.config, segments),
None => return None,
};
let effective_value = value_at_path(effective, segments);
if user_value.is_some() && user_value == effective_value {
@@ -522,8 +561,7 @@ fn compute_override_metadata(
return None;
}
let effective_layer = find_effective_layer(layers, segments);
let overriding_layer = effective_layer.unwrap_or_else(|| layers.user.metadata());
let overriding_layer = find_effective_layer(layers, segments)?;
let message = override_message(&overriding_layer.name);
Some(OverriddenMetadata {
@@ -552,23 +590,13 @@ fn find_effective_layer(
layers: &ConfigLayerStack,
segments: &[String],
) -> Option<ConfigLayerMetadata> {
let check =
|state: &ConfigLayerEntry| value_at_path(&state.config, segments).map(|_| state.metadata());
for layer in layers.layers_high_to_low() {
if let Some(meta) = value_at_path(&layer.config, segments).map(|_| layer.metadata()) {
return Some(meta);
}
}
if let Some(mdm) = &layers.mdm
&& let Some(meta) = check(mdm)
{
return Some(meta);
}
if let Some(system) = &layers.system
&& let Some(meta) = check(system)
{
return Some(meta);
}
if let Some(meta) = check(&layers.session_flags) {
return Some(meta);
}
check(&layers.user)
None
}
#[cfg(test)]
@@ -576,6 +604,7 @@ mod tests {
use super::*;
use anyhow::Result;
use codex_app_server_protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -677,16 +706,19 @@ remote_compaction = true
#[tokio::test]
async fn read_includes_origins_and_layers() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
let user_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&user_path, "model = \"user\"").unwrap();
let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file");
let managed_path = tmp.path().join("managed_config.toml");
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let service = ConfigService::with_overrides(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides {
managed_config_path: Some(managed_path),
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
},
@@ -707,12 +739,20 @@ remote_compaction = true
.get("approval_policy")
.expect("origin")
.name,
ConfigLayerName::System
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone()
},
);
let layers = response.layers.expect("layers present");
assert_eq!(layers.first().unwrap().name, ConfigLayerName::System);
assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags);
assert_eq!(layers.last().unwrap().name, ConfigLayerName::User);
assert_eq!(layers.len(), 2, "expected two layers");
assert_eq!(
layers.first().unwrap().name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(
layers.get(1).unwrap().name,
ConfigLayerSource::User { file: user_file }
);
}
#[tokio::test]
@@ -726,12 +766,13 @@ remote_compaction = true
let managed_path = tmp.path().join("managed_config.toml");
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let service = ConfigService::with_overrides(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides {
managed_config_path: Some(managed_path),
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
},
@@ -764,7 +805,9 @@ remote_compaction = true
.get("approval_policy")
.expect("origin")
.name,
ConfigLayerName::System
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone()
}
);
assert_eq!(result.status, WriteStatus::Ok);
assert!(result.overridden_metadata.is_none());
@@ -773,7 +816,8 @@ remote_compaction = true
#[tokio::test]
async fn version_conflict_rejected() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
let user_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&user_path, "model = \"user\"").unwrap();
let service = ConfigService::new(tmp.path().to_path_buf(), vec![]);
let error = service
@@ -830,7 +874,7 @@ remote_compaction = true
tmp.path().to_path_buf(),
vec![],
LoaderOverrides {
managed_config_path: Some(managed_path),
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
},
@@ -860,10 +904,13 @@ remote_compaction = true
#[tokio::test]
async fn read_reports_managed_overrides_user_and_session_flags() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
let user_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&user_path, "model = \"user\"").unwrap();
let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file");
let managed_path = tmp.path().join("managed_config.toml");
std::fs::write(&managed_path, "model = \"system\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let cli_overrides = vec![(
"model".to_string(),
@@ -874,7 +921,7 @@ remote_compaction = true
tmp.path().to_path_buf(),
cli_overrides,
LoaderOverrides {
managed_config_path: Some(managed_path),
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
},
@@ -890,12 +937,20 @@ remote_compaction = true
assert_eq!(response.config.model.as_deref(), Some("system"));
assert_eq!(
response.origins.get("model").expect("origin").name,
ConfigLayerName::System
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone()
},
);
let layers = response.layers.expect("layers");
assert_eq!(layers.first().unwrap().name, ConfigLayerName::System);
assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags);
assert_eq!(layers.get(2).unwrap().name, ConfigLayerName::User);
assert_eq!(
layers.first().unwrap().name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags);
assert_eq!(
layers.get(2).unwrap().name,
ConfigLayerSource::User { file: user_file }
);
}
#[tokio::test]
@@ -905,12 +960,13 @@ remote_compaction = true
let managed_path = tmp.path().join("managed_config.toml");
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
let service = ConfigService::with_overrides(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides {
managed_config_path: Some(managed_path),
managed_config_path: Some(managed_path.clone()),
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
},
@@ -929,7 +985,10 @@ remote_compaction = true
assert_eq!(result.status, WriteStatus::OkOverridden);
let overridden = result.overridden_metadata.expect("overridden metadata");
assert_eq!(overridden.overriding_layer.name, ConfigLayerName::System);
assert_eq!(
overridden.overriding_layer.name,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(overridden.effective_value, serde_json::json!("never"));
}

View File

@@ -16,7 +16,7 @@ Exported from `codex_core::config_loader`:
- `origins() -> HashMap<String, ConfigLayerMetadata>`
- `layers_high_to_low() -> Vec<ConfigLayer>`
- `with_user_config(user_config) -> ConfigLayerStack`
- `ConfigLayerEntry` (one layers `{name, source, config, version}`)
- `ConfigLayerEntry` (one layers `{name, config, version}`; `name` carries source metadata)
- `LoaderOverrides` (test/override hooks for managed config sources)
- `merge_toml_values(base, overlay)` (public helper used elsewhere)
@@ -61,4 +61,3 @@ Implementation is split by concern:
- `merge.rs`: recursive TOML merge.
- `fingerprint.rs`: stable per-layer hashing and per-key origins traversal.
- `macos.rs`: managed preferences integration (macOS only).

View File

@@ -0,0 +1,38 @@
use codex_protocol::protocol::AskForApproval;
use serde::Deserialize;
use crate::config::Constrained;
use crate::config::ConstraintError;
/// Normalized version of [`ConfigRequirementsToml`] after deserialization and
/// normalization.
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigRequirements {
pub approval_policy: Constrained<AskForApproval>,
}
impl Default for ConfigRequirements {
fn default() -> Self {
Self {
approval_policy: Constrained::allow_any_from_default(),
}
}
}
/// Base config deserialized from /etc/codex/requirements.toml or MDM.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsToml {
pub approval_policy: Option<Vec<AskForApproval>>,
}
impl TryFrom<ConfigRequirementsToml> for ConfigRequirements {
type Error = ConstraintError;
fn try_from(toml: ConfigRequirementsToml) -> Result<Self, Self::Error> {
let approval_policy: Constrained<AskForApproval> = match toml.approval_policy {
Some(policies) => Constrained::allow_values(AskForApproval::default(), policies)?,
None => Constrained::allow_any_from_default(),
};
Ok(ConfigRequirements { approval_policy })
}
}

View File

@@ -1,7 +1,7 @@
use super::LoaderOverrides;
#[cfg(target_os = "macos")]
use super::macos::load_managed_admin_config_layer;
use super::overrides::default_empty_table;
use crate::config::CONFIG_TOML_FILE;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::io;
use std::path::Path;
use std::path::PathBuf;
@@ -11,11 +11,18 @@ use toml::Value as TomlValue;
#[cfg(unix)]
const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml";
#[derive(Debug, Clone)]
pub(super) struct MangedConfigFromFile {
pub managed_config: TomlValue,
pub file: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
pub(super) struct LoadedConfigLayers {
pub base: TomlValue,
pub managed_config: Option<TomlValue>,
pub managed_preferences: Option<TomlValue>,
/// If present, data read from a file such as `/etc/codex/managed_config.toml`.
pub managed_config: Option<MangedConfigFromFile>,
/// If present, data read from managed preferences (macOS only).
pub managed_config_from_mdm: Option<TomlValue>,
}
pub(super) async fn load_config_layers_internal(
@@ -33,49 +40,52 @@ pub(super) async fn load_config_layers_internal(
managed_config_path,
} = overrides;
let managed_config_path =
managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home));
let managed_config_path = AbsolutePathBuf::from_absolute_path(
managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)),
)?;
let user_config_path = codex_home.join(CONFIG_TOML_FILE);
let user_config = read_config_from_path(&user_config_path, true).await?;
let managed_config = read_config_from_path(&managed_config_path, false).await?;
let managed_config = read_config_from_path(&managed_config_path, false)
.await?
.map(|managed_config| MangedConfigFromFile {
managed_config,
file: managed_config_path.clone(),
});
#[cfg(target_os = "macos")]
let managed_preferences =
load_managed_admin_config_layer(managed_preferences_base64.as_deref()).await?;
#[cfg(not(target_os = "macos"))]
let managed_preferences = load_managed_admin_config_layer(None).await?;
let managed_preferences = None;
Ok(LoadedConfigLayers {
base: user_config.unwrap_or_else(default_empty_table),
managed_config,
managed_preferences,
managed_config_from_mdm: managed_preferences,
})
}
pub(super) async fn read_config_from_path(
path: &Path,
pub(super) async fn read_config_from_path<P: AsRef<Path>>(
path: P,
log_missing_as_info: bool,
) -> io::Result<Option<TomlValue>> {
match fs::read_to_string(path).await {
match fs::read_to_string(path.as_ref()).await {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(value) => Ok(Some(value)),
Err(err) => {
tracing::error!("Failed to parse {}: {err}", path.display());
tracing::error!("Failed to parse {}: {err}", path.as_ref().display());
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
},
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if log_missing_as_info {
tracing::info!("{} not found, using defaults", path.display());
tracing::info!("{} not found, using defaults", path.as_ref().display());
} else {
tracing::debug!("{} not found", path.display());
tracing::debug!("{} not found", path.as_ref().display());
}
Ok(None)
}
Err(err) => {
tracing::error!("Failed to read {}: {err}", path.display());
tracing::error!("Failed to read {}: {err}", path.as_ref().display());
Err(err)
}
}

View File

@@ -1,118 +1,100 @@
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use core_foundation::string::CFStringRef;
use std::ffi::c_void;
use std::io;
use tokio::task;
use toml::Value as TomlValue;
#[cfg(target_os = "macos")]
mod native {
use super::*;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use core_foundation::string::CFStringRef;
use std::ffi::c_void;
use tokio::task;
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
) -> io::Result<Option<TomlValue>> {
if let Some(encoded) = override_base64 {
let trimmed = encoded.trim();
return if trimmed.is_empty() {
Ok(None)
} else {
parse_managed_preferences_base64(trimmed).map(Some)
};
}
const LOAD_ERROR: &str = "Failed to load managed preferences configuration";
match task::spawn_blocking(load_managed_admin_config).await {
Ok(result) => result,
Err(join_err) => {
if join_err.is_cancelled() {
tracing::error!("Managed preferences load task was cancelled");
} else {
tracing::error!("Managed preferences load task failed: {join_err}");
}
Err(io::Error::other(LOAD_ERROR))
}
}
}
pub(super) fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFPreferencesCopyAppValue(
key: CFStringRef,
application_id: CFStringRef,
) -> *mut c_void;
}
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
let value_ref = unsafe {
CFPreferencesCopyAppValue(
key.as_concrete_TypeRef(),
application_id.as_concrete_TypeRef(),
)
};
if value_ref.is_null() {
tracing::debug!(
"Managed preferences for {} key {} not found",
MANAGED_PREFERENCES_APPLICATION_ID,
MANAGED_PREFERENCES_CONFIG_KEY
);
return Ok(None);
}
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
let contents = value.to_string();
let trimmed = contents.trim();
parse_managed_preferences_base64(trimmed).map(Some)
}
pub(super) fn parse_managed_preferences_base64(encoded: &str) -> io::Result<TomlValue> {
let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
tracing::error!("Failed to decode managed preferences as base64: {err}");
io::Error::new(io::ErrorKind::InvalidData, err)
})?;
let decoded_str = String::from_utf8(decoded).map_err(|err| {
tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
io::Error::new(io::ErrorKind::InvalidData, err)
})?;
match toml::from_str::<TomlValue>(&decoded_str) {
Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)),
Ok(other) => {
tracing::error!(
"Managed preferences TOML must have a table at the root, found {other:?}",
);
Err(io::Error::new(
io::ErrorKind::InvalidData,
"managed preferences root must be a table",
))
}
Err(err) => {
tracing::error!("Failed to parse managed preferences TOML: {err}");
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
}
}
}
#[cfg(target_os = "macos")]
pub(crate) use native::load_managed_admin_config_layer;
#[cfg(not(target_os = "macos"))]
pub(crate) async fn load_managed_admin_config_layer(
_override_base64: Option<&str>,
override_base64: Option<&str>,
) -> io::Result<Option<TomlValue>> {
Ok(None)
if let Some(encoded) = override_base64 {
let trimmed = encoded.trim();
return if trimmed.is_empty() {
Ok(None)
} else {
parse_managed_preferences_base64(trimmed).map(Some)
};
}
const LOAD_ERROR: &str = "Failed to load managed preferences configuration";
match task::spawn_blocking(load_managed_admin_config).await {
Ok(result) => result,
Err(join_err) => {
if join_err.is_cancelled() {
tracing::error!("Managed preferences load task was cancelled");
} else {
tracing::error!("Managed preferences load task failed: {join_err}");
}
Err(io::Error::other(LOAD_ERROR))
}
}
}
fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void;
}
let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
let value_ref = unsafe {
CFPreferencesCopyAppValue(
key.as_concrete_TypeRef(),
application_id.as_concrete_TypeRef(),
)
};
if value_ref.is_null() {
tracing::debug!(
"Managed preferences for {} key {} not found",
MANAGED_PREFERENCES_APPLICATION_ID,
MANAGED_PREFERENCES_CONFIG_KEY
);
return Ok(None);
}
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
let contents = value.to_string();
let trimmed = contents.trim();
parse_managed_preferences_base64(trimmed).map(Some)
}
fn parse_managed_preferences_base64(encoded: &str) -> io::Result<TomlValue> {
let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
tracing::error!("Failed to decode managed preferences as base64: {err}");
io::Error::new(io::ErrorKind::InvalidData, err)
})?;
let decoded_str = String::from_utf8(decoded).map_err(|err| {
tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
io::Error::new(io::ErrorKind::InvalidData, err)
})?;
match toml::from_str::<TomlValue>(&decoded_str) {
Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)),
Ok(other) => {
tracing::error!(
"Managed preferences TOML must have a table at the root, found {other:?}",
);
Err(io::Error::new(
io::ErrorKind::InvalidData,
"managed preferences root must be a table",
))
}
Err(err) => {
tracing::error!("Failed to parse managed preferences TOML: {err}");
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
}
}

View File

@@ -1,5 +1,7 @@
mod config_requirements;
mod fingerprint;
mod layer_io;
#[cfg(target_os = "macos")]
mod macos;
mod merge;
mod overrides;
@@ -9,66 +11,167 @@ mod state;
mod tests;
use crate::config::CONFIG_TOML_FILE;
use codex_app_server_protocol::ConfigLayerName;
use crate::config_loader::layer_io::LoadedConfigLayers;
use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use config_requirements::ConfigRequirements;
pub use merge::merge_toml_values;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::LoaderOverrides;
const SESSION_FLAGS_SOURCE: &str = "--config";
const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64";
/// Configuration layering pipeline (top overrides bottom):
/// To build up the set of admin-enforced constraints, we build up from multiple
/// configuration layers in the following order, but a constraint defined in an
/// earlier layer cannot be overridden by a later layer:
///
/// +-------------------------+
/// | Managed preferences (*) |
/// +-------------------------+
/// ^
/// |
/// +-------------------------+
/// | managed_config.toml |
/// +-------------------------+
/// ^
/// |
/// +-------------------------+
/// | config.toml (base) |
/// +-------------------------+
/// - admin: managed preferences (*)
/// - system `/etc/codex/requirements.toml`
///
/// For backwards compatibility, we also load from
/// `/etc/codex/managed_config.toml` and map it to
/// `/etc/codex/requirements.toml`.
///
/// Configuration is built up from multiple layers in the following order:
///
/// - admin: managed preferences (*)
/// - system `/etc/codex/config.toml`
/// - user `${CODEX_HOME}/config.toml`
/// - cwd `${PWD}/config.toml`
/// - tree parent directories up to root looking for `./.codex/config.toml`
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml`
/// - runtime e.g., --config flags, model selector in UI
///
/// (*) Only available on macOS via managed device profiles.
///
/// See https://developers.openai.com/codex/security for details.
pub async fn load_config_layers_state(
codex_home: &Path,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
) -> io::Result<ConfigLayerStack> {
let managed_config_path = overrides
.managed_config_path
.clone()
.unwrap_or_else(|| layer_io::managed_config_default_path(codex_home));
let loaded_config_layers = layer_io::load_config_layers_internal(codex_home, overrides).await?;
let requirements = load_requirements_from_legacy_scheme(loaded_config_layers.clone()).await?;
let layers = layer_io::load_config_layers_internal(codex_home, overrides).await?;
let cli_overrides = overrides::build_cli_overrides_layer(cli_overrides);
// TODO(mbolin): Honor /etc/codex/requirements.toml.
Ok(ConfigLayerStack {
user: ConfigLayerEntry::new(
ConfigLayerName::User,
codex_home.join(CONFIG_TOML_FILE),
layers.base,
),
session_flags: ConfigLayerEntry::new(
ConfigLayerName::SessionFlags,
PathBuf::from(SESSION_FLAGS_SOURCE),
cli_overrides,
),
system: layers.managed_config.map(|cfg| {
ConfigLayerEntry::new(ConfigLayerName::System, managed_config_path.clone(), cfg)
}),
mdm: layers
.managed_preferences
.map(|cfg| ConfigLayerEntry::new(ConfigLayerName::Mdm, PathBuf::from(MDM_SOURCE), cfg)),
})
let mut layers = Vec::<ConfigLayerEntry>::new();
// TODO(mbolin): Honor managed preferences (macOS only).
// TODO(mbolin): Honor /etc/codex/config.toml.
// Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file
// exists, but is malformed, then this error should be propagated to the
// user.
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home)?;
match tokio::fs::read_to_string(&user_file).await {
Ok(contents) => {
let user_config: TomlValue =
toml::from_str(&contents).unwrap_or(TomlValue::Table(toml::map::Map::new()));
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
user_config,
));
}
Err(e) => {
if e.kind() != io::ErrorKind::NotFound {
return Err(io::Error::new(
e.kind(),
format!(
"Failed to read user config file {}: {e}",
user_file.as_path().display(),
),
));
}
}
}
// TODO(mbolin): Add layers for cwd, tree, and repo config files.
// Add a layer for runtime overrides from the CLI or UI, if any exist.
if !cli_overrides.is_empty() {
let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides);
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
cli_overrides_layer,
));
}
// Make a best-effort to support the legacy `managed_config.toml` as
// a config layer on top of everything else. Note users can still
// override these values on a per-turn basis in the TUI and VS Code.
let LoadedConfigLayers {
managed_config,
managed_config_from_mdm,
} = loaded_config_layers;
if let Some(config) = managed_config {
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: config.file.clone(),
},
config.managed_config,
));
}
if let Some(config) = managed_config_from_mdm {
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
config,
));
}
ConfigLayerStack::new(layers, requirements)
}
async fn load_requirements_from_legacy_scheme(
loaded_config_layers: LoadedConfigLayers,
) -> io::Result<ConfigRequirements> {
let mut config_requirements = ConfigRequirements::default();
// In this implementation, later layers override earlier layers, so list
// managed_config_from_mdm last because it has the highest precedence.
let LoadedConfigLayers {
managed_config,
managed_config_from_mdm,
} = loaded_config_layers;
for config in [
managed_config.map(|c| c.managed_config),
managed_config_from_mdm,
]
.into_iter()
.flatten()
{
let legacy_config: LegacyManagedConfigToml =
config.try_into().map_err(|err: toml::de::Error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse config requirements as TOML: {err}"),
)
})?;
let LegacyManagedConfigToml { approval_policy } = legacy_config;
if let Some(approval_policy) = approval_policy {
config_requirements.approval_policy =
crate::config::Constrained::allow_only(approval_policy);
}
}
Ok(config_requirements)
}
/// The legacy mechanism for specifying admin-enforced configuration is to read
/// from a file like `/etc/codex/managed_config.toml` that has the same
/// structure as `config.toml` where fields like `approval_policy` can specify
/// exactly one value rather than a list of allowed values.
///
/// If present, re-interpret `managed_config.toml` as a `requirements.toml`
/// where each specified field is treated as a constraint allowing only that
/// value.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
struct LegacyManagedConfigToml {
approval_policy: Option<AskForApproval>,
}

View File

@@ -1,9 +1,12 @@
use crate::config_loader::ConfigRequirements;
use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
use super::merge::merge_toml_values;
use codex_app_server_protocol::ConfigLayer;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerName;
use codex_app_server_protocol::ConfigLayerSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -18,18 +21,16 @@ pub struct LoaderOverrides {
#[derive(Debug, Clone)]
pub struct ConfigLayerEntry {
pub name: ConfigLayerName,
pub source: PathBuf,
pub name: ConfigLayerSource,
pub config: TomlValue,
pub version: String,
}
impl ConfigLayerEntry {
pub fn new(name: ConfigLayerName, source: PathBuf, config: TomlValue) -> Self {
pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
let version = version_for_toml(&config);
Self {
name,
source,
config,
version,
}
@@ -38,7 +39,6 @@ impl ConfigLayerEntry {
pub fn metadata(&self) -> ConfigLayerMetadata {
ConfigLayerMetadata {
name: self.name.clone(),
source: self.source.display().to_string(),
version: self.version.clone(),
}
}
@@ -46,7 +46,6 @@ impl ConfigLayerEntry {
pub fn as_layer(&self) -> ConfigLayer {
ConfigLayer {
name: self.name.clone(),
source: self.source.display().to_string(),
version: self.version.clone(),
config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null),
}
@@ -55,34 +54,90 @@ impl ConfigLayerEntry {
#[derive(Debug, Clone)]
pub struct ConfigLayerStack {
pub user: ConfigLayerEntry,
pub session_flags: ConfigLayerEntry,
pub system: Option<ConfigLayerEntry>,
pub mdm: Option<ConfigLayerEntry>,
/// Layers are listed from lowest precedence (base) to highest (top), so
/// later entries in the Vec override earlier ones.
layers: Vec<ConfigLayerEntry>,
/// Index into [layers] of the user config layer, if any.
user_layer_index: Option<usize>,
/// Constraints that must be enforced when deriving a [Config] from the
/// layers.
requirements: ConfigRequirements,
}
impl ConfigLayerStack {
pub fn with_user_config(&self, user_config: TomlValue) -> Self {
Self {
user: ConfigLayerEntry::new(
self.user.name.clone(),
self.user.source.clone(),
user_config,
),
session_flags: self.session_flags.clone(),
system: self.system.clone(),
mdm: self.mdm.clone(),
pub fn new(
layers: Vec<ConfigLayerEntry>,
requirements: ConfigRequirements,
) -> std::io::Result<Self> {
let user_layer_index = verify_layer_ordering(&layers)?;
Ok(Self {
layers,
user_layer_index,
requirements,
})
}
/// Returns the user config layer, if any.
pub fn get_user_layer(&self) -> Option<&ConfigLayerEntry> {
self.user_layer_index
.and_then(|index| self.layers.get(index))
}
pub fn requirements(&self) -> &ConfigRequirements {
&self.requirements
}
/// Creates a new [ConfigLayerStack] using the specified values to inject a
/// "user layer" into the stack. If such a layer already exists, it is
/// replaced; otherwise, it is inserted into the stack at the appropriate
/// position based on precedence rules.
pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self {
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
},
user_config,
);
let mut layers = self.layers.clone();
match self.user_layer_index {
Some(index) => {
layers[index] = user_layer;
Self {
layers,
user_layer_index: self.user_layer_index,
requirements: self.requirements.clone(),
}
}
None => {
let user_layer_index = match layers
.iter()
.position(|layer| layer.name.precedence() > user_layer.name.precedence())
{
Some(index) => {
layers.insert(index, user_layer);
index
}
None => {
layers.push(user_layer);
layers.len() - 1
}
};
Self {
layers,
user_layer_index: Some(user_layer_index),
requirements: self.requirements.clone(),
}
}
}
}
pub fn effective_config(&self) -> TomlValue {
let mut merged = self.user.config.clone();
merge_toml_values(&mut merged, &self.session_flags.config);
if let Some(system) = &self.system {
merge_toml_values(&mut merged, &system.config);
}
if let Some(mdm) = &self.mdm {
merge_toml_values(&mut merged, &mdm.config);
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in &self.layers {
merge_toml_values(&mut merged, &layer.config);
}
merged
}
@@ -91,38 +146,44 @@ impl ConfigLayerStack {
let mut origins = HashMap::new();
let mut path = Vec::new();
record_origins(
&self.user.config,
&self.user.metadata(),
&mut path,
&mut origins,
);
record_origins(
&self.session_flags.config,
&self.session_flags.metadata(),
&mut path,
&mut origins,
);
if let Some(system) = &self.system {
record_origins(&system.config, &system.metadata(), &mut path, &mut origins);
}
if let Some(mdm) = &self.mdm {
record_origins(&mdm.config, &mdm.metadata(), &mut path, &mut origins);
for layer in &self.layers {
record_origins(&layer.config, &layer.metadata(), &mut path, &mut origins);
}
origins
}
pub fn layers_high_to_low(&self) -> Vec<ConfigLayer> {
let mut layers = Vec::new();
if let Some(mdm) = &self.mdm {
layers.push(mdm.as_layer());
}
if let Some(system) = &self.system {
layers.push(system.as_layer());
}
layers.push(self.session_flags.as_layer());
layers.push(self.user.as_layer());
layers
/// Returns the highest-precedence to lowest-precedence layers, so
/// `ConfigLayerSource::SessionFlags` would be first, if present.
pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> {
self.layers.iter().rev().collect()
}
}
/// Ensures precedence ordering of config layers is correct. Returns the index
/// of the user config layer, if any (at most one should exist).
fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
if !layers.iter().map(|layer| &layer.name).is_sorted() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"config layers are not in correct precedence order",
));
}
let mut user_layer_index: Option<usize> = None;
let mut levels = Vec::with_capacity(layers.len());
for (index, layer) in layers.iter().enumerate() {
levels.push(layer.name.precedence());
if matches!(layer.name, ConfigLayerSource::User { .. }) {
if user_layer_index.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"multiple user config layers found",
));
}
user_layer_index = Some(index);
}
}
Ok(user_layer_index)
}

View File

@@ -66,13 +66,24 @@ async fn returns_empty_when_all_layers_missing() {
let layers = load_config_layers_state(tmp.path(), &[] as &[(String, TomlValue)], overrides)
.await
.expect("load layers");
let base_table = layers.user.config.as_table().expect("base table expected");
assert!(
layers.get_user_layer().is_none(),
"no user layer when CODEX_HOME/config.toml does not exist"
);
let binding = layers.effective_config();
let base_table = binding.as_table().expect("base table expected");
assert!(
base_table.is_empty(),
"expected empty base layer when configs missing"
);
assert!(
layers.system.is_none(),
let num_system_layers = layers
.layers_high_to_low()
.iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::System { .. }))
.count();
assert_eq!(
num_system_layers, 0,
"managed config layer should be absent when file missing"
);

View File

@@ -163,7 +163,9 @@ mod tests {
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent();
assert!(user_agent.starts_with("codex_cli_rs/"));
let originator = originator().value.as_str();
let prefix = format!("{originator}/");
assert!(user_agent.starts_with(&prefix));
}
#[tokio::test]
@@ -204,7 +206,7 @@ mod tests {
let originator_header = headers
.get("originator")
.expect("originator header missing");
assert_eq!(originator_header.to_str().unwrap(), "codex_cli_rs");
assert_eq!(originator_header.to_str().unwrap(), originator().value);
// User-Agent matches the computed Codex UA for that originator
let expected_ua = get_codex_user_agent();
@@ -241,9 +243,10 @@ mod tests {
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent();
let re = Regex::new(
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
)
let originator = regex_lite::escape(originator().value.as_str());
let re = Regex::new(&format!(
r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$"
))
.unwrap();
assert!(re.is_match(&user_agent));
}

View File

@@ -18,12 +18,33 @@ pub(crate) use legacy::LegacyFeatureToggles;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
Experimental,
Beta,
Beta {
menu_description: &'static str,
announcement: &'static str,
},
Stable,
Deprecated,
Removed,
}
impl Stage {
pub fn beta_menu_description(self) -> Option<&'static str> {
match self {
Stage::Beta {
menu_description, ..
} => Some(menu_description),
_ => None,
}
}
pub fn beta_announcement(self) -> Option<&'static str> {
match self {
Stage::Beta { announcement, .. } => Some(announcement),
_ => None,
}
}
}
/// Unique features toggled via configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Feature {
@@ -292,13 +313,44 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
// Unstable features.
FeatureSpec {
id: Feature::UnifiedExec,
key: "unified_exec",
id: Feature::WebSearchRequest,
key: "web_search_request",
stage: Stage::Stable,
default_enabled: false,
},
// Beta program. Rendered in the `/experimental` menu for users.
FeatureSpec {
id: Feature::Skills,
key: "skills",
// stage: Stage::Beta {
// menu_description: "Define new `skills` for the model",
// announcement: "NEW! Try the new `skills` features. Enable in /experimental!",
// },
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::UnifiedExec,
key: "unified_exec",
// stage: Stage::Beta {
// menu_description: "Run long-running terminal commands in the background.",
// announcement: "NEW! Try Background terminals for long running processes. Enable in /experimental!",
// },
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ShellSnapshot,
key: "shell_snapshot",
// stage: Stage::Beta {
// menu_description: "Snapshot your shell environment to avoid re-running login scripts for every command.",
// announcement: "NEW! Try shell snapshotting to make your Codex faster. Enable in /experimental!",
// },
stage: Stage::Experimental,
default_enabled: false,
},
// Unstable features.
FeatureSpec {
id: Feature::RmcpClient,
key: "rmcp_client",
@@ -308,13 +360,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ApplyPatchFreeform,
key: "apply_patch_freeform",
stage: Stage::Beta,
default_enabled: false,
},
FeatureSpec {
id: Feature::WebSearchRequest,
key: "web_search_request",
stage: Stage::Stable,
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
@@ -347,12 +393,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::Skills,
key: "skills",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ShellSnapshot,
key: "shell_snapshot",

View File

@@ -41,6 +41,7 @@ mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod parse_command;
pub mod path_utils;
pub mod powershell;
pub mod sandboxing;
mod stream_events_utils;

View File

@@ -398,7 +398,7 @@ impl McpConnectionManager {
/// Returns a single map that contains all tools. Each key is the
/// fully-qualified name for the tool.
#[instrument(skip_all)]
#[instrument(level = "trace", skip_all)]
pub async fn list_all_tools(&self) -> HashMap<String, ToolInfo> {
let mut tools = HashMap::new();
for managed_client in self.clients.values() {

View File

@@ -294,6 +294,20 @@ pub(super) fn find_family_for_model(slug: &str) -> ModelFamily {
)
// Production models.
} else if slug.starts_with("caribou") {
// Same as gpt-5.1-codex-max.
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
shell_type: ConfigShellToolType::ShellCommand,
supports_parallel_tool_calls: true,
support_verbosity: false,
truncation_policy: TruncationPolicy::Tokens(10_000),
context_window: Some(CONTEXT_WINDOW_272K),
)
} else if slug.starts_with("gpt-5.1-codex-max") {
model_family!(
slug, slug,

View File

@@ -11,6 +11,35 @@ pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str =
static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
vec![
ModelPreset {
id: "caribou".to_string(),
model: "caribou".to_string(),
display_name: "caribou".to_string(),
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Balances speed and reasoning depth for everyday tasks".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex problems".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::XHigh,
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
is_default: true,
upgrade: None,
show_in_picker: true,
supported_in_api: false,
},
ModelPreset {
id: "gpt-5.1-codex-max".to_string(),
model: "gpt-5.1-codex-max".to_string(),
@@ -35,9 +64,14 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
is_default: true,
upgrade: None,
is_default: false,
upgrade: Some(ModelUpgrade {
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: "caribou".to_string(),
}),
show_in_picker: true,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.1-codex".to_string(),
@@ -62,11 +96,12 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max".to_string(),
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(),
migration_config_key: "caribou".to_string(),
}),
show_in_picker: true,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.1-codex-mini".to_string(),
@@ -86,12 +121,9 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(),
}),
upgrade: None,
show_in_picker: true,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.2".to_string(),
@@ -110,7 +142,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Greater reasoning depth for complex or ambiguous problems".to_string(),
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::XHigh,
@@ -118,8 +150,13 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
],
is_default: false,
upgrade: None,
upgrade: Some(ModelUpgrade {
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: "caribou".to_string(),
}),
show_in_picker: true,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.1".to_string(),
@@ -143,11 +180,12 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max".to_string(),
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(),
migration_config_key: "caribou".to_string(),
}),
show_in_picker: true,
supported_in_api: true,
},
// Deprecated models.
ModelPreset {
@@ -172,11 +210,12 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max".to_string(),
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(),
migration_config_key: "caribou".to_string(),
}),
show_in_picker: false,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5-codex-mini".to_string(),
@@ -201,6 +240,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
}),
show_in_picker: false,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5".to_string(),
@@ -228,11 +268,12 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-max".to_string(),
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(),
migration_config_key: "caribou".to_string(),
}),
show_in_picker: false,
supported_in_api: true,
},
]
});

View File

@@ -29,7 +29,8 @@ use crate::openai_models::model_presets::builtin_model_presets;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300);
const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max";
const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.1-codex-max";
const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "caribou";
const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced";
/// Coordinates remote model discovery plus cached metadata on disk.
@@ -51,7 +52,7 @@ impl ModelsManager {
let codex_home = auth_manager.codex_home().to_path_buf();
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Vec::new()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
codex_home,
@@ -66,7 +67,7 @@ impl ModelsManager {
let codex_home = auth_manager.codex_home().to_path_buf();
Self {
local_models: builtin_model_presets(auth_manager.get_auth_mode()),
remote_models: RwLock::new(Vec::new()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
codex_home,
@@ -77,7 +78,9 @@ impl ModelsManager {
/// Fetch the latest remote models, using the on-disk cache when still fresh.
pub async fn refresh_available_models(&self, config: &Config) -> CoreResult<()> {
if !config.features.enabled(Feature::RemoteModels) {
if !config.features.enabled(Feature::RemoteModels)
|| self.auth_manager.get_auth_mode() == Some(AuthMode::ApiKey)
{
return Ok(());
}
if self.try_load_cache().await {
@@ -108,12 +111,12 @@ impl ModelsManager {
if let Err(err) = self.refresh_available_models(config).await {
error!("failed to refresh available models: {err}");
}
let remote_models = self.remote_models.read().await.clone();
let remote_models = self.remote_models(config).await;
self.build_available_models(remote_models)
}
pub fn try_list_models(&self) -> Result<Vec<ModelPreset>, TryLockError> {
let remote_models = self.remote_models.try_read()?.clone();
pub fn try_list_models(&self, config: &Config) -> Result<Vec<ModelPreset>, TryLockError> {
let remote_models = self.try_get_remote_models(config)?;
Ok(self.build_available_models(remote_models))
}
@@ -124,7 +127,7 @@ impl ModelsManager {
/// Look up the requested model family while applying remote metadata overrides.
pub async fn construct_model_family(&self, model: &str, config: &Config) -> ModelFamily {
Self::find_family_for_model(model)
.with_remote_overrides(self.remote_models.read().await.clone())
.with_remote_overrides(self.remote_models(config).await)
.with_config_overrides(config)
}
@@ -137,7 +140,7 @@ impl ModelsManager {
}
// if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model
let auth_mode = self.auth_manager.get_auth_mode();
let remote_models = self.remote_models.read().await.clone();
let remote_models = self.remote_models(config).await;
if auth_mode == Some(AuthMode::ChatGPT)
&& self
.build_available_models(remote_models)
@@ -145,13 +148,15 @@ impl ModelsManager {
.any(|m| m.model == CODEX_AUTO_BALANCED_MODEL)
{
return CODEX_AUTO_BALANCED_MODEL.to_string();
} else if auth_mode == Some(AuthMode::ChatGPT) {
return OPENAI_DEFAULT_CHATGPT_MODEL.to_string();
}
OPENAI_DEFAULT_MODEL.to_string()
OPENAI_DEFAULT_API_MODEL.to_string()
}
#[cfg(any(test, feature = "test-support"))]
pub fn get_model_offline(model: Option<&str>) -> String {
model.unwrap_or(OPENAI_DEFAULT_MODEL).to_string()
model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string()
}
#[cfg(any(test, feature = "test-support"))]
@@ -165,6 +170,12 @@ impl ModelsManager {
*self.remote_models.write().await = models;
}
fn load_remote_models_from_file() -> Result<Vec<ModelInfo>, std::io::Error> {
let file_contents = include_str!("../../models.json");
let response: ModelsResponse = serde_json::from_str(file_contents)?;
Ok(response.models)
}
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
async fn try_load_cache(&self) -> bool {
// todo(aibrahim): think if we should store fetched_at in ModelsManager so we don't always need to read the disk
@@ -209,7 +220,7 @@ impl ModelsManager {
let remote_presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
let existing_presets = self.local_models.clone();
let mut merged_presets = Self::merge_presets(remote_presets, existing_presets);
merged_presets = Self::filter_visible_models(merged_presets);
merged_presets = self.filter_visible_models(merged_presets);
let has_default = merged_presets.iter().any(|preset| preset.is_default);
if let Some(default) = merged_presets.first_mut()
@@ -221,10 +232,11 @@ impl ModelsManager {
merged_presets
}
fn filter_visible_models(models: Vec<ModelPreset>) -> Vec<ModelPreset> {
fn filter_visible_models(&self, models: Vec<ModelPreset>) -> Vec<ModelPreset> {
let chatgpt_mode = self.auth_manager.get_auth_mode() == Some(AuthMode::ChatGPT);
models
.into_iter()
.filter(|model| model.show_in_picker)
.filter(|model| model.show_in_picker && (chatgpt_mode || model.supported_in_api))
.collect()
}
@@ -253,6 +265,22 @@ impl ModelsManager {
merged_presets
}
async fn remote_models(&self, config: &Config) -> Vec<ModelInfo> {
if config.features.enabled(Feature::RemoteModels) {
self.remote_models.read().await.clone()
} else {
Vec::new()
}
}
fn try_get_remote_models(&self, config: &Config) -> Result<Vec<ModelInfo>, TryLockError> {
if config.features.enabled(Feature::RemoteModels) {
Ok(self.remote_models.try_read()?.clone())
} else {
Ok(Vec::new())
}
}
fn cache_path(&self) -> PathBuf {
self.codex_home.join(MODEL_CACHE_FILE)
}
@@ -377,7 +405,7 @@ mod tests {
.expect("load default test config");
config.features.enable(Feature::RemoteModels);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let manager = ModelsManager::with_provider(auth_manager, provider);
@@ -385,7 +413,7 @@ mod tests {
.refresh_available_models(&config)
.await
.expect("refresh succeeds");
let cached_remote = manager.remote_models.read().await.clone();
let cached_remote = manager.remote_models(&config).await;
assert_eq!(cached_remote, remote_models);
let available = manager.list_models(&config).await;
@@ -447,7 +475,7 @@ mod tests {
.await
.expect("first refresh succeeds");
assert_eq!(
*manager.remote_models.read().await,
manager.remote_models(&config).await,
remote_models,
"remote cache should store fetched models"
);
@@ -458,7 +486,7 @@ mod tests {
.await
.expect("cached refresh succeeds");
assert_eq!(
*manager.remote_models.read().await,
manager.remote_models(&config).await,
remote_models,
"cache path should not mutate stored models"
);
@@ -529,7 +557,7 @@ mod tests {
.await
.expect("second refresh succeeds");
assert_eq!(
*manager.remote_models.read().await,
manager.remote_models(&config).await,
updated_models,
"stale cache should trigger refetch"
);
@@ -567,7 +595,7 @@ mod tests {
.expect("load default test config");
config.features.enable(Feature::RemoteModels);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
let provider = provider_for(server.uri());
let mut manager = ModelsManager::with_provider(auth_manager, provider);
manager.cache_ttl = Duration::ZERO;
@@ -594,7 +622,7 @@ mod tests {
.expect("second refresh succeeds");
let available = manager
.try_list_models()
.try_list_models(&config)
.expect("models should be available");
assert!(
available.iter().any(|preset| preset.model == "remote-new"),
@@ -634,4 +662,25 @@ mod tests {
assert_eq!(available, vec![expected]);
}
#[test]
fn bundled_models_json_roundtrips() {
let file_contents = include_str!("../../models.json");
let response: ModelsResponse =
serde_json::from_str(file_contents).expect("bundled models.json should deserialize");
let serialized =
serde_json::to_string(&response).expect("bundled models.json should serialize");
let roundtripped: ModelsResponse =
serde_json::from_str(&serialized).expect("serialized models.json should deserialize");
assert_eq!(
response, roundtripped,
"bundled models.json should round trip through serde"
);
assert!(
!response.models.is_empty(),
"bundled models.json should contain at least one model"
);
}
}

View File

@@ -0,0 +1,116 @@
use std::path::Path;
use std::path::PathBuf;
use crate::env;
pub fn normalize_for_path_comparison<P: AsRef<Path>>(path: P) -> std::io::Result<PathBuf> {
let canonical = path.as_ref().canonicalize()?;
Ok(normalize_for_wsl(canonical))
}
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
normalize_for_wsl_with_flag(path, env::is_wsl())
}
fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
if !is_wsl {
return path;
}
if !is_wsl_case_insensitive_path(&path) {
return path;
}
lower_ascii_path(path)
}
fn is_wsl_case_insensitive_path(path: &Path) -> bool {
#[cfg(target_os = "linux")]
{
use std::os::unix::ffi::OsStrExt;
use std::path::Component;
let mut components = path.components();
let Some(Component::RootDir) = components.next() else {
return false;
};
let Some(Component::Normal(mnt)) = components.next() else {
return false;
};
if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
return false;
}
let Some(Component::Normal(drive)) = components.next() else {
return false;
};
let drive_bytes = drive.as_bytes();
drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
}
#[cfg(not(target_os = "linux"))]
{
let _ = path;
false
}
}
#[cfg(target_os = "linux")]
fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
}
#[cfg(target_os = "linux")]
fn lower_ascii_path(path: PathBuf) -> PathBuf {
use std::ffi::OsString;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::ffi::OsStringExt;
// WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
let bytes = path.as_os_str().as_bytes();
let mut lowered = Vec::with_capacity(bytes.len());
for byte in bytes {
lowered.push(byte.to_ascii_lowercase());
}
PathBuf::from(OsString::from_vec(lowered))
}
#[cfg(not(target_os = "linux"))]
fn lower_ascii_path(path: PathBuf) -> PathBuf {
path
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
mod wsl {
use super::super::normalize_for_wsl_with_flag;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn wsl_mnt_drive_paths_lowercase() {
let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true);
assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev"));
}
#[test]
fn wsl_non_drive_paths_unchanged() {
let path = PathBuf::from("/mnt/cc/Users/Dev");
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
assert_eq!(normalized, path);
}
#[test]
fn wsl_non_mnt_paths_unchanged() {
let path = PathBuf::from("/home/Dev");
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
assert_eq!(normalized, path);
}
}
}

View File

@@ -522,7 +522,7 @@ mod tests {
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
let expected = format!(
"base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
"base doc\n\n## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}"
);
assert_eq!(res, expected);
}
@@ -546,7 +546,7 @@ mod tests {
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.";
let expected = format!(
"## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
"## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}"
);
assert_eq!(res, expected);
}

View File

@@ -88,6 +88,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_) => false,
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::SkillsUpdateAvailable => false,
}
}

View File

@@ -166,30 +166,34 @@ mod tests {
use super::create_seatbelt_command_args;
use super::macos_dir_params;
use crate::protocol::SandboxPolicy;
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn create_seatbelt_args_with_read_only_git_subpath() {
fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() {
// Create a temporary workspace with two writable roots: one containing
// a top-level .git directory and one without it.
// top-level .git and .codex directories and one without them.
let tmp = TempDir::new().expect("tempdir");
let PopulatedTmp {
root_with_git,
root_without_git,
root_with_git_canon,
root_with_git_git_canon,
root_without_git_canon,
vulnerable_root,
vulnerable_root_canonical,
dot_git_canonical,
dot_codex_canonical,
empty_root,
empty_root_canonical,
} = populate_tmpdir(tmp.path());
let cwd = tmp.path().join("cwd");
fs::create_dir_all(&cwd).expect("create cwd");
// Build a policy that only includes the two test roots as writable and
// does not automatically include defaults TMPDIR or /tmp.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![root_with_git, root_without_git]
writable_roots: vec![vulnerable_root, empty_root]
.into_iter()
.map(|p| p.try_into().unwrap())
.collect(),
@@ -198,23 +202,34 @@ mod tests {
exclude_slash_tmp: true,
};
let args = create_seatbelt_command_args(
vec!["/bin/echo".to_string(), "hello".to_string()],
&policy,
&cwd,
);
// Create the Seatbelt command to wrap a shell command that tries to
// write to .codex/config.toml in the vulnerable root.
let shell_command: Vec<String> = [
"bash",
"-c",
"echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"",
"bash",
dot_codex_canonical
.join("config.toml")
.to_string_lossy()
.as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd);
// Build the expected policy text using a raw string for readability.
// Note that the policy includes:
// - the base policy,
// - read-only access to the filesystem,
// - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
// - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
let expected_policy = format!(
r#"{MACOS_SEATBELT_BASE_POLICY}
; allow read-only file operations
(allow file-read*)
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")) (subpath (param "WRITABLE_ROOT_2"))
)
"#,
);
@@ -224,17 +239,26 @@ mod tests {
expected_policy,
format!(
"-DWRITABLE_ROOT_0={}",
root_with_git_canon.to_string_lossy()
vulnerable_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_0={}",
root_with_git_git_canon.to_string_lossy()
dot_git_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_1={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
root_without_git_canon.to_string_lossy()
empty_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_2={}",
cwd.canonicalize()
.expect("canonicalize cwd")
.to_string_lossy()
),
format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()),
];
expected_args.extend(
@@ -243,30 +267,119 @@ mod tests {
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
);
expected_args.extend(vec![
"--".to_string(),
"/bin/echo".to_string(),
"hello".to_string(),
]);
expected_args.push("--".to_string());
expected_args.extend(shell_command);
assert_eq!(expected_args, args);
// Verify that .codex/config.toml cannot be modified under the generated
// Seatbelt policy.
let config_toml = dot_codex_canonical.join("config.toml");
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert_eq!(
"sandbox_mode = \"read-only\"\n",
String::from_utf8_lossy(&fs::read(&config_toml).expect("read config.toml")),
"config.toml should contain its original contents because it should not have been modified"
);
assert!(
!output.status.success(),
"command to write {} should fail under seatbelt",
&config_toml.display()
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!("bash: {}: Operation not permitted\n", config_toml.display()),
);
// Create a similar Seatbelt command that tries to write to a file in
// the .git folder, which should also be blocked.
let pre_commit_hook = dot_git_canonical.join("hooks").join("pre-commit");
let shell_command_git: Vec<String> = [
"bash",
"-c",
"echo 'pwned!' > \"$1\"",
"bash",
pre_commit_hook.to_string_lossy().as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let write_hooks_file_args = create_seatbelt_command_args(shell_command_git, &policy, &cwd);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&write_hooks_file_args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert!(
!fs::exists(&pre_commit_hook).expect("exists pre-commit hook"),
"{} should not exist because it should not have been created",
pre_commit_hook.display()
);
assert!(
!output.status.success(),
"command to write {} should fail under seatbelt",
&pre_commit_hook.display()
);
assert_eq!(
String::from_utf8_lossy(&output.stderr),
format!(
"bash: {}: Operation not permitted\n",
pre_commit_hook.display()
),
);
// Verify that writing a file to the folder containing .git and .codex is allowed.
let allowed_file = vulnerable_root_canonical.join("allowed.txt");
let shell_command_allowed: Vec<String> = [
"bash",
"-c",
"echo 'this is allowed' > \"$1\"",
"bash",
allowed_file.to_string_lossy().as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let write_allowed_file_args =
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&write_allowed_file_args)
.current_dir(&cwd)
.output()
.expect("execute seatbelt command");
assert!(
output.status.success(),
"command to write {} should succeed under seatbelt",
&allowed_file.display()
);
assert_eq!(
"this is allowed\n",
String::from_utf8_lossy(&fs::read(&allowed_file).expect("read allowed.txt")),
"{} should contain the written text",
allowed_file.display()
);
}
#[test]
fn create_seatbelt_args_for_cwd_as_git_repo() {
// Create a temporary workspace with two writable roots: one containing
// a top-level .git directory and one without it.
// top-level .git and .codex directories and one without them.
let tmp = TempDir::new().expect("tempdir");
let PopulatedTmp {
root_with_git,
root_with_git_canon,
root_with_git_git_canon,
vulnerable_root,
vulnerable_root_canonical,
dot_git_canonical,
dot_codex_canonical,
..
} = populate_tmpdir(tmp.path());
// Build a policy that does not specify any writable_roots, but does
// use the default ones (cwd and TMPDIR) and verifies the `.git` check
// is done properly for cwd.
// use the default ones (cwd and TMPDIR) and verifies the `.git` and
// `.codex` checks are done properly for cwd.
let policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
@@ -274,11 +387,21 @@ mod tests {
exclude_slash_tmp: false,
};
let args = create_seatbelt_command_args(
vec!["/bin/echo".to_string(), "hello".to_string()],
&policy,
root_with_git.as_path(),
);
let shell_command: Vec<String> = [
"bash",
"-c",
"echo 'sandbox_mode = \"danger-full-access\"' > \"$1\"",
"bash",
dot_codex_canonical
.join("config.toml")
.to_string_lossy()
.as_ref(),
]
.iter()
.map(std::string::ToString::to_string)
.collect();
let args =
create_seatbelt_command_args(shell_command.clone(), &policy, vulnerable_root.as_path());
let tmpdir_env_var = std::env::var("TMPDIR")
.ok()
@@ -296,13 +419,13 @@ mod tests {
// Note that the policy includes:
// - the base policy,
// - read-only access to the filesystem,
// - write access to WRITABLE_ROOT_0 (but not its .git) and WRITABLE_ROOT_1.
// - write access to WRITABLE_ROOT_0 (but not its .git or .codex), WRITABLE_ROOT_1, and cwd as WRITABLE_ROOT_2.
let expected_policy = format!(
r#"{MACOS_SEATBELT_BASE_POLICY}
; allow read-only file operations
(allow file-read*)
(allow file-write*
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
(require-all (subpath (param "WRITABLE_ROOT_0")) (require-not (subpath (param "WRITABLE_ROOT_0_RO_0"))) (require-not (subpath (param "WRITABLE_ROOT_0_RO_1"))) ) (subpath (param "WRITABLE_ROOT_1")){tempdir_policy_entry}
)
"#,
);
@@ -312,11 +435,15 @@ mod tests {
expected_policy,
format!(
"-DWRITABLE_ROOT_0={}",
root_with_git_canon.to_string_lossy()
vulnerable_root_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_0={}",
root_with_git_git_canon.to_string_lossy()
dot_git_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_0_RO_1={}",
dot_codex_canonical.to_string_lossy()
),
format!(
"-DWRITABLE_ROOT_1={}",
@@ -337,42 +464,68 @@ mod tests {
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
);
expected_args.extend(vec![
"--".to_string(),
"/bin/echo".to_string(),
"hello".to_string(),
]);
expected_args.push("--".to_string());
expected_args.extend(shell_command);
assert_eq!(expected_args, args);
}
struct PopulatedTmp {
root_with_git: PathBuf,
root_without_git: PathBuf,
root_with_git_canon: PathBuf,
root_with_git_git_canon: PathBuf,
root_without_git_canon: PathBuf,
/// Path containing a .git and .codex subfolder.
/// For the purposes of this test, we consider this a "vulnerable" root
/// because a bad actor could write to .git/hooks/pre-commit so an
/// unsuspecting user would run code as privileged the next time they
/// ran `git commit` themselves, or modified .codex/config.toml to
/// contain `sandbox_mode = "danger-full-access"` so the agent would
/// have full privileges the next time it ran in that repo.
vulnerable_root: PathBuf,
vulnerable_root_canonical: PathBuf,
dot_git_canonical: PathBuf,
dot_codex_canonical: PathBuf,
/// Path without .git or .codex subfolders.
empty_root: PathBuf,
/// Canonicalized version of `empty_root`.
empty_root_canonical: PathBuf,
}
fn populate_tmpdir(tmp: &Path) -> PopulatedTmp {
let root_with_git = tmp.join("with_git");
let root_without_git = tmp.join("no_git");
fs::create_dir_all(&root_with_git).expect("create with_git");
fs::create_dir_all(&root_without_git).expect("create no_git");
fs::create_dir_all(root_with_git.join(".git")).expect("create .git");
let vulnerable_root = tmp.join("vulnerable_root");
fs::create_dir_all(&vulnerable_root).expect("create vulnerable_root");
// TODO(mbolin): Should also support the case where `.git` is a file
// with a gitdir: ... line.
Command::new("git")
.arg("init")
.arg(".")
.current_dir(&vulnerable_root)
.output()
.expect("git init .");
fs::create_dir_all(vulnerable_root.join(".codex")).expect("create .codex");
fs::write(
vulnerable_root.join(".codex").join("config.toml"),
"sandbox_mode = \"read-only\"\n",
)
.expect("write .codex/config.toml");
let empty_root = tmp.join("empty_root");
fs::create_dir_all(&empty_root).expect("create empty_root");
// Ensure we have canonical paths for -D parameter matching.
let root_with_git_canon = root_with_git.canonicalize().expect("canonicalize with_git");
let root_with_git_git_canon = root_with_git_canon.join(".git");
let root_without_git_canon = root_without_git
let vulnerable_root_canonical = vulnerable_root
.canonicalize()
.expect("canonicalize no_git");
.expect("canonicalize vulnerable_root");
let dot_git_canonical = vulnerable_root_canonical.join(".git");
let dot_codex_canonical = vulnerable_root_canonical.join(".codex");
let empty_root_canonical = empty_root.canonicalize().expect("canonicalize empty_root");
PopulatedTmp {
root_with_git,
root_without_git,
root_with_git_canon,
root_with_git_git_canon,
root_without_git_canon,
vulnerable_root,
vulnerable_root_canonical,
dot_git_canonical,
dot_codex_canonical,
empty_root,
empty_root_canonical,
}
}
}

View File

@@ -3,9 +3,11 @@ use crate::git_info::resolve_root_git_project_for_trust;
use crate::skills::model::SkillError;
use crate::skills::model::SkillLoadOutcome;
use crate::skills::model::SkillMetadata;
use crate::skills::public::public_cache_root_dir;
use codex_protocol::protocol::SkillScope;
use dunce::canonicalize as normalize_path;
use serde::Deserialize;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::error::Error;
use std::fmt;
@@ -71,6 +73,11 @@ where
discover_skills_under_root(&root.path, root.scope, &mut outcome);
}
let mut seen: HashSet<String> = HashSet::new();
outcome
.skills
.retain(|skill| seen.insert(skill.name.clone()));
outcome
.skills
.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
@@ -85,22 +92,57 @@ pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot {
}
}
pub(crate) fn public_skills_root(codex_home: &Path) -> SkillRoot {
SkillRoot {
path: public_cache_root_dir(codex_home),
scope: SkillScope::Public,
}
}
pub(crate) fn repo_skills_root(cwd: &Path) -> Option<SkillRoot> {
resolve_root_git_project_for_trust(cwd).map(|repo_root| SkillRoot {
path: repo_root
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
scope: SkillScope::Repo,
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
let base = normalize_path(base).unwrap_or_else(|_| base.to_path_buf());
let repo_root =
resolve_root_git_project_for_trust(&base).map(|root| normalize_path(&root).unwrap_or(root));
let scope = SkillScope::Repo;
if let Some(repo_root) = repo_root.as_deref() {
for dir in base.ancestors() {
let skills_root = dir.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
if skills_root.is_dir() {
return Some(SkillRoot {
path: skills_root,
scope,
});
}
if dir == repo_root {
break;
}
}
return None;
}
let skills_root = base.join(REPO_ROOT_CONFIG_DIR_NAME).join(SKILLS_DIR_NAME);
skills_root.is_dir().then_some(SkillRoot {
path: skills_root,
scope,
})
}
fn skill_roots(config: &Config) -> Vec<SkillRoot> {
let mut roots = vec![user_skills_root(&config.codex_home)];
let mut roots = Vec::new();
if let Some(repo_root) = repo_skills_root(&config.cwd) {
roots.push(repo_root);
}
// Load order matters: we dedupe by name, keeping the first occurrence.
// This makes repo/user skills win over public skills.
roots.push(user_skills_root(&config.codex_home));
roots.push(public_skills_root(&config.codex_home));
roots
}
@@ -149,11 +191,17 @@ fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut Skil
if file_type.is_file() && file_name == SKILLS_FILENAME {
match parse_skill_file(&path, scope) {
Ok(skill) => outcome.skills.push(skill),
Err(err) => outcome.errors.push(SkillError {
path,
message: err.to_string(),
}),
Ok(skill) => {
outcome.skills.push(skill);
}
Err(err) => {
if scope != SkillScope::Public {
outcome.errors.push(SkillError {
path,
message: err.to_string(),
});
}
}
}
}
}
@@ -233,6 +281,7 @@ mod tests {
use super::*;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use codex_protocol::protocol::SkillScope;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::process::Command;
@@ -251,11 +300,11 @@ mod tests {
}
fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf {
write_skill_at(codex_home.path(), dir, name, description)
write_skill_at(&codex_home.path().join("skills"), dir, name, description)
}
fn write_skill_at(root: &Path, dir: &str, name: &str, description: &str) -> PathBuf {
let skill_dir = root.join(format!("skills/{dir}"));
let skill_dir = root.join(dir);
fs::create_dir_all(&skill_dir).unwrap();
let indented_description = description.replace('\n', "\n ");
let content = format!(
@@ -375,4 +424,316 @@ mod tests {
assert_eq!(skill.name, "repo-skill");
assert!(skill.path.starts_with(&repo_root));
}
#[test]
fn loads_skills_from_nearest_codex_dir_under_repo_root() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
let nested_dir = repo_dir.path().join("nested/inner");
fs::create_dir_all(&nested_dir).unwrap();
write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"root",
"root-skill",
"from root",
);
write_skill_at(
&repo_dir
.path()
.join("nested")
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"nested",
"nested-skill",
"from nested",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = nested_dir;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "nested-skill");
}
#[test]
fn loads_skills_from_codex_dir_when_not_git_repo() {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_skill_at(
&work_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"local",
"local-skill",
"from cwd",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = work_dir.path().to_path_buf();
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "local-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
}
#[test]
fn deduplicates_by_name_preferring_repo_over_user() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
write_skill(&codex_home, "user", "dupe-skill", "from user");
write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"repo",
"dupe-skill",
"from repo",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = repo_dir.path().to_path_buf();
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
}
#[test]
fn repo_skills_search_does_not_escape_repo_root() {
let codex_home = tempfile::tempdir().expect("tempdir");
let outer_dir = tempfile::tempdir().expect("tempdir");
let repo_dir = outer_dir.path().join("repo");
fs::create_dir_all(&repo_dir).unwrap();
write_skill_at(
&outer_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"outer",
"outer-skill",
"from outer",
);
let status = Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.status()
.expect("git init");
assert!(status.success(), "git init failed");
let mut cfg = make_config(&codex_home);
cfg.cwd = repo_dir;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 0);
}
#[test]
fn loads_skills_when_cwd_is_file_in_repo() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"repo",
"repo-skill",
"from repo",
);
let file_path = repo_dir.path().join("some-file.txt");
fs::write(&file_path, "contents").unwrap();
let mut cfg = make_config(&codex_home);
cfg.cwd = file_path;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "repo-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
}
#[test]
fn non_git_repo_skills_search_does_not_walk_parents() {
let codex_home = tempfile::tempdir().expect("tempdir");
let outer_dir = tempfile::tempdir().expect("tempdir");
let nested_dir = outer_dir.path().join("nested/inner");
fs::create_dir_all(&nested_dir).unwrap();
write_skill_at(
&outer_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"outer",
"outer-skill",
"from outer",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = nested_dir;
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 0);
}
#[test]
fn loads_skills_from_public_cache_when_present() {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_skill_at(
&codex_home.path().join("skills").join(".public"),
"public",
"public-skill",
"from public",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = work_dir.path().to_path_buf();
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "public-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Public);
}
#[test]
fn deduplicates_by_name_preferring_user_over_public() {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_skill(&codex_home, "user", "dupe-skill", "from user");
write_skill_at(
&codex_home.path().join("skills").join(".public"),
"public",
"dupe-skill",
"from public",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = work_dir.path().to_path_buf();
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::User);
}
#[test]
fn deduplicates_by_name_preferring_repo_over_public() {
let codex_home = tempfile::tempdir().expect("tempdir");
let repo_dir = tempfile::tempdir().expect("tempdir");
let status = Command::new("git")
.arg("init")
.current_dir(repo_dir.path())
.status()
.expect("git init");
assert!(status.success(), "git init failed");
write_skill_at(
&repo_dir
.path()
.join(REPO_ROOT_CONFIG_DIR_NAME)
.join(SKILLS_DIR_NAME),
"repo",
"dupe-skill",
"from repo",
);
write_skill_at(
&codex_home.path().join("skills").join(".public"),
"public",
"dupe-skill",
"from public",
);
let mut cfg = make_config(&codex_home);
cfg.cwd = repo_dir.path().to_path_buf();
let outcome = load_skills(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].name, "dupe-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
}
}

View File

@@ -2,38 +2,82 @@ use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::skills::SkillLoadOutcome;
use crate::skills::loader::load_skills_from_roots;
use crate::skills::loader::public_skills_root;
use crate::skills::loader::repo_skills_root;
use crate::skills::loader::user_skills_root;
use crate::skills::public::refresh_public_skills;
use tokio::sync::broadcast;
pub struct SkillsManager {
codex_home: PathBuf,
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
attempted_public_refresh: AtomicBool,
skills_update_tx: broadcast::Sender<()>,
}
impl SkillsManager {
pub fn new(codex_home: PathBuf) -> Self {
let (skills_update_tx, _skills_update_rx) = broadcast::channel(1);
Self {
codex_home,
cache_by_cwd: RwLock::new(HashMap::new()),
attempted_public_refresh: AtomicBool::new(false),
skills_update_tx,
}
}
pub(crate) fn subscribe_skills_update_notifications(&self) -> broadcast::Receiver<()> {
self.skills_update_tx.subscribe()
}
pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
self.skills_for_cwd_with_options(cwd, false)
}
pub(crate) fn skills_for_cwd_with_options(
&self,
cwd: &Path,
force_reload: bool,
) -> SkillLoadOutcome {
// Best-effort refresh: attempt at most once per manager instance.
if self
.attempted_public_refresh
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let codex_home = self.codex_home.clone();
let skills_update_tx = self.skills_update_tx.clone();
std::thread::spawn(move || match refresh_public_skills(&codex_home) {
Ok(outcome) => {
if outcome.updated() {
let _ = skills_update_tx.send(());
}
}
Err(err) => {
tracing::error!("failed to refresh public skills: {err}");
}
});
}
let cached = match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
};
if let Some(outcome) = cached {
if !force_reload && let Some(outcome) = cached {
return outcome;
}
let mut roots = vec![user_skills_root(&self.codex_home)];
let mut roots = Vec::new();
if let Some(repo_root) = repo_skills_root(cwd) {
roots.push(repo_root);
}
roots.push(user_skills_root(&self.codex_home));
roots.push(public_skills_root(&self.codex_home));
let outcome = load_skills_from_roots(roots);
match self.cache_by_cwd.write() {
Ok(mut cache) => {

View File

@@ -2,6 +2,7 @@ pub mod injection;
pub mod loader;
pub mod manager;
pub mod model;
pub mod public;
pub mod render;
pub(crate) use injection::SkillInjections;

View File

@@ -0,0 +1,397 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use thiserror::Error;
const PUBLIC_SKILLS_REPO_URL: &str = "https://github.com/openai/skills.git";
const PUBLIC_SKILLS_DIR_NAME: &str = ".public";
const SKILLS_DIR_NAME: &str = "skills";
struct TempDirCleanup {
path: PathBuf,
// Disable Drop cleanup after explicit cleanup to avoid double-delete.
active: bool,
}
impl TempDirCleanup {
fn new(path: PathBuf) -> Self {
Self { path, active: true }
}
fn cleanup(&mut self) -> Result<(), PublicSkillsError> {
if self.active && self.path.exists() {
fs::remove_dir_all(&self.path)
.map_err(|source| PublicSkillsError::io("remove public skills tmp dir", source))?;
}
self.active = false;
Ok(())
}
}
impl Drop for TempDirCleanup {
fn drop(&mut self) {
if self.active && self.path.exists() {
let _ = fs::remove_dir_all(&self.path);
}
}
}
pub(crate) fn public_cache_root_dir(codex_home: &Path) -> PathBuf {
codex_home
.join(SKILLS_DIR_NAME)
.join(PUBLIC_SKILLS_DIR_NAME)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PublicSkillsRefreshOutcome {
Skipped,
Updated,
}
impl PublicSkillsRefreshOutcome {
pub(crate) fn updated(self) -> bool {
matches!(self, Self::Updated)
}
}
pub(crate) fn refresh_public_skills(
codex_home: &Path,
) -> Result<PublicSkillsRefreshOutcome, PublicSkillsError> {
// Keep tests deterministic and offline-safe. Tests that want to exercise the
// refresh behavior should call `refresh_public_skills_from_repo_url`.
if cfg!(test) {
return Ok(PublicSkillsRefreshOutcome::Skipped);
}
refresh_public_skills_inner(codex_home, PUBLIC_SKILLS_REPO_URL)
}
#[cfg(test)]
pub(crate) fn refresh_public_skills_from_repo_url(
codex_home: &Path,
repo_url: &str,
) -> Result<PublicSkillsRefreshOutcome, PublicSkillsError> {
refresh_public_skills_inner(codex_home, repo_url)
}
fn refresh_public_skills_inner(
codex_home: &Path,
repo_url: &str,
) -> Result<PublicSkillsRefreshOutcome, PublicSkillsError> {
// Best-effort refresh: clone the repo to a temp dir, stage its `skills/`, then atomically swap
// the staged directory into the public cache.
let skills_root_dir = codex_home.join(SKILLS_DIR_NAME);
fs::create_dir_all(&skills_root_dir)
.map_err(|source| PublicSkillsError::io("create skills root dir", source))?;
let dest_public = public_cache_root_dir(codex_home);
let tmp_dir = skills_root_dir.join(format!(".public-tmp-{}", rand_suffix()));
if tmp_dir.exists() {
fs::remove_dir_all(&tmp_dir).map_err(|source| {
PublicSkillsError::io("remove existing public skills tmp dir", source)
})?;
}
fs::create_dir_all(&tmp_dir)
.map_err(|source| PublicSkillsError::io("create public skills tmp dir", source))?;
let mut tmp_dir_cleanup = TempDirCleanup::new(tmp_dir.clone());
let checkout_dir = tmp_dir.join("checkout");
clone_repo(repo_url, &checkout_dir)?;
let src_skills = checkout_dir.join(SKILLS_DIR_NAME);
let src_skills_metadata = fs::symlink_metadata(&src_skills)
.map_err(|source| PublicSkillsError::io("read skills dir metadata", source))?;
let src_skills_type = src_skills_metadata.file_type();
if src_skills_type.is_symlink() || !src_skills_type.is_dir() {
return Err(PublicSkillsError::RepoMissingSkillsDir {
skills_dir_name: SKILLS_DIR_NAME,
});
}
let staged_public = tmp_dir.join(PUBLIC_SKILLS_DIR_NAME);
stage_skills_dir(&src_skills, &staged_public)?;
atomic_swap_dir(&staged_public, &dest_public, &skills_root_dir)?;
tmp_dir_cleanup.cleanup()?;
Ok(PublicSkillsRefreshOutcome::Updated)
}
fn stage_skills_dir(src: &Path, staged: &Path) -> Result<(), PublicSkillsError> {
fs::rename(src, staged).map_err(|source| PublicSkillsError::io("stage skills dir", source))?;
prune_symlinks_and_special_files(staged)?;
Ok(())
}
fn prune_symlinks_and_special_files(root: &Path) -> Result<(), PublicSkillsError> {
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir)
.map_err(|source| PublicSkillsError::io("read staged skills dir", source))?
{
let entry = entry
.map_err(|source| PublicSkillsError::io("read staged skills dir entry", source))?;
let file_type = entry
.file_type()
.map_err(|source| PublicSkillsError::io("read staged skills entry type", source))?;
let path = entry.path();
if file_type.is_symlink() {
fs::remove_file(&path).map_err(|source| {
PublicSkillsError::io("remove symlink from staged skills", source)
})?;
continue;
}
if file_type.is_dir() {
stack.push(path);
continue;
}
if file_type.is_file() {
continue;
}
fs::remove_file(&path).map_err(|source| {
PublicSkillsError::io("remove special file from staged skills", source)
})?;
}
}
Ok(())
}
fn clone_repo(repo_url: &str, checkout_dir: &Path) -> Result<(), PublicSkillsError> {
let out = std::process::Command::new("git")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true")
.arg("clone")
.arg("--depth")
.arg("1")
.arg(repo_url)
.arg(checkout_dir)
.stdin(std::process::Stdio::null())
.output()
.map_err(|source| PublicSkillsError::io("spawn `git clone`", source))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stderr = stderr.trim();
return if stderr.is_empty() {
Err(PublicSkillsError::GitCloneFailed { status: out.status })
} else {
Err(PublicSkillsError::GitCloneFailedWithStderr {
status: out.status,
stderr: stderr.to_owned(),
})
};
}
Ok(())
}
fn atomic_swap_dir(staged: &Path, dest: &Path, parent: &Path) -> Result<(), PublicSkillsError> {
if let Some(dest_parent) = dest.parent() {
fs::create_dir_all(dest_parent)
.map_err(|source| PublicSkillsError::io("create public skills dest parent", source))?;
}
let backup_base = dest
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("skills");
let backup = parent.join(format!("{backup_base}.old-{}", rand_suffix()));
if backup.exists() {
fs::remove_dir_all(&backup)
.map_err(|source| PublicSkillsError::io("remove old public skills backup", source))?;
}
if dest.exists() {
fs::rename(dest, &backup)
.map_err(|source| PublicSkillsError::io("rename public skills to backup", source))?;
}
if let Err(err) = fs::rename(staged, dest) {
if backup.exists() {
let _ = fs::rename(&backup, dest);
}
return Err(PublicSkillsError::io(
"rename staged public skills into place",
err,
));
}
if backup.exists() {
fs::remove_dir_all(&backup)
.map_err(|source| PublicSkillsError::io("remove public skills backup", source))?;
}
Ok(())
}
fn rand_suffix() -> String {
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{pid:x}-{nanos:x}")
}
#[derive(Debug, Error)]
pub(crate) enum PublicSkillsError {
#[error("io error while {action}: {source}")]
Io {
action: &'static str,
#[source]
source: std::io::Error,
},
#[error("repo did not contain a `{skills_dir_name}` directory")]
RepoMissingSkillsDir { skills_dir_name: &'static str },
#[error("`git clone` failed with status {status}")]
GitCloneFailed { status: ExitStatus },
#[error("`git clone` failed with status {status}: {stderr}")]
GitCloneFailedWithStderr { status: ExitStatus, stderr: String },
}
impl PublicSkillsError {
fn io(action: &'static str, source: std::io::Error) -> Self {
Self::Io { action, source }
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn write_public_skill(repo_dir: &TempDir, name: &str, description: &str) {
let skills_dir = repo_dir.path().join("skills").join(name);
fs::create_dir_all(&skills_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
fs::write(skills_dir.join("SKILL.md"), content).unwrap();
}
fn git(repo_dir: &TempDir, args: &[&str]) {
let status = std::process::Command::new("git")
.args([
"-c",
"user.name=codex-test",
"-c",
"user.email=codex-test@example.com",
])
.args(args)
.current_dir(repo_dir.path())
.status()
.unwrap();
assert!(status.success(), "git command failed: {args:?}");
}
#[tokio::test]
async fn refresh_copies_skills_subdir_into_public_cache() {
let codex_home = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
git(&repo_dir, &["init"]);
write_public_skill(&repo_dir, "demo", "from repo");
git(&repo_dir, &["add", "."]);
git(&repo_dir, &["commit", "-m", "init"]);
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
.unwrap();
let path = public_cache_root_dir(codex_home.path())
.join("demo")
.join("SKILL.md");
let contents = fs::read_to_string(path).unwrap();
assert!(contents.contains("name: demo"));
assert!(contents.contains("description: from repo"));
}
#[tokio::test]
async fn refresh_overwrites_existing_public_cache() {
let codex_home = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
git(&repo_dir, &["init"]);
write_public_skill(&repo_dir, "demo", "v1");
git(&repo_dir, &["add", "."]);
git(&repo_dir, &["commit", "-m", "v1"]);
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
.unwrap();
write_public_skill(&repo_dir, "demo", "v2");
git(&repo_dir, &["add", "."]);
git(&repo_dir, &["commit", "-m", "v2"]);
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
.unwrap();
let path = public_cache_root_dir(codex_home.path())
.join("demo")
.join("SKILL.md");
let contents = fs::read_to_string(path).unwrap();
assert_eq!(contents.matches("description:").count(), 1);
assert!(contents.contains("description: v2"));
}
#[cfg(unix)]
#[tokio::test]
async fn refresh_prunes_symlinks_inside_skills_dir() {
use std::os::unix::fs::symlink;
let codex_home = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
git(&repo_dir, &["init"]);
write_public_skill(&repo_dir, "demo", "from repo");
let demo_dir = repo_dir.path().join("skills").join("demo");
symlink("SKILL.md", demo_dir.join("link-to-skill")).unwrap();
git(&repo_dir, &["add", "."]);
git(&repo_dir, &["commit", "-m", "init"]);
refresh_public_skills_from_repo_url(codex_home.path(), repo_dir.path().to_str().unwrap())
.unwrap();
assert!(
!public_cache_root_dir(codex_home.path())
.join("demo")
.join("link-to-skill")
.exists()
);
}
#[cfg(unix)]
#[tokio::test]
async fn refresh_rejects_symlinked_skills_dir() {
use std::os::unix::fs::symlink;
let codex_home = tempfile::tempdir().unwrap();
let repo_dir = tempfile::tempdir().unwrap();
git(&repo_dir, &["init"]);
let skills_target = repo_dir.path().join("skills-target");
fs::create_dir_all(skills_target.join("demo")).unwrap();
fs::write(
skills_target.join("demo").join("SKILL.md"),
"---\nname: demo\ndescription: from repo\n---\n",
)
.unwrap();
symlink("skills-target", repo_dir.path().join("skills")).unwrap();
git(&repo_dir, &["add", "."]);
git(&repo_dir, &["commit", "-m", "init"]);
let err = refresh_public_skills_from_repo_url(
codex_home.path(),
repo_dir.path().to_str().unwrap(),
)
.unwrap_err();
assert!(err.to_string().contains("repo did not contain"));
}
}

View File

@@ -7,14 +7,13 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("These skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.".to_string());
lines.push("These skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.".to_string());
for skill in skills {
let path_str = skill.path.to_string_lossy().replace('\\', "/");
lines.push(format!(
"- {}: {} (file: {})",
skill.name, skill.description, path_str
));
let name = skill.name.as_str();
let description = skill.description.as_str();
lines.push(format!("- {name}: {description} (file: {path_str})"));
}
lines.push(

View File

@@ -39,7 +39,7 @@ pub(crate) struct HandleOutputCtx {
pub cancellation_token: CancellationToken,
}
#[instrument(skip_all)]
#[instrument(level = "trace", skip_all)]
pub(crate) async fn handle_output_item_done(
ctx: &mut HandleOutputCtx,
item: ResponseItem,

View File

@@ -41,31 +41,36 @@ impl SessionTask for GhostSnapshotTask {
) -> Option<String> {
tokio::task::spawn(async move {
let token = self.token;
let warnings_enabled = !ctx.ghost_snapshot.disable_warnings;
// Channel used to signal when the snapshot work has finished so the
// timeout warning task can exit early without sending a warning.
let (snapshot_done_tx, snapshot_done_rx) = oneshot::channel::<()>();
let ctx_for_warning = ctx.clone();
let cancellation_token_for_warning = cancellation_token.clone();
let session_for_warning = session.clone();
// Fire a generic warning if the snapshot is still running after
// three minutes; this helps users discover large untracked files
// that might need to be added to .gitignore.
tokio::task::spawn(async move {
tokio::select! {
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
session_for_warning.session
.send_event(
&ctx_for_warning,
EventMsg::Warning(WarningEvent {
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
}),
)
.await;
if warnings_enabled {
let ctx_for_warning = ctx.clone();
let cancellation_token_for_warning = cancellation_token.clone();
let session_for_warning = session.clone();
// Fire a generic warning if the snapshot is still running after
// three minutes; this helps users discover large untracked files
// that might need to be added to .gitignore.
tokio::task::spawn(async move {
tokio::select! {
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
session_for_warning.session
.send_event(
&ctx_for_warning,
EventMsg::Warning(WarningEvent {
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
}),
)
.await;
}
_ = snapshot_done_rx => {}
_ = cancellation_token_for_warning.cancelled() => {}
}
_ = snapshot_done_rx => {}
_ = cancellation_token_for_warning.cancelled() => {}
}
});
});
} else {
drop(snapshot_done_rx);
}
let ctx_for_task = ctx.clone();
let cancelled = tokio::select! {
@@ -84,18 +89,20 @@ impl SessionTask for GhostSnapshotTask {
{
Ok(Ok((ghost_commit, report))) => {
info!("ghost snapshot blocking task finished");
for message in format_snapshot_warnings(
ghost_snapshot.ignore_large_untracked_files,
ghost_snapshot.ignore_large_untracked_dirs,
&report,
) {
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
if warnings_enabled {
for message in format_snapshot_warnings(
ghost_snapshot.ignore_large_untracked_files,
ghost_snapshot.ignore_large_untracked_dirs,
&report,
) {
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
}
}
session
.session

View File

@@ -159,6 +159,7 @@ impl Session {
for task in self.take_all_running_tasks().await {
self.handle_task_abort(task, reason.clone()).await;
}
self.close_unified_exec_sessions().await;
}
pub async fn on_task_finished(
@@ -167,12 +168,18 @@ impl Session {
last_agent_message: Option<String>,
) {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut()
let should_close_sessions = if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
*active = None;
}
true
} else {
false
};
drop(active);
if should_close_sessions {
self.close_unified_exec_sessions().await;
}
let event = EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message });
self.send_event(turn_context.as_ref(), event).await;
}
@@ -196,6 +203,13 @@ impl Session {
}
}
async fn close_unified_exec_sessions(&self) {
self.services
.unified_exec_manager
.terminate_all_sessions()
.await;
}
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
let sub_id = task.turn_context.sub_id.clone();
if task.cancellation_token.is_cancelled() {

View File

@@ -7,7 +7,7 @@ use async_trait::async_trait;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;
use tracing::info_span;
use tracing::trace_span;
use super::SessionTask;
use super::SessionTaskContext;
@@ -30,7 +30,7 @@ impl SessionTask for RegularTask {
) -> Option<String> {
let sess = session.clone_session();
let run_task_span =
info_span!(parent: sess.services.otel_manager.current_span(), "run_task");
trace_span!(parent: sess.services.otel_manager.current_span(), "run_task");
run_task(sess, ctx, input, cancellation_token)
.instrument(run_task_span)
.await

View File

@@ -16,7 +16,6 @@ use tokio_util::sync::CancellationToken;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::codex_delegate::run_codex_conversation_one_shot;
use crate::protocol::SandboxPolicy;
use crate::review_format::format_review_findings_block;
use crate::review_format::render_review_output_text;
use crate::state::TaskKind;
@@ -78,7 +77,6 @@ async fn start_review_conversation(
) -> Option<async_channel::Receiver<Event>> {
let config = ctx.client.config();
let mut sub_agent_config = config.as_ref().clone();
sub_agent_config.sandbox_policy = SandboxPolicy::new_read_only_policy();
// Run with only reviewer rubric — drop outer user_instructions
sub_agent_config.user_instructions = None;
// Avoid loading project docs; reviewer only needs findings

View File

@@ -37,9 +37,6 @@ pub enum ToolPayload {
LocalShell {
params: ShellToolCallParams,
},
UnifiedExec {
arguments: String,
},
Mcp {
server: String,
tool: String,
@@ -53,7 +50,6 @@ impl ToolPayload {
ToolPayload::Function { arguments } => Cow::Borrowed(arguments),
ToolPayload::Custom { input } => Cow::Borrowed(input),
ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")),
ToolPayload::UnifiedExec { arguments } => Cow::Borrowed(arguments),
ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments),
}
}

View File

@@ -77,16 +77,15 @@ impl ToolHandler for UnifiedExecHandler {
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
payload,
ToolPayload::Function { .. } | ToolPayload::UnifiedExec { .. }
)
matches!(payload, ToolPayload::Function { .. })
}
async fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) =
&invocation.payload
else {
let ToolPayload::Function { arguments } = &invocation.payload else {
tracing::error!(
"This should never happen, invocation payload is wrong: {:?}",
invocation.payload
);
return true;
};
@@ -110,7 +109,6 @@ impl ToolHandler for UnifiedExecHandler {
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
ToolPayload::UnifiedExec { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"unified_exec handler received unsupported payload".to_string(),

View File

@@ -70,9 +70,11 @@ pub fn format_exec_output_for_model_freeform(
// round to 1 decimal place
let duration_seconds = ((exec_output.duration.as_secs_f32()) * 10.0).round() / 10.0;
let total_lines = exec_output.aggregated_output.text.lines().count();
let content = build_content_with_timeout(exec_output);
let formatted_output = truncate_text(&exec_output.aggregated_output.text, truncation_policy);
let total_lines = content.lines().count();
let formatted_output = truncate_text(&content, truncation_policy);
let mut sections = Vec::new();
@@ -92,21 +94,21 @@ pub fn format_exec_output_str(
exec_output: &ExecToolCallOutput,
truncation_policy: TruncationPolicy,
) -> String {
let ExecToolCallOutput {
aggregated_output, ..
} = exec_output;
let content = aggregated_output.text.as_str();
let body = if exec_output.timed_out {
format!(
"command timed out after {} milliseconds\n{content}",
exec_output.duration.as_millis()
)
} else {
content.to_string()
};
let content = build_content_with_timeout(exec_output);
// Truncate for model consumption before serialization.
formatted_truncate_text(&body, truncation_policy)
formatted_truncate_text(&content, truncation_policy)
}
/// Extracts exec output content and prepends a timeout message if the command timed out.
fn build_content_with_timeout(exec_output: &ExecToolCallOutput) -> String {
if exec_output.timed_out {
format!(
"command timed out after {} milliseconds\n{}",
exec_output.duration.as_millis(),
exec_output.aggregated_output.text
)
} else {
exec_output.aggregated_output.text.clone()
}
}

View File

@@ -6,8 +6,8 @@ use tokio_util::either::Either;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use tracing::Instrument;
use tracing::info_span;
use tracing::instrument;
use tracing::trace_span;
use crate::codex::Session;
use crate::codex::TurnContext;
@@ -45,7 +45,7 @@ impl ToolCallRuntime {
}
}
#[instrument(skip_all, fields(call = ?call))]
#[instrument(level = "trace", skip_all, fields(call = ?call))]
pub(crate) fn handle_tool_call(
self,
call: ToolCall,
@@ -60,7 +60,7 @@ impl ToolCallRuntime {
let lock = Arc::clone(&self.parallel_execution);
let started = Instant::now();
let dispatch_span = info_span!(
let dispatch_span = trace_span!(
"dispatch_tool_call",
otel.name = call.tool_name.as_str(),
tool_name = call.tool_name.as_str(),

View File

@@ -55,7 +55,7 @@ impl ToolRouter {
.any(|config| config.spec.name() == tool_name)
}
#[instrument(skip_all, err)]
#[instrument(level = "trace", skip_all, err)]
pub async fn build_tool_call(
session: &Session,
item: ResponseItem,
@@ -78,15 +78,10 @@ impl ToolRouter {
},
}))
} else {
let payload = if name == "unified_exec" {
ToolPayload::UnifiedExec { arguments }
} else {
ToolPayload::Function { arguments }
};
Ok(Some(ToolCall {
tool_name: name,
call_id,
payload,
payload: ToolPayload::Function { arguments },
}))
}
}
@@ -131,7 +126,7 @@ impl ToolRouter {
}
}
#[instrument(skip_all, err)]
#[instrument(level = "trace", skip_all, err)]
pub async fn dispatch_tool_call(
&self,
session: Arc<Session>,

View File

@@ -43,7 +43,12 @@ impl ToolsConfig {
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
} else if features.enabled(Feature::UnifiedExec) {
ConfigShellToolType::UnifiedExec
// If ConPTY not supported (for old Windows versions), fallback on ShellCommand.
if codex_utils_pty::conpty_supported() {
ConfigShellToolType::UnifiedExec
} else {
ConfigShellToolType::ShellCommand
}
} else {
model_family.shell_type
};
@@ -153,8 +158,7 @@ fn create_exec_command_tool() -> ToolSpec {
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to false unless a shell snapshot is available."
.to_string(),
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
);
@@ -336,7 +340,7 @@ fn create_shell_command_tool() -> ToolSpec {
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to false unless a shell snapshot is available."
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},

View File

@@ -175,7 +175,6 @@ impl UnifiedExecSessionManager {
// Shortlived command: emit ExecCommandEnd immediately using the
// same helper as the background watcher, so all end events share
// one implementation.
self.release_process_id(&request.process_id).await;
let exit = exit_code.unwrap_or(-1);
emit_exec_end_for_unified_exec(
Arc::clone(&context.session),
@@ -191,6 +190,7 @@ impl UnifiedExecSessionManager {
)
.await;
self.release_process_id(&request.process_id).await;
session.check_for_sandbox_denial_with_text(&text).await?;
} else {
// Longlived command: persist the session so write_stdin can reuse

View File

@@ -13,6 +13,7 @@ use std::path::PathBuf;
#[cfg(target_os = "linux")]
use assert_cmd::cargo::cargo_bin;
pub mod process;
pub mod responses;
pub mod streaming_sse;
pub mod test_codex;

View File

@@ -0,0 +1,48 @@
use anyhow::Context;
use std::fs;
use std::path::Path;
use std::time::Duration;
pub async fn wait_for_pid_file(path: &Path) -> anyhow::Result<String> {
let pid = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(path) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.context("timed out waiting for pid file")?;
Ok(pid)
}
pub fn process_is_alive(pid: &str) -> anyhow::Result<bool> {
let status = std::process::Command::new("kill")
.args(["-0", pid])
.status()
.context("failed to probe process liveness with kill -0")?;
Ok(status.success())
}
async fn wait_for_process_exit_inner(pid: String) -> anyhow::Result<()> {
loop {
if !process_is_alive(&pid)? {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
pub async fn wait_for_process_exit(pid: &str) -> anyhow::Result<()> {
let pid = pid.to_string();
tokio::time::timeout(Duration::from_secs(2), wait_for_process_exit_inner(pid))
.await
.context("timed out waiting for process to exit")??;
Ok(())
}

View File

@@ -1,6 +1,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Result;
use codex_core::config::Constrained;
use codex_core::features::Feature;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskForApproval;
@@ -126,7 +127,7 @@ impl ActionKind {
);
let command = format!("python3 -c \"{script}\"");
let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?;
let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?;
Ok((event, Some(command)))
}
ActionKind::RunCommand { command } => {
@@ -1462,7 +1463,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
let model = model_override.unwrap_or("gpt-5.1");
let mut builder = test_codex().with_model(model).with_config(move |config| {
config.approval_policy = approval_policy;
config.approval_policy = Constrained::allow_any(approval_policy);
config.sandbox_policy = sandbox_policy.clone();
for feature in features {
config.features.enable(feature);
@@ -1568,7 +1569,7 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts
let sandbox_policy = SandboxPolicy::ReadOnly;
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.approval_policy = approval_policy;
config.approval_policy = Constrained::allow_any(approval_policy);
config.sandbox_policy = sandbox_policy_for_config;
});
let test = builder.build(&server).await?;

View File

@@ -1,3 +1,4 @@
use codex_core::config::Constrained;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
@@ -61,7 +62,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
// Build a conversation configured to require approvals so the delegate
// routes ExecApprovalRequest via the parent.
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
config.approval_policy = AskForApproval::OnRequest;
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.sandbox_policy = SandboxPolicy::ReadOnly;
});
let test = builder.build(&server).await.expect("build test codex");
@@ -137,7 +138,7 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() {
mount_sse_sequence(&server, vec![sse1, sse2]).await;
let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| {
config.approval_policy = AskForApproval::OnRequest;
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
// Use a restricted sandbox so patch approval is required
config.sandbox_policy = SandboxPolicy::ReadOnly;
config.include_apply_patch_tool = true;

View File

@@ -1009,7 +1009,6 @@ async fn auto_compact_runs_after_token_limit_hit() {
ev_assistant_message("m3", AUTO_SUMMARY_TEXT),
ev_completed_with_tokens("r3", 200),
]);
let sse_resume = sse(vec![ev_completed("r3-resume")]);
let sse4 = sse(vec![
ev_assistant_message("m4", FINAL_REPLY),
ev_completed_with_tokens("r4", 120),
@@ -1038,15 +1037,6 @@ async fn auto_compact_runs_after_token_limit_hit() {
};
mount_sse_once_match(&server, third_matcher, sse3).await;
let resume_marker = prefixed_auto_summary;
let resume_matcher = move |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(resume_marker)
&& !body_contains_text(body, SUMMARIZATION_PROMPT)
&& !body.contains(POST_AUTO_USER_MSG)
};
mount_sse_once_match(&server, resume_matcher, sse_resume).await;
let fourth_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(POST_AUTO_USER_MSG) && !body_contains_text(body, SUMMARIZATION_PROMPT)
@@ -1106,8 +1096,8 @@ async fn auto_compact_runs_after_token_limit_hit() {
let requests = get_responses_requests(&server).await;
assert_eq!(
requests.len(),
5,
"expected user turns, a compaction request, a resumed turn, and the follow-up turn; got {}",
4,
"expected user turns, a compaction request, and the follow-up turn; got {}",
requests.len()
);
let is_auto_compact = |req: &wiremock::Request| {
@@ -1131,19 +1121,6 @@ async fn auto_compact_runs_after_token_limit_hit() {
"auto compact should add a third request"
);
let resume_summary_marker = prefixed_auto_summary;
let resume_index = requests
.iter()
.enumerate()
.find_map(|(idx, req)| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
(body.contains(resume_summary_marker)
&& !body_contains_text(body, SUMMARIZATION_PROMPT)
&& !body.contains(POST_AUTO_USER_MSG))
.then_some(idx)
})
.expect("resume request missing after compaction");
let follow_up_index = requests
.iter()
.enumerate()
@@ -1154,15 +1131,12 @@ async fn auto_compact_runs_after_token_limit_hit() {
.then_some(idx)
})
.expect("follow-up request missing");
assert_eq!(follow_up_index, 4, "follow-up request should be last");
assert_eq!(follow_up_index, 3, "follow-up request should be last");
let body_first = requests[0].body_json::<serde_json::Value>().unwrap();
let body_auto = requests[auto_compact_index]
.body_json::<serde_json::Value>()
.unwrap();
let body_resume = requests[resume_index]
.body_json::<serde_json::Value>()
.unwrap();
let body_follow_up = requests[follow_up_index]
.body_json::<serde_json::Value>()
.unwrap();
@@ -1201,23 +1175,6 @@ async fn auto_compact_runs_after_token_limit_hit() {
"auto compact should send the summarization prompt as a user message",
);
let input_resume = body_resume.get("input").and_then(|v| v.as_array()).unwrap();
assert!(
input_resume.iter().any(|item| {
item.get("type").and_then(|v| v.as_str()) == Some("message")
&& item.get("role").and_then(|v| v.as_str()) == Some("user")
&& item
.get("content")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|entry| entry.get("text"))
.and_then(|v| v.as_str())
.map(|text| text.contains(prefixed_auto_summary))
.unwrap_or(false)
}),
"resume request should include compacted history"
);
let input_follow_up = body_follow_up
.get("input")
.and_then(|v| v.as_array())
@@ -1276,6 +1233,10 @@ async fn auto_compact_persists_rollout_entries() {
ev_assistant_message("m3", &auto_summary_payload),
ev_completed_with_tokens("r3", 200),
]);
let sse4 = sse(vec![
ev_assistant_message("m4", FINAL_REPLY),
ev_completed_with_tokens("r4", 120),
]);
let first_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
@@ -1299,12 +1260,19 @@ async fn auto_compact_persists_rollout_entries() {
};
mount_sse_once_match(&server, third_matcher, sse3).await;
let fourth_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(POST_AUTO_USER_MSG) && !body_contains_text(body, SUMMARIZATION_PROMPT)
};
mount_sse_once_match(&server, fourth_matcher, sse4).await;
let model_provider = non_openai_model_provider(&server);
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = model_provider;
set_test_compact_prompt(&mut config);
config.model_auto_compact_token_limit = Some(200_000);
let conversation_manager = ConversationManager::with_models_provider(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
@@ -1335,6 +1303,16 @@ async fn auto_compact_persists_rollout_entries() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: POST_AUTO_USER_MSG.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex.submit(Op::Shutdown).await.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
@@ -1731,6 +1709,8 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
ev_assistant_message("m6", FINAL_REPLY),
ev_completed_with_tokens("r6", 120),
]);
let follow_up_user = "FOLLOW_UP_AUTO_COMPACT";
let final_user = "FINAL_AUTO_COMPACT";
mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4, sse5, sse6]).await;
@@ -1751,31 +1731,31 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_
.unwrap()
.conversation;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: MULTI_AUTO_MSG.into(),
}],
})
.await
.unwrap();
let mut auto_compact_lifecycle_events = Vec::new();
loop {
let event = codex.next_event().await.unwrap();
if event.id.starts_with("auto-compact-")
&& matches!(
event.msg,
EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_)
)
{
auto_compact_lifecycle_events.push(event);
continue;
}
if let EventMsg::TaskComplete(_) = &event.msg
&& !event.id.starts_with("auto-compact-")
{
break;
for user in [MULTI_AUTO_MSG, follow_up_user, final_user] {
codex
.submit(Op::UserInput {
items: vec![UserInput::Text { text: user.into() }],
})
.await
.unwrap();
loop {
let event = codex.next_event().await.unwrap();
if event.id.starts_with("auto-compact-")
&& matches!(
event.msg,
EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_)
)
{
auto_compact_lifecycle_events.push(event);
continue;
}
if let EventMsg::TaskComplete(_) = &event.msg
&& !event.id.starts_with("auto-compact-")
{
break;
}
}
}
@@ -1821,6 +1801,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
let context_window = 100;
let limit = context_window * 90 / 100;
let over_limit_tokens = context_window * 95 / 100 + 1;
let follow_up_user = "FOLLOW_UP_AFTER_LIMIT";
let first_turn = sse(vec![
ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"),
@@ -1873,6 +1854,17 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: follow_up_user.into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await;
// Assert first request captured expected user message that triggers function call.
let first_request = first_turn_mock.single_request().input();
assert!(
@@ -1916,6 +1908,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
let first_user = "COUNT_PRE_LAST_REASONING";
let second_user = "TRIGGER_COMPACT_AT_LIMIT";
let third_user = "AFTER_REMOTE_COMPACT";
let pre_last_reasoning_content = "a".repeat(2_400);
let post_last_reasoning_content = "b".repeat(4_000);
@@ -1928,7 +1921,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
ev_reasoning_item("post-reasoning", &["post"], &[&post_last_reasoning_content]),
ev_completed_with_tokens("r2", 80),
]);
let resume_turn = sse(vec![
let third_turn = sse(vec![
ev_assistant_message("m4", FINAL_REPLY),
ev_completed_with_tokens("r4", 1),
]);
@@ -1940,8 +1933,8 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
first_turn,
// Turn 2: reasoning after last user (should be ignored for compaction).
second_turn,
// Turn 3: resume after remote compaction.
resume_turn,
// Turn 3: next user turn after remote compaction.
third_turn,
],
)
.await;
@@ -1973,7 +1966,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
.expect("build codex")
.codex;
for (idx, user) in [first_user, second_user].into_iter().enumerate() {
for (idx, user) in [first_user, second_user, third_user]
.into_iter()
.enumerate()
{
codex
.submit(Op::UserInput {
items: vec![UserInput::Text { text: user.into() }],
@@ -1982,10 +1978,10 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
if idx == 0 {
if idx < 2 {
assert!(
compact_mock.requests().is_empty(),
"remote compaction should not run after the first turn"
"remote compaction should not run before the next user turn"
);
}
}
@@ -2006,20 +2002,21 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() {
assert_eq!(
requests.len(),
3,
"conversation should include two user turns and a post-compaction resume"
"conversation should include three user turns"
);
let second_request_body = requests[1].body_json().to_string();
assert!(
!second_request_body.contains("REMOTE_COMPACT_SUMMARY"),
"second turn should not include compacted history"
);
let resume_body = requests[2].body_json().to_string();
let third_request_body = requests[2].body_json().to_string();
assert!(
resume_body.contains("REMOTE_COMPACT_SUMMARY") || resume_body.contains(FINAL_REPLY),
"resume request should follow remote compact and use compacted history"
third_request_body.contains("REMOTE_COMPACT_SUMMARY")
|| third_request_body.contains(FINAL_REPLY),
"third turn should include compacted history"
);
assert!(
resume_body.contains("ENCRYPTED_COMPACTION_SUMMARY"),
"resume request should include compaction summary item"
third_request_body.contains("ENCRYPTED_COMPACTION_SUMMARY"),
"third turn should include compaction summary item"
);
}

View File

@@ -52,8 +52,11 @@ fn expected_models_for_api_key() -> Vec<ModelPreset> {
}
fn expected_models_for_chatgpt() -> Vec<ModelPreset> {
let mut gpt_5_1_codex_max = gpt_5_1_codex_max();
gpt_5_1_codex_max.is_default = false;
vec![
gpt_5_1_codex_max(),
caribou(),
gpt_5_1_codex_max,
gpt_5_1_codex(),
gpt_5_1_codex_mini(),
gpt_5_2(),
@@ -61,6 +64,38 @@ fn expected_models_for_chatgpt() -> Vec<ModelPreset> {
]
}
fn caribou() -> ModelPreset {
ModelPreset {
id: "caribou".to_string(),
model: "caribou".to_string(),
display_name: "caribou".to_string(),
description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
effort(
ReasoningEffort::Low,
"Fast responses with lighter reasoning",
),
effort(
ReasoningEffort::Medium,
"Balances speed and reasoning depth for everyday tasks",
),
effort(
ReasoningEffort::High,
"Greater reasoning depth for complex problems",
),
effort(
ReasoningEffort::XHigh,
"Extra high reasoning depth for complex problems",
),
],
is_default: true,
upgrade: None,
show_in_picker: true,
supported_in_api: false,
}
}
fn gpt_5_1_codex_max() -> ModelPreset {
ModelPreset {
id: "gpt-5.1-codex-max".to_string(),
@@ -87,8 +122,9 @@ fn gpt_5_1_codex_max() -> ModelPreset {
),
],
is_default: true,
upgrade: None,
upgrade: Some(caribou_upgrade()),
show_in_picker: true,
supported_in_api: true,
}
}
@@ -114,8 +150,9 @@ fn gpt_5_1_codex() -> ModelPreset {
),
],
is_default: false,
upgrade: Some(gpt_5_1_codex_max_upgrade()),
upgrade: Some(caribou_upgrade()),
show_in_picker: true,
supported_in_api: true,
}
}
@@ -137,8 +174,9 @@ fn gpt_5_1_codex_mini() -> ModelPreset {
),
],
is_default: false,
upgrade: Some(gpt_5_1_codex_max_upgrade()),
upgrade: None,
show_in_picker: true,
supported_in_api: true,
}
}
@@ -162,7 +200,7 @@ fn gpt_5_2() -> ModelPreset {
),
effort(
ReasoningEffort::High,
"Greater reasoning depth for complex or ambiguous problems",
"Maximizes reasoning depth for complex or ambiguous problems",
),
effort(
ReasoningEffort::XHigh,
@@ -170,8 +208,9 @@ fn gpt_5_2() -> ModelPreset {
),
],
is_default: false,
upgrade: None,
upgrade: Some(caribou_upgrade()),
show_in_picker: true,
supported_in_api: true,
}
}
@@ -197,16 +236,17 @@ fn gpt_5_1() -> ModelPreset {
),
],
is_default: false,
upgrade: Some(gpt_5_1_codex_max_upgrade()),
upgrade: Some(caribou_upgrade()),
show_in_picker: true,
supported_in_api: true,
}
}
fn gpt_5_1_codex_max_upgrade() -> codex_protocol::openai_models::ModelUpgrade {
fn caribou_upgrade() -> codex_protocol::openai_models::ModelUpgrade {
codex_protocol::openai_models::ModelUpgrade {
id: "gpt-5.1-codex-max".to_string(),
id: "caribou".to_string(),
reasoning_effort_mapping: None,
migration_config_key: "hide_gpt-5.1-codex-max_migration_prompt".to_string(),
migration_config_key: "caribou".to_string(),
}
}

View File

@@ -1,3 +1,4 @@
use codex_core::config::Constrained;
use codex_core::features::Feature;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -25,6 +26,7 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use std::sync::Mutex;
use tracing::Level;
use tracing_test::traced_test;
use tracing_subscriber::fmt::format::FmtSpan;
@@ -454,6 +456,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() {
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::TRACE)
.with_span_events(FmtSpan::FULL)
.with_writer(MockWriter::new(buffer))
.finish();
@@ -517,6 +520,7 @@ async fn record_responses_sets_span_fields_for_response_events() {
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::TRACE)
.with_span_events(FmtSpan::FULL)
.with_writer(MockWriter::new(buffer))
.finish();
@@ -930,7 +934,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::OnRequest;
config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
})
.build(&server)
@@ -979,7 +983,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
})
.build(&server)
.await
@@ -1037,7 +1041,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
})
.build(&server)
.await
@@ -1095,7 +1099,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
})
.build(&server)
.await
@@ -1153,7 +1157,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
})
.build(&server)
.await
@@ -1211,7 +1215,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
})
.build(&server)
.await
@@ -1270,7 +1274,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
})
.build(&server)
.await

View File

@@ -59,60 +59,8 @@ fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn codex_mini_latest_tools() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
use pretty_assertions::assert_eq;
let server = start_mock_server().await;
let req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.disable(Feature::ApplyPatchFreeform);
config.model = Some("codex-mini-latest".to_string());
})
.build(&server)
.await?;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 1".into(),
}],
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 2".into(),
}],
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let expected_instructions = [
include_str!("../../prompt.md"),
include_str!("../../../apply-patch/apply_patch_tool_instructions.md"),
]
.join("\n");
let body0 = req1.single_request().body_json();
assert_eq!(
body0["instructions"],
serde_json::json!(expected_instructions),
);
let body1 = req2.single_request().body_json();
assert_eq!(
body1["instructions"],
serde_json::json!(expected_instructions),
);
Ok(())
fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -204,6 +152,70 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_mini_latest_tools() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
use pretty_assertions::assert_eq;
let server = start_mock_server().await;
let req1 = mount_sse_once(&server, sse_completed("resp-1")).await;
let req2 = mount_sse_once(&server, sse_completed("resp-2")).await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.user_instructions = Some("be consistent and helpful".to_string());
config.features.disable(Feature::ApplyPatchFreeform);
config.model = Some("codex-mini-latest".to_string());
})
.build(&server)
.await?;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 1".into(),
}],
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello 2".into(),
}],
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let expected_instructions = [
include_str!("../../prompt.md"),
include_str!("../../../apply-patch/apply_patch_tool_instructions.md"),
]
.join("\n");
let body0 = req1.single_request().body_json();
let instructions0 = body0["instructions"]
.as_str()
.expect("instructions should be a string");
assert_eq!(
normalize_newlines(instructions0),
normalize_newlines(&expected_instructions)
);
let body1 = req2.single_request().body_json();
let instructions1 = body1["instructions"]
.as_str()
.expect("instructions should be a string");
assert_eq!(
normalize_newlines(instructions1),
normalize_newlines(&expected_instructions)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prefixes_context_and_instructions_once_and_consistently_across_requests()
-> anyhow::Result<()> {
@@ -593,7 +605,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
.await?;
let default_cwd = config.cwd.clone();
let default_approval_policy = config.approval_policy;
let default_approval_policy = config.approval_policy.value();
let default_sandbox_policy = config.sandbox_policy.clone();
let default_model = session_configured.model;
let default_effort = config.model_reasoning_effort;
@@ -685,7 +697,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
.await?;
let default_cwd = config.cwd.clone();
let default_approval_policy = config.approval_policy;
let default_approval_policy = config.approval_policy.value();
let default_sandbox_policy = config.sandbox_policy.clone();
let default_model = session_configured.model;
let default_effort = config.model_reasoning_effort;

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