Compare commits

...

71 Commits

Author SHA1 Message Date
iceweasel-oai
06c9bfd54f Release 0.76.0-alpha.2 2025-12-18 12:17:45 -08:00
iceweasel-oai
e9023d5662 use mainline version as baseline in ci (#8271) 2025-12-18 11:53:36 -08:00
iceweasel-oai
ad41182ee8 grant read ACL to exe directory first so we can call the command runner (#8275)
when granting read access to the sandbox user, grant the
codex/command-runner exe directory first so commands can run before the
entire read ACL process is finished.
2025-12-18 19:52:32 +00:00
Celia Chen
2e5d52cb14 [release] Add a dmg target for MacOS (#8207)
Add a dmg target that bundles the codex and codex responses api proxy
binaries for MacOS. this target is signed and notarized.

Verified by triggering a build here:
https://github.com/openai/codex/actions/runs/20318136302/job/58367155205.
Downloaded the artifact and verified that the dmg is signed and
notarized, and the codex binary contained works as expected.
2025-12-18 11:19:10 -08:00
Jeremy Rose
be274cbe62 tui: improve rendering of search cell (#8273)
before:

<img width="795" height="150" alt="Screenshot 2025-12-18 at 10 48 01 AM"
src="https://github.com/user-attachments/assets/6f4d8856-b4c2-4e2a-b60a-b86f82b956a0"
/>

after:

<img width="795" height="150" alt="Screenshot 2025-12-18 at 10 48 39 AM"
src="https://github.com/user-attachments/assets/dd0d167a-5d09-4bb7-9d36-95a2eb1aaa83"
/>
2025-12-18 11:05:26 -08:00
Ahmed Ibrahim
7157421daa splash screen (#8270)
# 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-18 10:59:53 -08:00
Michael Bolin
b903285746 feat: migrate to new constraint-based loading strategy (#8251)
This is a significant change to how layers of configuration are applied.
In particular, the `ConfigLayerStack` now has two important fields:

- `layers: Vec<ConfigLayerEntry>`
- `requirements: ConfigRequirements`

We merge `TomlValue`s across the layers, but they are subject to
`ConfigRequirements` before creating a `Config`.

How I would review this PR:

- start with `codex-rs/app-server-protocol/src/protocol/v2.rs` and note
the new variants added to the `ConfigLayerSource` enum:
`LegacyManagedConfigTomlFromFile` and `LegacyManagedConfigTomlFromMdm`
- note that `ConfigLayerSource` now has a `precedence()` method and
implements `PartialOrd`
- `codex-rs/core/src/config_loader/layer_io.rs` is responsible for
loading "admin" preferences from `/etc/codex/managed_config.toml` and
MDM. Because `/etc/codex/managed_config.toml` is now deprecated in favor
of `/etc/codex/requirements.toml` and `/etc/codex/config.toml`, we now
include some extra information on the `LoadedConfigLayers` returned in
`layer_io.rs`.
- `codex-rs/core/src/config_loader/mod.rs` has major changes to
`load_config_layers_state()`, which is what produces `ConfigLayerStack`.
The docstring has the new specification and describes the various layers
that will be loaded and the precedence order.
- It uses the information from `LoaderOverrides` "twice," both in the
spirit of legacy support:
- We use one instances to derive an instance of `ConfigRequirements`.
Currently, the only field in `managed_config.toml` that contributes to
`ConfigRequirements` is `approval_policy`. This PR introduces
`Constrained::allow_only()` to support this.
- We use a clone of `LoaderOverrides` to derive
`ConfigLayerSource::LegacyManagedConfigTomlFromFile` and
`ConfigLayerSource::LegacyManagedConfigTomlFromMdm` layers, as
appropriate. As before, this ends up being a "best effort" at enterprise
controls, but is enforcement is not guaranteed like it is for
`ConfigRequirements`.
- Now we only create a "user" layer if `$CODEX_HOME/config.toml` exists.
(Previously, a user layer was always created for `ConfigLayerStack`.)
- Similarly, we only add a "session flags" layer if there are CLI
overrides.
- `config_loader/state.rs` contains the updated implementation for
`ConfigLayerStack`. Note the public API is largely the same as before,
but the implementation is quite different. We leverage the fact that
`ConfigLayerSource` is now `PartialOrd` to ensure layers are in the
correct order.
- A `Config` constructed via `ConfigBuilder.build()` will use
`load_config_layers_state()` to create the `ConfigLayerStack` and use
the associated `ConfigRequirements` when constructing the `Config`
object.
- That said, a `Config` constructed via
`Config::load_from_base_config_with_overrides()` does _not_ yet use
`ConfigBuilder`, so it creates a `ConfigRequirements::default()` instead
of loading a proper `ConfigRequirements`. I will fix this in a
subsequent PR.

Then the following files are mostly test changes:

```
codex-rs/app-server/tests/suite/v2/config_rpc.rs
codex-rs/core/src/config/service.rs
codex-rs/core/src/config_loader/tests.rs
```

Again, because we do not always include "user" and "session flags"
layers when the contents are empty, `ConfigLayerStack` sometimes has
fewer layers than before (and the precedence order changed slightly),
which is the main reason integration tests changed.
2025-12-18 10:06:05 -08:00
squinlan-oai
425c8dc372 cloud: default to current branch in cloud exec (#7460)
## Summary
- add a shared git-ref resolver and use it for `codex cloud exec` and
TUI task submission
- expose a new `--branch` flag to override the git ref passed to cloud
tasks
- cover the git-ref resolution behavior with new async unit tests and
supporting dev dependencies

## Testing
- cargo test -p codex-cloud-tasks


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_692decc6cbec8332953470ef063e11ab)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Co-authored-by: Jeremy Rose <nornagon@openai.com>
2025-12-18 17:44:38 +00:00
jif-oai
aea47b6553 feat: add name to beta features (#8266)
Add a name to Beta features

<img width="906" height="153" alt="Screenshot 2025-12-18 at 16 42 49"
src="https://github.com/user-attachments/assets/d56f3519-0613-4d9a-ad4d-38b1a7eb125a"
/>
2025-12-18 16:59:46 +00:00
Ahmed Ibrahim
f084e5264b caribou (#8265)
Welcome caribou

<img width="1536" height="1024" alt="image"
src="https://github.com/user-attachments/assets/2a67b21f-40cf-4518-aee4-691af331ab50"
/>
2025-12-18 08:58:44 -08:00
Magson Leone dos Santos
4c9d589f14 docs: clarify codex resume --all (CWD column & filtering) (#8264)
This pull request makes a small update to the session picker
documentation for `codex resume`. The main change clarifies how to view
the original working directory (CWD) for sessions and when the Git
branch is shown.

- The session picker now displays the recorded Git branch when
available, and instructions are added for showing the original working
directory by using the `--all` flag, which also disables CWD filtering
and adds a `CWD` column.
2025-12-18 16:54:50 +00:00
Michael Bolin
deafead169 chore: prefer AsRef<Path> to &Path (#8249)
This is some minor API cleanup that will make it easier to use
`AbsolutePathBuf` in more places in a subsequent PR.
2025-12-18 08:50:13 -08:00
Ahmed Ibrahim
374d591311 chores: clean picker (#8232)
# 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-18 08:41:34 -08:00
Michael Bolin
9bf41e9262 chore: simplify loading of Mac-specific logic in config_loader (#8248)
Over in `config_loader/macos.rs`, we were doing this complicated `mod`
thing to expose one version of `load_managed_admin_config_layer()` for
Mac:


580c59aa9a/codex-rs/core/src/config_loader/macos.rs (L4-L5)

While exposing a trivial implementation for non-Mac:


580c59aa9a/codex-rs/core/src/config_loader/macos.rs (L110-L117)

That was being used like this:


580c59aa9a/codex-rs/core/src/config_loader/layer_io.rs (L47-L48)

This PR simplifies that callsite in `layer_io.rs` to just be:

```rust
    #[cfg(not(target_os = "macos"))]
    let managed_preferences = None;
```

And updates `config_loader/mod.rs` so we only pull in `macos.rs` on Mac:

```rust
#[cfg(target_os = "macos")]
mod macos;
```

This simplifies `macos.rs` considerably, though it looks like a big
change because everything gets unindented and reformatted because we can
drop the whole `mod native` thing now.




---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/8248).
* #8251
* #8249
* __->__ #8248
2025-12-18 07:35:16 -08:00
jif-oai
1cfacbf56d chore: add beta features (#8201) 2025-12-18 13:09:12 +00:00
jif-oai
cea76b85af nit: ui background terminals (#8255) 2025-12-18 10:34:10 +00:00
xl-openai
5c8d22138a Reintroduce feature flags for skills. (#8244)
1. Reintroduce feature flags for skills;
2. UI tweaks (truncate descriptions, better validation error display).
2025-12-18 01:14:11 -08:00
Ethan Phillips
e1deeefa0f Change "Team" to "Buisness" and add Education (#8221)
This pull request updates the ChatGPT login description in the
onboarding authentication widgets to clarify which plans include usage.
The description now lists "Business" rather than "Team" and adds
"Education" plans in addition to the previously mentioned plans.

I have read the CLA Document and I hereby sign the CLAs.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-18 08:35:22 +00:00
Michael Bolin
580c59aa9a fix: introduce ConfigBuilder (#8235)
Introduce `ConfigBuilder` as an alternative to our existing `Config`
constructors.

I noticed that the existing constructors,
`Config::load_with_cli_overrides()` and
`Config::load_with_cli_overrides_and_harness_overrides()`, did not take
`codex_home` as a parameter, which can be a problem.

Historically, when Codex was purely a CLI, we wanted to be extra sure
that the creation of `codex_home` was always done via
`find_codex_home()`, so we did not expose `codex_home` as a parameter
when creating `Config` in business logic. But in integration tests,
`codex_home` nearly always needs to be configured (as a temp directory),
which is why callers would have to go through
`Config::load_from_base_config_with_overrides()` instead.

Now that the Codex harness also functions as an app server, which could
conceivably load multiple threads where `codex_home` is parameterized
differently in each one, I think it makes sense to make this
configurable. Going to a builder pattern makes it more flexible to
ensure an arbitrary permutation of options can be set when constructing
a `Config` while using the appropriate defaults for the options that
aren't set explicitly.

Ultimately, I think this should make it possible for us to make
`Config::load_from_base_config_with_overrides()` private because all
integration tests should be able to leverage `ConfigBuilder` instead.
Though there could be edge cases, so I'll pursue that migration after we
get through the current config overhaul.






---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/8235).
* #8237
* __->__ #8235
2025-12-17 23:45:09 -08:00
Gav Verma
50dafbc31b Make loading malformed skills fail-open (#8243)
Instead of failing to start Codex, clearly call out that N skills did
not load and provide warnings so that the user may fix them.

<img width="3548" height="874" alt="image"
src="https://github.com/user-attachments/assets/6ce041b2-1373-4007-a6dd-0194e58fafe4"
/>
2025-12-17 23:41:04 -08:00
xl-openai
da3869eeb6 Support SYSTEM skills. (#8220)
1. Remove PUBLIC skills and introduce SYSTEM skills embedded in the
binary and installed into $CODEX_HOME/skills/.system at startup.
2. Skills are now always enabled (feature flag removed).
3. Update skills/list to accept forceReload and plumb it through (not
used by clients yet).
2025-12-17 18:48:28 -08:00
Ahmed Ibrahim
6f102e18c4 Show migration link (#8228)
# 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-18 02:03:40 +00: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
208 changed files with 10308 additions and 3513 deletions

View File

@@ -0,0 +1,246 @@
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
sign-binaries:
description: Whether to sign and notarize the macOS binaries.
required: false
default: "true"
sign-dmg:
description: Whether to sign and notarize the macOS dmg.
required: false
default: "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
if: ${{ inputs.sign-binaries == 'true' }}
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
if: ${{ inputs.sign-binaries == 'true' }}
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
source "$GITHUB_ACTION_PATH/notary_helpers.sh"
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"
notarize_submission "$binary" "$archive_path" "$notary_key_path"
}
notarize_binary "codex"
notarize_binary "codex-responses-api-proxy"
- name: Sign and notarize macOS dmg
if: ${{ inputs.sign-dmg == 'true' }}
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_CODESIGN_IDENTITY APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do
if [[ -z "${!var:-}" ]]; then
echo "$var is required"
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
source "$GITHUB_ACTION_PATH/notary_helpers.sh"
dmg_path="codex-rs/target/${{ inputs.target }}/release/codex-${{ inputs.target }}.dmg"
if [[ ! -f "$dmg_path" ]]; then
echo "dmg $dmg_path not found"
exit 1
fi
keychain_args=()
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
fi
codesign --force --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$dmg_path"
notarize_submission "codex-${{ inputs.target }}.dmg" "$dmg_path" "$notary_key_path"
xcrun stapler staple "$dmg_path"
- 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

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
notarize_submission() {
local label="$1"
local path="$2"
local notary_key_path="$3"
if [[ -z "${APPLE_NOTARIZATION_KEY_ID:-}" || -z "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
echo "APPLE_NOTARIZATION_KEY_ID and APPLE_NOTARIZATION_ISSUER_ID are required for notarization"
exit 1
fi
if [[ -z "$notary_key_path" || ! -f "$notary_key_path" ]]; then
echo "Notary key file $notary_key_path not found"
exit 1
fi
if [[ ! -f "$path" ]]; then
echo "Notarization payload $path not found"
exit 1
fi
local submission_json
submission_json=$(xcrun notarytool submit "$path" \
--key "$notary_key_path" \
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
--output-format json \
--wait)
local status submission_id
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 $label"
exit 1
fi
echo "::notice title=Notarization::$label submission ${submission_id} completed with status ${status}"
if [[ "$status" != "Accepted" ]]; then
echo "Notarization failed for ${label} (submission ${submission_id}, status ${status})"
exit 1
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
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

@@ -128,174 +128,77 @@ jobs:
account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
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: ${{ runner.os == 'macOS' }}
name: MacOS code signing (binaries)
uses: ./.github/actions/macos-code-sign
with:
target: ${{ matrix.target }}
sign-binaries: "true"
sign-dmg: "false"
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 }}
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
- if: ${{ runner.os == 'macOS' }}
name: Build macOS dmg
shell: bash
run: |
set -euo pipefail
if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then
echo "APPLE_CODESIGN_IDENTITY is required for macOS signing"
target="${{ matrix.target }}"
release_dir="target/${target}/release"
dmg_root="${RUNNER_TEMP}/codex-dmg-root"
volname="Codex (${target})"
dmg_path="${release_dir}/codex-${target}.dmg"
# The previous "MacOS code signing (binaries)" step signs + notarizes the
# built artifacts in `${release_dir}`. This step packages *those same*
# signed binaries into a dmg.
codex_binary_path="${release_dir}/codex"
proxy_binary_path="${release_dir}/codex-responses-api-proxy"
rm -rf "$dmg_root"
mkdir -p "$dmg_root"
if [[ ! -f "$codex_binary_path" ]]; then
echo "Binary $codex_binary_path not found"
exit 1
fi
if [[ ! -f "$proxy_binary_path" ]]; then
echo "Binary $proxy_binary_path not found"
exit 1
fi
keychain_args=()
if [[ -n "${APPLE_CODESIGN_KEYCHAIN:-}" && -f "${APPLE_CODESIGN_KEYCHAIN}" ]]; then
keychain_args+=(--keychain "${APPLE_CODESIGN_KEYCHAIN}")
ditto "$codex_binary_path" "${dmg_root}/codex"
ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy"
rm -f "$dmg_path"
hdiutil create \
-volname "$volname" \
-srcfolder "$dmg_root" \
-format UDZO \
-ov \
"$dmg_path"
if [[ ! -f "$dmg_path" ]]; then
echo "dmg $dmg_path not found after build"
exit 1
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"
- if: ${{ runner.os == 'macOS' }}
name: MacOS code signing (dmg)
uses: ./.github/actions/macos-code-sign
with:
target: ${{ matrix.target }}
sign-binaries: "false"
sign-dmg: "true"
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
@@ -318,6 +221,10 @@ jobs:
cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore"
fi
if [[ "${{ matrix.target }}" == *apple-darwin ]]; then
cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg"
fi
- if: ${{ matrix.runner == 'windows-11-arm' }}
name: Install zstd
shell: powershell
@@ -352,7 +259,7 @@ jobs:
base="$(basename "$f")"
# Skip files that are already archives (shouldn't happen, but be
# safe).
if [[ "$base" == *.tar.gz || "$base" == *.zip ]]; then
if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then
continue
fi
@@ -380,29 +287,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 +371,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 +422,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()

29
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",
@@ -1290,6 +1291,7 @@ dependencies = [
"futures",
"http 1.3.1",
"image",
"include_dir",
"indexmap 2.12.0",
"keyring",
"landlock",
@@ -1701,7 +1703,6 @@ dependencies = [
"anyhow",
"arboard",
"assert_matches",
"async-stream",
"base64",
"chrono",
"clap",
@@ -3366,7 +3367,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.1",
"system-configuration",
"tokio",
"tower-service",
@@ -3589,6 +3590,25 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indenter"
version = "0.3.3"
@@ -5152,7 +5172,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.1",
"thiserror 2.0.17",
"tokio",
"tracing",
@@ -5189,7 +5209,7 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.1",
"tracing",
"windows-sys 0.60.2",
]
@@ -6910,6 +6930,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]

View File

@@ -49,7 +49,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.0.0"
version = "0.76.0-alpha.2"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024
@@ -141,6 +141,7 @@ icu_locale_core = "2.1"
icu_provider = { version = "2.1", features = ["sync"] }
ignore = "0.4.23"
image = { version = "^0.25.9", default-features = false }
include_dir = "0.7.4"
indexmap = "2.12.0"
insta = "1.44.3"
itertools = "0.14.0"

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,72 @@ 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 {
/// A settings from a layer with a higher precedence will override a setting
/// from a layer with a lower precedence.
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,
}
}
}
/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from
/// layer `A` will be overridden by settings from layer `B`.
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 +346,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 +354,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 +391,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 +404,7 @@ pub enum ConfigWriteErrorCode {
ConfigValidationError,
ConfigPathNotFound,
ConfigSchemaUnknownKey,
UserLayerNotFound,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -761,7 +818,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 +828,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 +839,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 +917,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)]
@@ -965,6 +1028,10 @@ pub struct SkillsListParams {
/// When empty, defaults to the current session working directory.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cwds: Vec<PathBuf>,
/// When true, bypass the skills cache and re-scan skills from disk.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub force_reload: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -981,6 +1048,7 @@ pub struct SkillsListResponse {
pub enum SkillScope {
User,
Repo,
System,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1026,6 +1094,7 @@ impl From<CoreSkillScope> for SkillScope {
match value {
CoreSkillScope::User => Self::User,
CoreSkillScope::Repo => Self::Repo,
CoreSkillScope::System => Self::System,
}
}
}
@@ -1581,6 +1650,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")]
@@ -1913,6 +1991,30 @@ mod tests {
);
}
#[test]
fn skills_list_params_serialization_uses_force_reload() {
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: Vec::new(),
force_reload: false,
})
.unwrap(),
json!({}),
);
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: vec![PathBuf::from("/repo")],
force_reload: true,
})
.unwrap(),
json!({
"cwds": ["/repo"],
"forceReload": true,
}),
);
}
#[test]
fn codex_error_info_serializes_http_status_code_in_camel_case() {
let value = CodexErrorInfo::ResponseTooManyFailedAttempts {

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.
@@ -65,9 +77,9 @@ Example (from OpenAI's official VSCode extension):
- `review/start` — kick off Codexs automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `model/list` — list available models (with reasoning effort options).
- `skills/list` — list skills for one or more `cwd` values.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `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(
@@ -2605,36 +2640,27 @@ impl CodexMessageProcessor {
}
async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) {
let SkillsListParams { cwds } = params;
let SkillsListParams { cwds, force_reload } = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.clone()]
} else {
cwds
};
let data = if self.config.features.enabled(Feature::Skills) {
let skills_manager = self.conversation_manager.skills_manager();
cwds.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd(&cwd);
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills);
codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
}
})
.collect()
} else {
cwds.into_iter()
.map(|cwd| codex_app_server_protocol::SkillsListEntry {
let skills_manager = self.conversation_manager.skills_manager();
let data = cwds
.into_iter()
.map(|cwd| {
let outcome = skills_manager.skills_for_cwd_with_options(&cwd, force_reload);
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills);
codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: Vec::new(),
})
.collect()
};
skills,
errors,
}
})
.collect();
self.outgoing
.send_response(request_id, SkillsListResponse { data })
.await;
@@ -3313,7 +3339,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 +3347,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

@@ -47,75 +47,6 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
} = to_response::<ModelListResponse>(response)?;
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(),
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: 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(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.1-codex-mini".to_string(),
model: "gpt-5.1-codex-mini".to_string(),
display_name: "gpt-5.1-codex-mini".to_string(),
description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(),
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
},
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
Model {
id: "gpt-5.2".to_string(),
model: "gpt-5.2".to_string(),
@@ -138,7 +69,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
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 {
@@ -147,25 +78,17 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
],
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
is_default: true,
},
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-mini".to_string(),
model: "gpt-5.1-codex-mini".to_string(),
display_name: "gpt-5.1-codex-mini".to_string(),
description: "Optimized for codex. Cheaper, faster, but less capable.".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(),
},
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,
@@ -176,6 +99,60 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
default_reasoning_effort: ReasoningEffort::Medium,
is_default: false,
},
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: "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,
},
Model {
id: "gpt-5.2-codex".to_string(),
model: "gpt-5.2-codex".to_string(),
display_name: "gpt-5.2-codex".to_string(),
description: "Latest frontier agentic coding model.".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,
},
];
assert_eq!(items, expected_models);
@@ -210,7 +187,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.2");
let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?;
let second_request = mcp
@@ -232,7 +209,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.1-codex-mini");
let third_cursor = second_cursor.ok_or_else(|| anyhow!("cursor for third page"))?;
let third_request = mcp
@@ -254,7 +231,7 @@ async fn list_models_pagination_works() -> Result<()> {
} = to_response::<ModelListResponse>(third_response)?;
assert_eq!(third_items.len(), 1);
assert_eq!(third_items[0].id, "gpt-5.1-codex-mini");
assert_eq!(third_items[0].id, "gpt-5.1-codex-max");
let fourth_cursor = third_cursor.ok_or_else(|| anyhow!("cursor for fourth page"))?;
let fourth_request = mcp
@@ -276,30 +253,8 @@ 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");
let fifth_cursor = fourth_cursor.ok_or_else(|| anyhow!("cursor for fifth page"))?;
let fifth_request = mcp
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(fifth_cursor.clone()),
})
.await?;
let fifth_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fifth_request)),
)
.await??;
let ModelListResponse {
data: fifth_items,
next_cursor: fifth_cursor,
} = 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!(fourth_items[0].id, "gpt-5.2-codex");
assert!(fourth_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

@@ -37,6 +37,9 @@ unicode-width = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
supports-color = { workspace = true }
[dependencies.async-trait]
workspace = true
[dev-dependencies]
async-trait = { workspace = true }
pretty_assertions = { workspace = true }

View File

@@ -34,10 +34,6 @@ pub struct ExecCommand {
#[arg(long = "env", value_name = "ENV_ID")]
pub environment: String,
/// Git branch to run in Codex Cloud.
#[arg(long = "branch", value_name = "BRANCH", default_value = "main")]
pub branch: String,
/// Number of assistant attempts (best-of-N).
#[arg(
long = "attempts",
@@ -45,6 +41,10 @@ pub struct ExecCommand {
value_parser = parse_attempts
)]
pub attempts: usize,
/// Git branch to run in Codex Cloud (defaults to current branch).
#[arg(long = "branch", value_name = "BRANCH")]
pub branch: Option<String>,
}
fn parse_attempts(input: &str) -> Result<usize, String> {

View File

@@ -104,6 +104,54 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
})
}
#[async_trait::async_trait]
trait GitInfoProvider {
async fn default_branch_name(&self, path: &std::path::Path) -> Option<String>;
async fn current_branch_name(&self, path: &std::path::Path) -> Option<String>;
}
struct RealGitInfo;
#[async_trait::async_trait]
impl GitInfoProvider for RealGitInfo {
async fn default_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::default_branch_name(path).await
}
async fn current_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::current_branch_name(path).await
}
}
async fn resolve_git_ref(branch_override: Option<&String>) -> String {
resolve_git_ref_with_git_info(branch_override, &RealGitInfo).await
}
async fn resolve_git_ref_with_git_info(
branch_override: Option<&String>,
git_info: &impl GitInfoProvider,
) -> String {
if let Some(branch) = branch_override {
let branch = branch.trim();
if !branch.is_empty() {
return branch.to_string();
}
}
if let Ok(cwd) = std::env::current_dir() {
if let Some(branch) = git_info.current_branch_name(&cwd).await {
branch
} else if let Some(branch) = git_info.default_branch_name(&cwd).await {
branch
} else {
"main".to_string()
}
} else {
"main".to_string()
}
}
async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
let crate::cli::ExecCommand {
query,
@@ -114,11 +162,12 @@ async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_exec").await?;
let prompt = resolve_query_input(query)?;
let env_id = resolve_environment_id(&ctx, &environment).await?;
let git_ref = resolve_git_ref(branch.as_ref()).await;
let created = codex_cloud_tasks_client::CloudBackend::create_task(
&*ctx.backend,
&env_id,
&prompt,
&branch,
&git_ref,
false,
attempts,
)
@@ -1362,17 +1411,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
let backend = Arc::clone(&backend);
let best_of_n = page.best_of_n;
tokio::spawn(async move {
let git_ref = if let Ok(cwd) = std::env::current_dir() {
if let Some(branch) = codex_core::git_info::default_branch_name(&cwd).await {
branch
} else if let Some(branch) = codex_core::git_info::current_branch_name(&cwd).await {
branch
} else {
"main".to_string()
}
} else {
"main".to_string()
};
let git_ref = resolve_git_ref(None).await;
let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, &git_ref, false, best_of_n).await;
let evt = match result {
@@ -1991,6 +2030,7 @@ fn pretty_lines_from_error(raw: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve_git_ref_with_git_info;
use codex_cloud_tasks_client::DiffSummary;
use codex_cloud_tasks_client::MockClient;
use codex_cloud_tasks_client::TaskId;
@@ -2005,6 +2045,85 @@ mod tests {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
struct StubGitInfo {
default_branch: Option<String>,
current_branch: Option<String>,
}
impl StubGitInfo {
fn new(default_branch: Option<String>, current_branch: Option<String>) -> Self {
Self {
default_branch,
current_branch,
}
}
}
#[async_trait::async_trait]
impl super::GitInfoProvider for StubGitInfo {
async fn default_branch_name(&self, _path: &std::path::Path) -> Option<String> {
self.default_branch.clone()
}
async fn current_branch_name(&self, _path: &std::path::Path) -> Option<String> {
self.current_branch.clone()
}
}
#[tokio::test]
async fn branch_override_is_used_when_provided() {
let git_ref = resolve_git_ref_with_git_info(
Some(&"feature/override".to_string()),
&StubGitInfo::new(None, None),
)
.await;
assert_eq!(git_ref, "feature/override");
}
#[tokio::test]
async fn trims_override_whitespace() {
let git_ref = resolve_git_ref_with_git_info(
Some(&" feature/spaces ".to_string()),
&StubGitInfo::new(None, None),
)
.await;
assert_eq!(git_ref, "feature/spaces");
}
#[tokio::test]
async fn prefers_current_branch_when_available() {
let git_ref = resolve_git_ref_with_git_info(
None,
&StubGitInfo::new(
Some("default-main".to_string()),
Some("feature/current".to_string()),
),
)
.await;
assert_eq!(git_ref, "feature/current");
}
#[tokio::test]
async fn falls_back_to_current_branch_when_default_is_missing() {
let git_ref = resolve_git_ref_with_git_info(
None,
&StubGitInfo::new(None, Some("develop".to_string())),
)
.await;
assert_eq!(git_ref, "develop");
}
#[tokio::test]
async fn falls_back_to_main_when_no_git_info_is_available() {
let git_ref = resolve_git_ref_with_git_info(None, &StubGitInfo::new(None, None)).await;
assert_eq!(git_ref, "main");
}
#[test]
fn format_task_status_lines_with_diff_and_label() {
let now = Utc::now();

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 {

View File

@@ -43,6 +43,7 @@ env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
include_dir = { workspace = true }
indexmap = { workspace = true }
keyring = { workspace = true, features = ["crypto-rust"] }
libc = { workspace = true }

View File

@@ -0,0 +1,117 @@
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Codex CLI harness, sandboxing, and approvals
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
- **read-only**: The sandbox only permits reading files.
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
- **restricted**: Requires approval
- **enabled**: No approval needed
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
- (for all of these, you should weigh alternative paths that do not require approval)
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
When requesting approval to execute a command that will require escalated privileges:
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5

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;
@@ -215,11 +219,10 @@ impl Codex {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let loaded_skills = if config.features.enabled(Feature::Skills) {
Some(skills_manager.skills_for_cwd(&config.cwd))
} else {
None
};
let loaded_skills = config
.features
.enabled(Feature::Skills)
.then(|| skills_manager.skills_for_cwd(&config.cwd));
if let Some(outcome) = &loaded_skills {
for err in &outcome.errors {
@@ -260,7 +263,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 +414,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 +437,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 +449,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 +457,7 @@ impl SessionConfiguration {
if let Some(cwd) = updates.cwd.clone() {
next_configuration.cwd = cwd;
}
next_configuration
Ok(next_configuration)
}
}
@@ -523,7 +526,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 +643,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 +693,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,
@@ -762,7 +765,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 +824,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 +929,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 +1592,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 +1611,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 +1647,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 +1729,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 +1778,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 +1808,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 +1962,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 +1978,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 +2005,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 +2065,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,20 +2226,26 @@ 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(),
});
sess.send_event(&turn_context, event).await;
let skills_outcome = if sess.enabled(Feature::Skills) {
Some(
sess.services
.skills_manager
.skills_for_cwd(&turn_context.cwd),
)
} else {
None
};
let skills_outcome = sess.enabled(Feature::Skills).then(|| {
sess.services
.skills_manager
.skills_for_cwd(&turn_context.cwd)
});
let SkillInjections {
items: skill_items,
@@ -2232,25 +2314,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 +2361,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 +2509,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 +2538,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 +2554,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 +2566,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 +2851,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 +2923,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 +3127,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 +3218,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,233 @@
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 fn empty_field(field_name: impl Into<String>) -> Self {
Self {
message: format!("field `{}` cannot be empty", field_name.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

@@ -7,10 +7,11 @@ 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,7 +20,9 @@ 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::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
@@ -145,7 +148,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 +202,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 +222,13 @@ impl ConfigService {
.load_layers_state()
.await
.map_err(|err| ConfigServiceError::io("failed to load configuration", err))?;
let user_layer = match layers.get_user_layer() {
Some(layer) => Cow::Borrowed(layer),
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
};
if let Some(expected) = expected_version.as_deref()
&& expected != layers.user.version
&& expected != user_layer.version
{
return Err(ConfigServiceError::write(
ConfigWriteErrorCode::ConfigVersionConflict,
@@ -220,7 +236,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 +288,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 +311,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,
})
}
@@ -319,6 +338,32 @@ impl ConfigService {
}
}
async fn create_empty_user_layer(
config_toml: &AbsolutePathBuf,
) -> Result<ConfigLayerEntry, ConfigServiceError> {
let toml_value = match tokio::fs::read_to_string(config_toml).await {
Ok(contents) => toml::from_str(&contents).map_err(|e| {
ConfigServiceError::toml("failed to parse existing user config.toml", e)
})?,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
tokio::fs::write(config_toml, "").await.map_err(|e| {
ConfigServiceError::io("failed to create empty user config.toml", e)
})?;
TomlValue::Table(toml::map::Map::new())
} else {
return Err(ConfigServiceError::io("failed to read user config.toml", e));
}
}
};
Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
},
toml_value,
))
}
fn parse_value(value: JsonValue) -> Result<Option<TomlValue>, String> {
if value.is_null() {
return Ok(None);
@@ -469,14 +514,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(expected: impl AsRef<Path>, provided: impl AsRef<Path>) -> 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 +543,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 +572,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 +586,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 +615,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 +629,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 +731,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 +764,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 +791,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 +830,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 +841,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 +899,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 +929,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 +946,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 +962,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 +985,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 +1010,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,47 @@
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 allowed_approval_policies: 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.allowed_approval_policies {
Some(policies) => {
let default_value = AskForApproval::default();
if policies.contains(&default_value) {
Constrained::allow_values(default_value, policies)?
} else if let Some(first) = policies.first() {
Constrained::allow_values(*first, policies)?
} else {
return Err(ConstraintError::empty_field("allowed_approval_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,
path: impl AsRef<Path>,
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,176 @@ 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).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing user config file {}: {e}",
user_file.as_path().display(),
),
)
})?;
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. For fields in
// `managed_config.toml` that do not have an equivalent in
// `ConfigRequirements`, 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,42 @@ 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;
for (index, layer) in layers.iter().enumerate() {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
if user_layer_index.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"multiple user config layers found",
));
}
user_layer_index = Some(index);
}
}
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,41 @@ pub(crate) use legacy::LegacyFeatureToggles;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
Experimental,
Beta,
Beta {
name: &'static str,
menu_description: &'static str,
announcement: &'static str,
},
Stable,
Deprecated,
Removed,
}
impl Stage {
pub fn beta_menu_name(self) -> Option<&'static str> {
match self {
Stage::Beta { name, .. } => Some(name),
_ => None,
}
}
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 {
@@ -58,12 +87,12 @@ pub enum Feature {
RemoteModels,
/// Allow model to call multiple tools in parallel (only for models supporting it).
ParallelToolCalls,
/// Experimental skills injection (CLI flag-driven).
Skills,
/// Experimental shell snapshotting.
ShellSnapshot,
/// Experimental TUI v2 (viewport) implementation.
Tui2,
/// Enable discovery and injection of skills.
Skills,
}
impl Feature {
@@ -292,13 +321,34 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
// Unstable features.
FeatureSpec {
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::UnifiedExec,
key: "unified_exec",
stage: Stage::Experimental,
stage: Stage::Beta {
name: "Background terminal",
menu_description: "Run long-running terminal commands in the background.",
announcement: "NEW! Try Background terminals for long running processes. Enable in /experimental!",
},
default_enabled: false,
},
FeatureSpec {
id: Feature::ShellSnapshot,
key: "shell_snapshot",
stage: Stage::Beta {
name: "Shell snapshot",
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!",
},
default_enabled: false,
},
// Unstable features.
FeatureSpec {
id: Feature::RmcpClient,
key: "rmcp_client",
@@ -308,13 +358,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 {

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

@@ -16,6 +16,7 @@ const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md
const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md");
const GPT_5_2_INSTRUCTIONS: &str = include_str!("../../gpt_5_2_prompt.md");
const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md");
const GPT_5_2_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt-5.2-codex_prompt.md");
pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000;
/// A model family is a group of models that share certain characteristics.
@@ -270,7 +271,7 @@ pub(super) fn find_family_for_model(slug: &str) -> ModelFamily {
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(),
base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
shell_type: ConfigShellToolType::ShellCommand,
supports_parallel_tool_calls: true,
@@ -294,6 +295,20 @@ pub(super) fn find_family_for_model(slug: &str) -> ModelFamily {
)
// Production models.
} else if slug.starts_with("gpt-5.2-codex") {
// Same as gpt-5.1-codex-max.
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
base_instructions: GPT_5_2_CODEX_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

@@ -12,10 +12,10 @@ pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str =
static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
vec![
ModelPreset {
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.2-codex".to_string(),
model: "gpt-5.2-codex".to_string(),
display_name: "gpt-5.2-codex".to_string(),
description: "Latest frontier agentic coding model.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
ReasoningEffortPreset {
@@ -38,35 +38,36 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
is_default: true,
upgrade: None,
show_in_picker: true,
supported_in_api: false,
},
ModelPreset {
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.1-codex-max".to_string(),
model: "gpt-5.1-codex-max".to_string(),
display_name: "gpt-5.1-codex-max".to_string(),
description: "Codex-optimized flagship for deep and fast reasoning.".to_string(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning".to_string(),
description: "Fast responses with lighter reasoning".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
description: "Balances speed and reasoning depth for everyday tasks".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
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: 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: Some(gpt_52_codex_upgrade()),
show_in_picker: true,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.1-codex-mini".to_string(),
@@ -86,12 +87,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: Some(gpt_52_codex_upgrade()),
show_in_picker: true,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5.2".to_string(),
@@ -110,7 +108,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,36 +116,9 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
],
is_default: false,
upgrade: None,
show_in_picker: true,
},
ModelPreset {
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(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
],
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: Some(gpt_52_codex_upgrade()),
show_in_picker: true,
supported_in_api: true,
},
// Deprecated models.
ModelPreset {
@@ -171,12 +142,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: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5-codex-mini".to_string(),
@@ -195,12 +163,35 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
},
],
is_default: false,
upgrade: Some(ModelUpgrade {
id: "gpt-5.1-codex-mini".to_string(),
reasoning_effort_mapping: None,
migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(),
}),
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
},
ModelPreset {
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(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Fastest responses with limited reasoning".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Dynamically adjusts reasoning based on the task".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems"
.to_string(),
},
],
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
},
ModelPreset {
id: "gpt-5".to_string(),
@@ -227,16 +218,51 @@ 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: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
},
ModelPreset {
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(),
default_reasoning_effort: ReasoningEffort::Medium,
supported_reasoning_efforts: vec![
ReasoningEffortPreset {
effort: ReasoningEffort::Low,
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::Medium,
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(),
},
ReasoningEffortPreset {
effort: ReasoningEffort::High,
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
],
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
supported_in_api: true,
},
]
});
fn gpt_52_codex_upgrade() -> ModelUpgrade {
ModelUpgrade {
id: "gpt-5.2-codex".to_string(),
reasoning_effort_mapping: None,
migration_config_key: "gpt-5.2-codex".to_string(),
model_link: Some("https://openai.com/index/introducing-gpt-5-2-codex".to_string()),
upgrade_copy: Some(
"Codex is now powered by gpt-5.2-codex, our latest frontier agentic coding model. It is smarter and faster than its predecessors and capable of long-running project-scale work."
.to_string(),
),
}
}
pub(super) fn builtin_model_presets(_auth_mode: Option<AuthMode>) -> Vec<ModelPreset> {
PRESETS
.iter()

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 = "gpt-5.2-codex";
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(path: impl AsRef<Path>) -> 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

@@ -14,7 +14,6 @@
//! 3. We do **not** walk past the Git root.
use crate::config::Config;
use crate::features::Feature;
use crate::skills::SkillMetadata;
use crate::skills::render_skills_section;
use dunce::canonicalize as normalize_path;
@@ -37,11 +36,7 @@ pub(crate) async fn get_user_instructions(
config: &Config,
skills: Option<&[SkillMetadata]>,
) -> Option<String> {
let skills_section = if config.features.enabled(Feature::Skills) {
skills.and_then(render_skills_section)
} else {
None
};
let skills_section = skills.and_then(render_skills_section);
let project_docs = match read_project_docs(config).await {
Ok(docs) => docs,
@@ -260,7 +255,6 @@ mod tests {
config.cwd = root.path().to_path_buf();
config.project_doc_max_bytes = limit;
config.features.enable(Feature::Skills);
config.user_instructions = instructions.map(ToOwned::to_owned);
config
@@ -522,7 +516,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 +540,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

@@ -0,0 +1,169 @@
---
name: plan
description: Plan lifecycle management for Codex plans stored in $CODEX_HOME/plans (default ~/.codex/plans). Use when a user asks to create, find, read, update, delete, or manage plan documents for implementation work or overview/reference documentation.
---
# Plan
## Overview
Create and manage plan documents on disk. Plans stored on disk are markdown files with YAML frontmatter and free-form content. When drafting in chat, output only the plan body without frontmatter; add frontmatter only when stashing to disk. Support both implementation plans and overview/reference plans. Only write to the plans folder; do not modify the repository codebase.
## Core rules
- Resolve the plans directory as `$CODEX_HOME/plans` or `~/.codex/plans` when `CODEX_HOME` is not set.
- Create the plans directory if it does not exist.
- Never write to the repo; only read files to understand context.
- Require frontmatter with **only** `name` and `description` (single-line values) for on-disk plans.
- When presenting a draft plan in chat, omit frontmatter and start at `# Plan`.
- Enforce naming rules: short, lower-case, hyphen-delimited; filename must equal `<name>.md`.
- If a plan is not found, state it clearly and offer to create one.
- Allow overview-style plans that document flows, architecture, or context without a work checklist.
## Decide the task
1. **Find/list**: discover plans by frontmatter summary; confirm if multiple matches exist.
2. **Read/use**: validate frontmatter; present summary and full contents.
3. **Create**: inspect repo read-only; choose plan style (implementation vs overview); draft plan; write to plans directory only.
4. **Update**: load plan; revise content and/or description; preserve frontmatter keys; overwrite the plan file.
5. **Delete**: confirm intent, then remove the plan file if asked.
## Plan discovery
- Prefer `scripts/list_plans.py` for quick summaries.
- Use `scripts/read_plan_frontmatter.py` to validate a specific plan.
- If name mismatches filename or frontmatter is missing fields, call it out and ask whether to fix.
## Plan creation workflow
1. Read relevant docs and entry points (`README.md`, `docs/`, key modules) to scope requirements.
2. Identify scope, constraints, and data model/API implications (or capture existing behavior for an overview).
3. Draft either an ordered implementation plan or a structured overview plan with diagrams/notes as needed.
4. Immediately output the plan body only (no frontmatter), then ask the user if they want to 1. Make changes, 2. Implement it, 3. Stash it as per plan.
5. If the user wants to stash it, prepend frontmatter and save the plan under the computed plans directory using `scripts/create_plan.py`.
## Plan update workflow
- Re-read the plan and related code/docs before updating.
- Keep the plan name stable unless the user explicitly wants a rename.
- If renaming, update both frontmatter `name` and filename together.
## Scripts (low-freedom helpers)
Create a plan file (body only; frontmatter is written for you). Run from the plan skill directory:
```bash
python ./scripts/create_plan.py \
--name codex-rate-limit-overview \
--description "Scope and update plan for Codex rate limiting" \
--body-file /tmp/plan-body.md
```
Read frontmatter summary for a plan (run from the plan skill directory):
```bash
python ./scripts/read_plan_frontmatter.py ~/.codex/plans/codex-rate-limit-overview.md
```
List plan summaries (optional filter; run from the plan skill directory):
```bash
python ./scripts/list_plans.py --query "rate limit"
```
## Plan file format
Use one of the structures below for the plan body. When drafting, output only the body (no frontmatter). When stashing, prepend this frontmatter:
```markdown
---
name: <plan-name>
description: <1-line summary>
---
```
### Implementation plan body template
```markdown
# Plan
<1-3 sentences: intent, scope, and approach.>
## Requirements
- <Requirement 1>
- <Requirement 2>
## Scope
- In:
- Out:
## Files and entry points
- <File/module/entry point 1>
- <File/module/entry point 2>
## Data model / API changes
- <If applicable, describe schema or contract changes>
## Action items
[ ] <Step 1>
[ ] <Step 2>
[ ] <Step 3>
[ ] <Step 4>
[ ] <Step 5>
[ ] <Step 6>
## Testing and validation
- <Tests, commands, or validation steps>
## Risks and edge cases
- <Risk 1>
- <Risk 2>
## Open questions
- <Question 1>
- <Question 2>
```
### Overview plan body template
```markdown
# Plan
<1-3 sentences: intent and scope of the overview.>
## Overview
<Describe the system, flow, or architecture at a high level.>
## Diagrams
<Include text or Mermaid diagrams if helpful.>
## Key file references
- <File/module/entry point 1>
- <File/module/entry point 2>
## Auth / routing / behavior notes
- <Capture relevant differences (e.g., auth modes, routing paths).>
## Current status
- <What is live today vs pending work, if known.>
## Action items
- None (overview only).
## Testing and validation
- None (overview only).
## Risks and edge cases
- None (overview only).
## Open questions
- None.
```
## Writing guidance
- Keep action items ordered and concrete; include file/entry-point hints.
- For overview plans, keep action items minimal and set sections to "None" when not applicable.
- Always include testing/validation and risks/edge cases in implementation plans.
- Use open questions only when necessary (max 3).
- If a section is not applicable, note "None" briefly rather than removing it.

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Create or overwrite a plan markdown file in $CODEX_HOME/plans."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from plan_utils import get_plans_dir, validate_plan_name
DEFAULT_TEMPLATE = """# Plan
<1-3 sentences: intent, scope, and approach.>
## Requirements
- <Requirement 1>
- <Requirement 2>
## Scope
- In:
- Out:
## Files and entry points
- <File/module/entry point 1>
- <File/module/entry point 2>
## Data model / API changes
- <If applicable, describe schema or contract changes>
## Action items
[ ] <Step 1>
[ ] <Step 2>
[ ] <Step 3>
[ ] <Step 4>
[ ] <Step 5>
[ ] <Step 6>
## Testing and validation
- <Tests, commands, or validation steps>
## Risks and edge cases
- <Risk 1>
- <Risk 2>
## Open questions
- <Question 1>
- <Question 2>
"""
def read_body(args: argparse.Namespace) -> str | None:
if args.template:
return DEFAULT_TEMPLATE
if args.body_file:
return Path(args.body_file).read_text(encoding="utf-8")
if not sys.stdin.isatty():
return sys.stdin.read()
return None
def main() -> int:
parser = argparse.ArgumentParser(
description="Create a plan file under $CODEX_HOME/plans or ~/.codex/plans."
)
parser.add_argument("--name", required=True, help="Plan name (lower-case, hyphen-delimited).")
parser.add_argument("--description", required=True, help="Short plan description.")
parser.add_argument(
"--body-file",
help="Path to markdown body (without frontmatter). If omitted, read from stdin.",
)
parser.add_argument(
"--template",
action="store_true",
help="Write a template body instead of reading from stdin or --body-file.",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite the plan file if it already exists.",
)
args = parser.parse_args()
name = args.name.strip()
description = args.description.strip()
validate_plan_name(name)
if not description or "\n" in description:
raise SystemExit("Description must be a single line.")
body = read_body(args)
if body is None:
raise SystemExit("Provide --body-file, stdin, or --template to supply plan content.")
body = body.strip()
if not body:
raise SystemExit("Plan body cannot be empty.")
if body.lstrip().startswith("---"):
raise SystemExit("Plan body should not include frontmatter.")
plans_dir = get_plans_dir()
plans_dir.mkdir(parents=True, exist_ok=True)
plan_path = plans_dir / f"{name}.md"
if plan_path.exists() and not args.overwrite:
raise SystemExit(f"Plan already exists: {plan_path}. Use --overwrite to replace.")
content = f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"
plan_path.write_text(content, encoding="utf-8")
print(str(plan_path))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""List plan summaries by reading frontmatter only."""
from __future__ import annotations
import argparse
import json
from plan_utils import get_plans_dir, parse_frontmatter
def main() -> int:
parser = argparse.ArgumentParser(description="List plan summaries from $CODEX_HOME/plans.")
parser.add_argument("--query", help="Case-insensitive substring to filter name/description.")
parser.add_argument("--json", action="store_true", help="Emit JSON output.")
args = parser.parse_args()
plans_dir = get_plans_dir()
if not plans_dir.exists():
raise SystemExit(f"Plans directory not found: {plans_dir}")
query = args.query.lower() if args.query else None
items = []
for path in sorted(plans_dir.glob("*.md")):
try:
data = parse_frontmatter(path)
except ValueError:
continue
name = data.get("name")
description = data.get("description")
if not name or not description:
continue
if query:
haystack = f"{name} {description}".lower()
if query not in haystack:
continue
items.append({"name": name, "description": description, "path": str(path)})
if args.json:
print(json.dumps(items))
else:
for item in items:
print(f"{item['name']}\t{item['description']}\t{item['path']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Shared helpers for plan scripts."""
from __future__ import annotations
import os
import re
from pathlib import Path
_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
def get_codex_home() -> Path:
"""Return CODEX_HOME if set, else ~/.codex."""
return Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
def get_plans_dir() -> Path:
return get_codex_home() / "plans"
def validate_plan_name(name: str) -> None:
if not name or not _NAME_RE.match(name):
raise ValueError(
"Invalid plan name. Use short, lower-case, hyphen-delimited names "
"(e.g., codex-rate-limit-overview)."
)
def parse_frontmatter(path: Path) -> dict:
"""Parse YAML frontmatter from a markdown file without reading the body."""
with path.open("r", encoding="utf-8") as handle:
first = handle.readline()
if first.strip() != "---":
raise ValueError("Frontmatter must start with '---'.")
data: dict[str, str] = {}
for line in handle:
stripped = line.strip()
if stripped == "---":
return data
if not stripped or stripped.startswith("#"):
continue
if ":" not in line:
raise ValueError(f"Invalid frontmatter line: {line.rstrip()}")
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if value and len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
data[key] = value
raise ValueError("Frontmatter must end with '---'.")

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Read plan frontmatter without loading the full markdown body."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from plan_utils import parse_frontmatter
def main() -> int:
parser = argparse.ArgumentParser(description="Read name/description from plan frontmatter.")
parser.add_argument("plan_path", help="Path to the plan markdown file.")
parser.add_argument("--json", action="store_true", help="Emit JSON output.")
args = parser.parse_args()
path = Path(args.plan_path).expanduser()
if not path.exists():
raise SystemExit(f"Plan not found: {path}")
data = parse_frontmatter(path)
name = data.get("name")
description = data.get("description")
if not name or not description:
raise SystemExit("Frontmatter must include name and description.")
payload = {"name": name, "description": description, "path": str(path)}
if args.json:
print(json.dumps(payload))
else:
print(f"name: {name}")
print(f"description: {description}")
print(f"path: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,362 @@
---
name: Skill Creator
description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations.
---
# Skill Creator
This skill provides guidance for creating effective skills.
## About Skills
Skills are modular, self-contained packages that extend Codex's capabilities by providing
specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific
domains or tasks—they transform Codex from a general-purpose agent into a specialized agent
equipped with procedural knowledge that no model can fully possess.
### What Skills Provide
1. Specialized workflows - Multi-step procedures for specific domains
2. Tool integrations - Instructions for working with specific file formats or APIs
3. Domain expertise - Company-specific knowledge, schemas, business logic
4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match the level of specificity to the task's fragility and variability:
**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
### Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
#### SKILL.md (required)
Every SKILL.md consists of:
- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used.
- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
#### Bundled Resources (optional)
##### Scripts (`scripts/`)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed
- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks
- **Benefits**: Token efficient, deterministic, may be executed without loading into context
- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments
##### References (`references/`)
Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking.
- **When to include**: For documentation that Codex should reference while working
- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications
- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed
- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md
- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
##### Assets (`assets/`)
Files not intended to be loaded into context, but rather used within the output Codex produces.
- **When to include**: When the skill needs files that will be used in the final output
- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography
- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context
#### What to Not Include in a Skill
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
- README.md
- INSTALLATION_GUIDE.md
- QUICK_REFERENCE.md
- CHANGELOG.md
- etc.
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
### Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
1. **Metadata (name + description)** - Always in context (~100 words)
2. **SKILL.md body** - When skill triggers (<5k words)
3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window)
#### Progressive Disclosure Patterns
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
**Pattern 1: High-level guide with references**
```markdown
# PDF Processing
## Quick start
Extract text with pdfplumber:
[code example]
## Advanced features
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
```
Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
**Pattern 2: Domain-specific organization**
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
```
bigquery-skill/
├── SKILL.md (overview and navigation)
└── reference/
├── finance.md (revenue, billing metrics)
├── sales.md (opportunities, pipeline)
├── product.md (API usage, features)
└── marketing.md (campaigns, attribution)
```
When a user asks about sales metrics, Codex only reads sales.md.
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
```
cloud-deploy/
├── SKILL.md (workflow + provider selection)
└── references/
├── aws.md (AWS deployment patterns)
├── gcp.md (GCP deployment patterns)
└── azure.md (Azure deployment patterns)
```
When the user chooses AWS, Codex only reads aws.md.
**Pattern 3: Conditional details**
Show basic content, link to advanced content:
```markdown
# DOCX Processing
## Creating documents
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
## Editing documents
For simple edits, modify the XML directly.
**For tracked changes**: See [REDLINING.md](REDLINING.md)
**For OOXML details**: See [OOXML.md](OOXML.md)
```
Codex reads REDLINING.md or OOXML.md only when the user needs those features.
**Important guidelines:**
- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing.
## Skill Creation Process
Skill creation involves these steps:
1. Understand the skill with concrete examples
2. Plan reusable skill contents (scripts, references, assets)
3. Initialize the skill (run init_skill.py)
4. Edit the skill (implement resources and write SKILL.md)
5. Package the skill (run package_skill.py)
6. Iterate based on real usage
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
### Skill Naming
- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`).
- Prefer short, verb-led phrases that describe the action.
- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`).
- Name the skill folder exactly after the skill name.
### Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
### Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
1. Considering how to execute on the example from scratch
2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows:
1. Rotating a PDF requires re-writing the same code each time
2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill
Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
1. Writing a frontend webapp requires the same boilerplate HTML/React each time
2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows:
1. Querying BigQuery requires re-discovering the table schemas and relationships each time
2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
### Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
```bash
scripts/init_skill.py <skill-name> --path <output-directory>
```
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates example resource directories: `scripts/`, `references/`, and `assets/`
- Adds example files in each directory that can be customized or deleted
After initialization, customize or remove the generated SKILL.md and example files as needed.
### Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively.
#### Learn Proven Design Patterns
Consult these helpful guides based on your skill's needs:
- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic
- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns
These files contain established best practices for effective skill design.
#### Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
Any example files and directories not needed for the skill should be deleted. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.
#### Update SKILL.md
**Writing Guidelines:** Always use imperative/infinitive form.
##### Frontmatter
Write the YAML frontmatter with `name` and `description`:
- `name`: The skill name
- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill.
- Include both what the Skill does and specific triggers/contexts for when to use it.
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex.
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
Do not include any other fields in YAML frontmatter.
##### Body
Write instructions for using the skill and its bundled resources.
### Step 5: Packaging a Skill
Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
```bash
scripts/package_skill.py <path/to/skill-folder>
```
Optional output directory specification:
```bash
scripts/package_skill.py <path/to/skill-folder> ./dist
```
The packaging script will:
1. **Validate** the skill automatically, checking:
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
- File organization and resource references
2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
### Step 6: Iterate
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
**Iteration workflow:**
1. Use the skill on real tasks
2. Notice struggles or inefficiencies
3. Identify how SKILL.md or bundled resources should be updated
4. Implement changes and test again

View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
Skill Initializer - Creates a new skill from template
Usage:
init_skill.py <skill-name> --path <path>
Examples:
init_skill.py my-new-skill --path skills/public
init_skill.py my-api-helper --path skills/private
init_skill.py custom-skill --path /custom/location
"""
import re
import sys
from pathlib import Path
MAX_SKILL_NAME_LENGTH = 64
SKILL_TEMPLATE = """---
name: {skill_name}
description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
---
# {skill_title}
## Overview
[TODO: 1-2 sentences explaining what this skill enables]
## Structuring This Skill
[TODO: Choose the structure that best fits this skill's purpose. Common patterns:
**1. Workflow-Based** (best for sequential processes)
- Works well when there are clear step-by-step procedures
- Example: DOCX skill with "Workflow Decision Tree""Reading""Creating""Editing"
- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
**2. Task-Based** (best for tool collections)
- Works well when the skill offers different operations/capabilities
- Example: PDF skill with "Quick Start""Merge PDFs""Split PDFs""Extract Text"
- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
**3. Reference/Guidelines** (best for standards or specifications)
- Works well for brand guidelines, coding standards, or requirements
- Example: Brand styling with "Brand Guidelines""Colors""Typography""Features"
- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
**4. Capabilities-Based** (best for integrated systems)
- Works well when the skill provides multiple interrelated features
- Example: Product Management with "Core Capabilities" → numbered capability list
- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
Delete this entire "Structuring This Skill" section when done - it's just guidance.]
## [TODO: Replace with the first main section based on chosen structure]
[TODO: Add content here. See examples in existing skills:
- Code samples for technical skills
- Decision trees for complex workflows
- Concrete examples with realistic user requests
- References to scripts/templates/references as needed]
## Resources
This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
### scripts/
Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
**Examples from other skills:**
- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments.
### references/
Documentation and reference material intended to be loaded into context to inform Codex's process and thinking.
**Examples from other skills:**
- Product management: `communication.md`, `context_building.md` - detailed workflow guides
- BigQuery: API reference documentation and query examples
- Finance: Schema documentation, company policies
**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working.
### assets/
Files not intended to be loaded into context, but rather used within the output Codex produces.
**Examples from other skills:**
- Brand styling: PowerPoint template files (.pptx), logo files
- Frontend builder: HTML/React boilerplate project directories
- Typography: Font files (.ttf, .woff2)
**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
---
**Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
"""
EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
"""
Example helper script for {skill_name}
This is a placeholder script that can be executed directly.
Replace with actual implementation or delete if not needed.
Example real scripts from other skills:
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
"""
def main():
print("This is an example script for {skill_name}")
# TODO: Add actual script logic here
# This could be data processing, file conversion, API calls, etc.
if __name__ == "__main__":
main()
'''
EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
This is a placeholder for detailed reference documentation.
Replace with actual reference content or delete if not needed.
Example real reference docs from other skills:
- product-management/references/communication.md - Comprehensive guide for status updates
- product-management/references/context_building.md - Deep-dive on gathering context
- bigquery/references/ - API references and query examples
## When Reference Docs Are Useful
Reference docs are ideal for:
- Comprehensive API documentation
- Detailed workflow guides
- Complex multi-step processes
- Information too lengthy for main SKILL.md
- Content that's only needed for specific use cases
## Structure Suggestions
### API Reference Example
- Overview
- Authentication
- Endpoints with examples
- Error codes
- Rate limits
### Workflow Guide Example
- Prerequisites
- Step-by-step instructions
- Common patterns
- Troubleshooting
- Best practices
"""
EXAMPLE_ASSET = """# Example Asset File
This placeholder represents where asset files would be stored.
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
Asset files are NOT intended to be loaded into context, but rather used within
the output Codex produces.
Example asset files from other skills:
- Brand guidelines: logo.png, slides_template.pptx
- Frontend builder: hello-world/ directory with HTML/React boilerplate
- Typography: custom-font.ttf, font-family.woff2
- Data: sample_data.csv, test_dataset.json
## Common Asset Types
- Templates: .pptx, .docx, boilerplate directories
- Images: .png, .jpg, .svg, .gif
- Fonts: .ttf, .otf, .woff, .woff2
- Boilerplate code: Project directories, starter files
- Icons: .ico, .svg
- Data files: .csv, .json, .xml, .yaml
Note: This is a text placeholder. Actual assets can be any file type.
"""
def normalize_skill_name(skill_name):
"""Normalize a skill name to lowercase hyphen-case."""
normalized = skill_name.strip().lower()
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
normalized = re.sub(r"-{2,}", "-", normalized)
return normalized
def title_case_skill_name(skill_name):
"""Convert hyphenated skill name to Title Case for display."""
return " ".join(word.capitalize() for word in skill_name.split("-"))
def init_skill(skill_name, path):
"""
Initialize a new skill directory with template SKILL.md.
Args:
skill_name: Name of the skill
path: Path where the skill directory should be created
Returns:
Path to created skill directory, or None if error
"""
# Determine skill directory path
skill_dir = Path(path).resolve() / skill_name
# Check if directory already exists
if skill_dir.exists():
print(f"❌ Error: Skill directory already exists: {skill_dir}")
return None
# Create skill directory
try:
skill_dir.mkdir(parents=True, exist_ok=False)
print(f"✅ Created skill directory: {skill_dir}")
except Exception as e:
print(f"❌ Error creating directory: {e}")
return None
# Create SKILL.md from template
skill_title = title_case_skill_name(skill_name)
skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title)
skill_md_path = skill_dir / "SKILL.md"
try:
skill_md_path.write_text(skill_content)
print("✅ Created SKILL.md")
except Exception as e:
print(f"❌ Error creating SKILL.md: {e}")
return None
# Create resource directories with example files
try:
# Create scripts/ directory with example script
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / "example.py"
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.chmod(0o755)
print("✅ Created scripts/example.py")
# Create references/ directory with example reference doc
references_dir = skill_dir / "references"
references_dir.mkdir(exist_ok=True)
example_reference = references_dir / "api_reference.md"
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
print("✅ Created references/api_reference.md")
# Create assets/ directory with example asset placeholder
assets_dir = skill_dir / "assets"
assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / "example_asset.txt"
example_asset.write_text(EXAMPLE_ASSET)
print("✅ Created assets/example_asset.txt")
except Exception as e:
print(f"❌ Error creating resource directories: {e}")
return None
# Print next steps
print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description")
print("2. Customize or delete the example files in scripts/, references/, and assets/")
print("3. Run the validator when ready to check the skill structure")
return skill_dir
def main():
if len(sys.argv) < 4 or sys.argv[2] != "--path":
print("Usage: init_skill.py <skill-name> --path <path>")
print("\nSkill name requirements:")
print(" - Use a hyphen-case identifier (e.g., 'data-analyzer')")
print(
" - Input is normalized to lowercase letters, digits, and hyphens only "
"(e.g., 'Plan Mode' -> 'plan-mode')"
)
print(f" - Max {MAX_SKILL_NAME_LENGTH} characters after normalization")
print(" - Directory name matches the normalized skill name")
print("\nExamples:")
print(" init_skill.py my-new-skill --path skills/public")
print(" init_skill.py my-api-helper --path skills/private")
print(" init_skill.py custom-skill --path /custom/location")
sys.exit(1)
raw_skill_name = sys.argv[1]
skill_name = normalize_skill_name(raw_skill_name)
if not skill_name:
print("❌ Error: Skill name must include at least one letter or digit.")
sys.exit(1)
if len(skill_name) > MAX_SKILL_NAME_LENGTH:
print(
f"❌ Error: Skill name '{skill_name}' is too long ({len(skill_name)} characters). "
f"Maximum is {MAX_SKILL_NAME_LENGTH} characters."
)
sys.exit(1)
if skill_name != raw_skill_name:
print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.")
path = sys.argv[3]
print(f"🚀 Initializing skill: {skill_name}")
print(f" Location: {path}")
print()
result = init_skill(skill_name, path)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Skill Packager - Creates a distributable .skill file of a skill folder
Usage:
python utils/package_skill.py <path/to/skill-folder> [output-directory]
Example:
python utils/package_skill.py skills/public/my-skill
python utils/package_skill.py skills/public/my-skill ./dist
"""
import sys
import zipfile
from pathlib import Path
from quick_validate import validate_skill
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a .skill file.
Args:
skill_path: Path to the skill folder
output_dir: Optional output directory for the .skill file (defaults to current directory)
Returns:
Path to the created .skill file, or None if error
"""
skill_path = Path(skill_path).resolve()
# Validate skill folder exists
if not skill_path.exists():
print(f"❌ Error: Skill folder not found: {skill_path}")
return None
if not skill_path.is_dir():
print(f"❌ Error: Path is not a directory: {skill_path}")
return None
# Validate SKILL.md exists
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
print(f"❌ Error: SKILL.md not found in {skill_path}")
return None
# Run validation before packaging
print("🔍 Validating skill...")
valid, message = validate_skill(skill_path)
if not valid:
print(f"❌ Validation failed: {message}")
print(" Please fix the validation errors before packaging.")
return None
print(f"{message}\n")
# Determine output location
skill_name = skill_path.name
if output_dir:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True)
else:
output_path = Path.cwd()
skill_filename = output_path / f"{skill_name}.skill"
# Create the .skill file (zip format)
try:
with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
# Walk through the skill directory
for file_path in skill_path.rglob("*"):
if file_path.is_file():
# Calculate the relative path within the zip
arcname = file_path.relative_to(skill_path.parent)
zipf.write(file_path, arcname)
print(f" Added: {arcname}")
print(f"\n✅ Successfully packaged skill to: {skill_filename}")
return skill_filename
except Exception as e:
print(f"❌ Error creating .skill file: {e}")
return None
def main():
if len(sys.argv) < 2:
print("Usage: python utils/package_skill.py <path/to/skill-folder> [output-directory]")
print("\nExample:")
print(" python utils/package_skill.py skills/public/my-skill")
print(" python utils/package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging skill: {skill_path}")
if output_dir:
print(f" Output directory: {output_dir}")
print()
result = package_skill(skill_path, output_dir)
if result:
sys.exit(0)
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
Quick validation script for skills - minimal version
"""
import re
import sys
from pathlib import Path
import yaml
def validate_skill(skill_path):
"""Basic validation of a skill"""
skill_path = Path(skill_path)
skill_md = skill_path / "SKILL.md"
if not skill_md.exists():
return False, "SKILL.md not found"
content = skill_md.read_text()
if not content.startswith("---"):
return False, "No YAML frontmatter found"
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
return False, "Invalid frontmatter format"
frontmatter_text = match.group(1)
try:
frontmatter = yaml.safe_load(frontmatter_text)
if not isinstance(frontmatter, dict):
return False, "Frontmatter must be a YAML dictionary"
except yaml.YAMLError as e:
return False, f"Invalid YAML in frontmatter: {e}"
allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"}
unexpected_keys = set(frontmatter.keys()) - allowed_properties
if unexpected_keys:
allowed = ", ".join(sorted(allowed_properties))
unexpected = ", ".join(sorted(unexpected_keys))
return (
False,
f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}",
)
if "name" not in frontmatter:
return False, "Missing 'name' in frontmatter"
if "description" not in frontmatter:
return False, "Missing 'description' in frontmatter"
name = frontmatter.get("name", "")
if not isinstance(name, str):
return False, f"Name must be a string, got {type(name).__name__}"
name = name.strip()
if name:
if not re.match(r"^[a-z0-9-]+$", name):
return (
False,
f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)",
)
if name.startswith("-") or name.endswith("-") or "--" in name:
return (
False,
f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens",
)
if len(name) > 64:
return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters."
description = frontmatter.get("description", "")
if not isinstance(description, str):
return False, f"Description must be a string, got {type(description).__name__}"
description = description.strip()
if description:
if "<" in description or ">" in description:
return False, "Description cannot contain angle brackets (< or >)"
if len(description) > 1024:
return (
False,
f"Description is too long ({len(description)} characters). Maximum is 1024 characters.",
)
return True, "Skill is valid!"
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python quick_validate.py <skill_directory>")
sys.exit(1)
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)

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::system::system_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 system_skills_root(codex_home: &Path) -> SkillRoot {
SkillRoot {
path: system_cache_root_dir(codex_home),
scope: SkillScope::System,
}
}
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 system skills.
roots.push(user_skills_root(&config.codex_home));
roots.push(system_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::System {
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,25 @@ 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_system_skill(
codex_home: &TempDir,
dir: &str,
name: &str,
description: &str,
) -> PathBuf {
write_skill_at(
&codex_home.path().join("skills/.system"),
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 +438,320 @@ 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 loads_system_skills_with_lowest_priority() {
let codex_home = tempfile::tempdir().expect("tempdir");
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
write_skill(&codex_home, "user", "dupe-skill", "from user");
let cfg = make_config(&codex_home);
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].description, "from user");
assert_eq!(outcome.skills[0].scope, SkillScope::User);
}
#[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_system_cache_when_present() {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_system_skill(&codex_home, "system", "system-skill", "from system");
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, "system-skill");
assert_eq!(outcome.skills[0].scope, SkillScope::System);
}
#[test]
fn deduplicates_by_name_preferring_user_over_system() {
let codex_home = tempfile::tempdir().expect("tempdir");
let work_dir = tempfile::tempdir().expect("tempdir");
write_skill(&codex_home, "user", "dupe-skill", "from user");
write_system_skill(&codex_home, "system", "dupe-skill", "from system");
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_system() {
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_system_skill(&codex_home, "system", "dupe-skill", "from system");
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

@@ -6,8 +6,9 @@ use std::sync::RwLock;
use crate::skills::SkillLoadOutcome;
use crate::skills::loader::load_skills_from_roots;
use crate::skills::loader::repo_skills_root;
use crate::skills::loader::system_skills_root;
use crate::skills::loader::user_skills_root;
use crate::skills::system::install_system_skills;
pub struct SkillsManager {
codex_home: PathBuf,
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
@@ -15,6 +16,10 @@ pub struct SkillsManager {
impl SkillsManager {
pub fn new(codex_home: PathBuf) -> Self {
if let Err(err) = install_system_skills(&codex_home) {
tracing::error!("failed to install system skills: {err}");
}
Self {
codex_home,
cache_by_cwd: RwLock::new(HashMap::new()),
@@ -22,18 +27,24 @@ impl SkillsManager {
}
pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome {
self.skills_for_cwd_with_options(cwd, false)
}
pub fn skills_for_cwd_with_options(&self, cwd: &Path, force_reload: bool) -> SkillLoadOutcome {
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(system_skills_root(&self.codex_home));
let outcome = load_skills_from_roots(roots);
match self.cache_by_cwd.write() {
Ok(mut cache) => {

View File

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

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

@@ -0,0 +1,163 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use include_dir::Dir;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::Hash;
use std::hash::Hasher;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
const SYSTEM_SKILLS_DIR: Dir =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/src/skills/assets/samples");
const SYSTEM_SKILLS_DIR_NAME: &str = ".system";
const SKILLS_DIR_NAME: &str = "skills";
const SYSTEM_SKILLS_MARKER_FILENAME: &str = ".codex-system-skills.marker";
/// Returns the on-disk cache location for embedded system skills.
///
/// This is typically located at `CODEX_HOME/skills/.system`.
pub(crate) fn system_cache_root_dir(codex_home: &Path) -> PathBuf {
AbsolutePathBuf::try_from(codex_home)
.and_then(|codex_home| system_cache_root_dir_abs(&codex_home))
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|_| {
codex_home
.join(SKILLS_DIR_NAME)
.join(SYSTEM_SKILLS_DIR_NAME)
})
}
fn system_cache_root_dir_abs(codex_home: &AbsolutePathBuf) -> std::io::Result<AbsolutePathBuf> {
codex_home
.join(SKILLS_DIR_NAME)?
.join(SYSTEM_SKILLS_DIR_NAME)
}
/// Installs embedded system skills into `CODEX_HOME/skills/.system`.
///
/// Clears any existing system skills directory first and then writes the embedded
/// skills directory into place.
///
/// To avoid doing unnecessary work on every startup, a marker file is written
/// with a fingerprint of the embedded directory. When the marker matches, the
/// install is skipped.
pub(crate) fn install_system_skills(codex_home: &Path) -> Result<(), SystemSkillsError> {
let codex_home = AbsolutePathBuf::try_from(codex_home)
.map_err(|source| SystemSkillsError::io("normalize codex home dir", source))?;
let skills_root_dir = codex_home
.join(SKILLS_DIR_NAME)
.map_err(|source| SystemSkillsError::io("resolve skills root dir", source))?;
fs::create_dir_all(skills_root_dir.as_path())
.map_err(|source| SystemSkillsError::io("create skills root dir", source))?;
let dest_system = system_cache_root_dir_abs(&codex_home)
.map_err(|source| SystemSkillsError::io("resolve system skills cache root dir", source))?;
let marker_path = dest_system
.join(SYSTEM_SKILLS_MARKER_FILENAME)
.map_err(|source| SystemSkillsError::io("resolve system skills marker path", source))?;
let expected_fingerprint = embedded_system_skills_fingerprint();
if dest_system.as_path().is_dir()
&& read_marker(&marker_path).is_ok_and(|marker| marker == expected_fingerprint)
{
return Ok(());
}
if dest_system.as_path().exists() {
fs::remove_dir_all(dest_system.as_path())
.map_err(|source| SystemSkillsError::io("remove existing system skills dir", source))?;
}
write_embedded_dir(&SYSTEM_SKILLS_DIR, &dest_system)?;
fs::write(marker_path.as_path(), format!("{expected_fingerprint}\n"))
.map_err(|source| SystemSkillsError::io("write system skills marker", source))?;
Ok(())
}
fn read_marker(path: &AbsolutePathBuf) -> Result<String, SystemSkillsError> {
Ok(fs::read_to_string(path.as_path())
.map_err(|source| SystemSkillsError::io("read system skills marker", source))?
.trim()
.to_string())
}
fn embedded_system_skills_fingerprint() -> String {
let mut items: Vec<(String, Option<u64>)> = SYSTEM_SKILLS_DIR
.entries()
.iter()
.map(|entry| match entry {
include_dir::DirEntry::Dir(dir) => (dir.path().to_string_lossy().to_string(), None),
include_dir::DirEntry::File(file) => {
let mut file_hasher = DefaultHasher::new();
file.contents().hash(&mut file_hasher);
(
file.path().to_string_lossy().to_string(),
Some(file_hasher.finish()),
)
}
})
.collect();
items.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut hasher = DefaultHasher::new();
for (path, contents_hash) in items {
path.hash(&mut hasher);
contents_hash.hash(&mut hasher);
}
format!("{:x}", hasher.finish())
}
/// Writes the embedded `include_dir::Dir` to disk under `dest`.
///
/// Preserves the embedded directory structure.
fn write_embedded_dir(dir: &Dir<'_>, dest: &AbsolutePathBuf) -> Result<(), SystemSkillsError> {
fs::create_dir_all(dest.as_path())
.map_err(|source| SystemSkillsError::io("create system skills dir", source))?;
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(subdir) => {
let subdir_dest = dest.join(subdir.path()).map_err(|source| {
SystemSkillsError::io("resolve system skills subdir", source)
})?;
fs::create_dir_all(subdir_dest.as_path()).map_err(|source| {
SystemSkillsError::io("create system skills subdir", source)
})?;
write_embedded_dir(subdir, dest)?;
}
include_dir::DirEntry::File(file) => {
let path = dest.join(file.path()).map_err(|source| {
SystemSkillsError::io("resolve system skills file", source)
})?;
if let Some(parent) = path.as_path().parent() {
fs::create_dir_all(parent).map_err(|source| {
SystemSkillsError::io("create system skills file parent", source)
})?;
}
fs::write(path.as_path(), file.contents())
.map_err(|source| SystemSkillsError::io("write system skill file", source))?;
}
}
}
Ok(())
}
#[derive(Debug, Error)]
pub(crate) enum SystemSkillsError {
#[error("io error while {action}: {source}")]
Io {
action: &'static str,
#[source]
source: std::io::Error,
},
}
impl SystemSkillsError {
fn io(action: &'static str, source: std::io::Error) -> Self {
Self::Io { action, source }
}
}

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() {

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