Compare commits

...

73 Commits

Author SHA1 Message Date
Kazuhiro Sera
19606f0b36 Merge branch 'main' into user-friendly-error-handling 2025-09-09 10:21:08 +09:00
jif-oai
62bd0e3d9d feat: POSIX unification and snapshot sessions (#3179)
## Session snapshot
For POSIX shell, the goal is to take a snapshot of the interactive shell
environment, store it in a session file located in `.codex/` and only
source this file for every command that is run.
As a result, if a snapshot files exist, `bash -lc <CALL>` get replaced
by `bash -c <CALL>`.

This also fixes the issue that `bash -lc` does not source `.bashrc`,
resulting in missing env variables and aliases in the codex session.
## POSIX unification
Unify `bash` and `zsh` shell into a POSIX shell. The rational is that
the tool will not use any `zsh` specific capabilities.

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-09-08 18:09:45 -07:00
jif-oai
a9c68ea270 feat: Run cargo shear during CI (#3338)
Run cargo shear as part of the CI to ensure no unused dependencies
2025-09-09 01:05:08 +00:00
Jeremy Rose
ac58749bd3 allow mach-lookup for com.apple.system.opendirectoryd.libinfo (#3334)
in the base sandbox policy. this is [allowed in Chrome
renderers](https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=266;drc=7afa0043cfcddb3ef9dafe5acbfc01c2f7e7df01),
so I feel it's fairly safe.
2025-09-08 16:28:52 -07:00
Robert
79cbd2ab1b Improve explanation of how the shell handles quotes in config.md (#3169)
* Clarify how the shell's handling of quotes affects the interpretation
of TOML values in `--config`/`-c`
* Provide examples of the right way to pass complex TOML values
* The previous explanation incorrectly demonstrated how to pass TOML
values to `--config`/`-c` (misunderstanding how the shell’s handling of
quotes affects things) and would result in invalid invocations of
`codex`.
2025-09-08 15:58:25 -07:00
Gabriel Peal
5eaaf307e1 Generate more typescript types and return conversation id with ConversationSummary (#3219)
This PR does multiple things that are necessary for conversation resume
to work from the extension. I wanted to make sure everything worked so
these changes wound up in one PR:
1. Generate more ts types
2. Resume rollout history files rather than create a new one every time
it is resumed so you don't see a duplicate conversation in history for
every resume. Chatted with @aibrahim-oai to verify this
3. Return conversation_id in conversation summaries
4. [Cleanup] Use serde and strong types for a lot of the rollout file
parsing
2025-09-08 17:54:47 -04:00
Justin Lebar
18330c2362 Format large numbers in a more readable way. (#2046)
- In the bottom line of the TUI, print the number of tokens to 3 sigfigs
  with an SI suffix, e.g. "1.23K".
- Elsewhere where we print a number, I figure it's worthwhile to print
  the exact number, because e.g. it's a summary of your session. Here we print
  the numbers comma-separated.
2025-09-08 21:48:48 +00:00
Jeremy Rose
4c46490e53 Highlight Proposed Command preview (#3319)
#### Summary
- highlight proposed command previews with the shared bash syntax
highlighter
- keep the Proposed Command section consistent with other execution
renderings
2025-09-08 10:48:41 -07:00
Gabriel Peal
5c1416d99b Add a getUserAgent MCP method (#3320)
This will allow the extension to pass this user agent + a suffix for its
requests
2025-09-08 13:30:13 -04:00
Michael Bolin
0525b48baa chore: upgrade to actions/setup-node@v5 (#3316)
Dependabot tried to automatically upgrade us to `actions/setup-node@v5`
in https://github.com/openai/codex/pull/3293, but it broke our CI. Note
this upgrade has breaking changes:

https://github.com/actions/setup-node/releases/tag/v5.0.0

I think the problem was that `v5` was correctly reading our
`packageManager` line here:


e2b3053b2b/package.json (L24)

and then tried to run `pnpm`, but couldn't because it wasn't available
yet. This PR:

- moves `pnpm/action-setup` before `actions/setup-node`
- drops `version` from our `pnpm/action-setup` step because it is not
necessary when it is specified in `package.json` (which it is in our
case), so leaving it here ran the risk of the two getting out of sync
- upgrades `actions/setup-node` from `v4` to `v5`
- deletes the two custom steps we had to enable Node.js caching since
`v5` claims to do this for us now
- adds `--frozen-lockfile` to our `pnpm install` invocation, which
seemed like something we should have always had there
2025-09-08 09:34:59 -07:00
Jeremy Rose
1f4f9cde8e tui: paste with ctrl+v checks file_list (#3211)
I found that pasting images from Finder with Ctrl+V was resulting in
incorrect results; this seems to work better.
2025-09-08 09:31:42 -07:00
Biturd
cad37009e1 fix: improve MCP server initialization error handling #3196 #2346 #2555 (#3243)
• I have signed the CLA by commenting the required sentence and
triggered recheck.
• Local checks are all green (fmt / clippy / test).
• Could you please approve the pending GitHub Actions workflows
(first-time contributor), and when convenient, help with one approving
review so I can proceed? Thanks!

  ## Summary
- Catch and log task panics during server initialization instead of
propagating JoinError
- Handle tool listing failures gracefully, allowing partial server
initialization
- Improve error resilience on macOS where init timeouts are more common

  ## Test plan
  - [x] Test MCP server initialization with timeout scenarios
  - [x] Verify graceful handling of tool listing failures
  - [x] Confirm improved error messages and logging
  - [x] Test on macOS 

 ## Fix issue  #3196 #2346 #2555
### fix before:
<img width="851" height="363" alt="image"
src="https://github.com/user-attachments/assets/e1f9c749-71fd-4873-a04f-d3fc4cbe0ae6"
/>

<img width="775" height="108" alt="image"
src="https://github.com/user-attachments/assets/4e4748bd-9dd6-42b5-b38b-8bfe9341a441"
/>

### fix improved:
<img width="966" height="528" alt="image"
src="https://github.com/user-attachments/assets/418324f3-e37a-4a3c-8bdd-934f9ff21dfb"
/>

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-09-08 09:28:12 -07:00
dependabot[bot]
e2b3053b2b chore(deps): bump image from 0.25.6 to 0.25.8 in /codex-rs (#3297)
Bumps [image](https://github.com/image-rs/image) from 0.25.6 to 0.25.8.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/image-rs/image/blob/v0.25.8/CHANGES.md">image's
changelog</a>.</em></p>
<blockquote>
<h3>Version 0.25.8</h3>
<p>Re-release of <code>0.25.7</code></p>
<p>Fixes:</p>
<ul>
<li>Reverted a signature change to <code>load_from_memory</code> that
lead to large scale
type inference breakage despite being technically compatible.</li>
<li>Color conversion Luma to Rgb used incorrect coefficients instead of
broadcasting.</li>
</ul>
<h3>Version 0.25.7 (yanked)</h3>
<p>Features:</p>
<ul>
<li>Added an API for external image format implementations to register
themselves as decoders for a specific format in <code>image</code> (<a
href="https://redirect.github.com/image-rs/image/issues/2372">#2372</a>)</li>
<li>Added <a
href="https://www.color.org/iccmax/download/CICP_tag_and_type_amendment.pdf">CICP</a>
awarenes via <a href="https://crates.io/crates/moxcms">moxcms</a> to
support color spaces (<a
href="https://redirect.github.com/image-rs/image/issues/2531">#2531</a>).
The support for transforming is limited for now and will be gradually
expanded.</li>
<li>You can now embed Exif metadata when writing JPEG, PNG and WebP
images (<a
href="https://redirect.github.com/image-rs/image/issues/2537">#2537</a>,
<a
href="https://redirect.github.com/image-rs/image/issues/2539">#2539</a>)</li>
<li>Added functions to extract orientation from Exif metadata and
optionally clear it in the Exif chunk (<a
href="https://redirect.github.com/image-rs/image/issues/2484">#2484</a>)</li>
<li>Serde support for more types (<a
href="https://redirect.github.com/image-rs/image/issues/2445">#2445</a>)</li>
<li>PNM encoder now supports writing 16-bit images (<a
href="https://redirect.github.com/image-rs/image/issues/2431">#2431</a>)</li>
</ul>
<p>API improvements:</p>
<ul>
<li><code>save</code>, <code>save_with_format</code>,
<code>write_to</code> and <code>write_with_encoder</code> methods on
<code>DynamicImage</code> now automatically convert the pixel format
when necessary instead of returning an error (<a
href="https://redirect.github.com/image-rs/image/issues/2501">#2501</a>)</li>
<li>Added <code>DynamicImage::has_alpha()</code> convenience method</li>
<li>Implemented <code>TryFrom&lt;ExtendedColorType&gt;</code> for
<code>ColorType</code> (<a
href="https://redirect.github.com/image-rs/image/issues/2444">#2444</a>)</li>
<li>Added <code>const HAS_ALPHA</code> to trait <code>Pixel</code></li>
<li>Unified the error for unsupported encoder colors (<a
href="https://redirect.github.com/image-rs/image/issues/2543">#2543</a>)</li>
<li>Added a <code>hooks</code> module to customize builtin behavior,
<code>register_format_detection_hook</code> and
<code>register_decoding_hook</code> for the determining format of a file
and selecting an <code>ImageDecoder</code> implementation respectively.
(<a
href="https://redirect.github.com/image-rs/image/issues/2372">#2372</a>)</li>
</ul>
<p>Performance improvements:</p>
<ul>
<li>Gaussian blur (<a
href="https://redirect.github.com/image-rs/image/issues/2496">#2496</a>)
and box blur (<a
href="https://redirect.github.com/image-rs/image/issues/2515">#2515</a>)
are now faster</li>
<li>Improve compilation times by avoiding unnecessary instantiation of
generic functions (<a
href="https://redirect.github.com/image-rs/image/issues/2468">#2468</a>,
<a
href="https://redirect.github.com/image-rs/image/issues/2470">#2470</a>)</li>
</ul>
<p>Bug fixes:</p>
<ul>
<li>Many improvements to image format decoding: TIFF, WebP, AVIF, PNG,
GIF, BMP, TGA</li>
<li>Fixed <code>GifEncoder::encode()</code> ignoring the speed parameter
and always using the slowest speed (<a
href="https://redirect.github.com/image-rs/image/issues/2504">#2504</a>)</li>
<li><code>.pnm</code> is now recognized as a file extension for the PNM
format (<a
href="https://redirect.github.com/image-rs/image/issues/2559">#2559</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="98b001da0d"><code>98b001d</code></a>
Merge pull request <a
href="https://redirect.github.com/image-rs/image/issues/2592">#2592</a>
from image-rs/release-0.25.8</li>
<li><a
href="f86232081c"><code>f862320</code></a>
Metadata and changelog for a 0.25.8</li>
<li><a
href="3b1c1db11d"><code>3b1c1db</code></a>
Merge pull request <a
href="https://redirect.github.com/image-rs/image/issues/2593">#2593</a>
from image-rs/luma-to-rgb-transform-is-broadcast</li>
<li><a
href="1f574d3d1e"><code>1f574d3</code></a>
Replace manual rounding code with f32::round</li>
<li><a
href="545cb3788b"><code>545cb37</code></a>
Color tests in the middle of dynamic range</li>
<li><a
href="9882fa9fe0"><code>9882fa9</code></a>
Remove coefficients from luma_expand</li>
<li><a
href="70b9aa3ef1"><code>70b9aa3</code></a>
Revert &quot;Make load_from_memory generic&quot;</li>
<li><a
href="b94c33379f"><code>b94c333</code></a>
Enable CI for backport branch</li>
<li><a
href="a24556bc87"><code>a24556b</code></a>
Merge pull request <a
href="https://redirect.github.com/image-rs/image/issues/2581">#2581</a>
from image-rs/release-0.25.7</li>
<li><a
href="9175dbc70e"><code>9175dbc</code></a>
Fix readme typo (<a
href="https://redirect.github.com/image-rs/image/issues/2580">#2580</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/image-rs/image/compare/v0.25.6...v0.25.8">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=image&package-manager=cargo&previous-version=0.25.6&new-version=0.25.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 08:25:23 -07:00
dependabot[bot]
e47bd33689 chore(deps): bump clap from 4.5.45 to 4.5.47 in /codex-rs (#3296)
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.45 to 4.5.47.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/clap-rs/clap/releases">clap's
releases</a>.</em></p>
<blockquote>
<h2>v4.5.47</h2>
<h2>[4.5.47] - 2025-09-02</h2>
<h3>Features</h3>
<ul>
<li>Added <code>impl FromArgMatches for ()</code></li>
<li>Added <code>impl Args for ()</code></li>
<li>Added <code>impl Subcommand for ()</code></li>
<li>Added <code>impl FromArgMatches for Infallible</code></li>
<li>Added <code>impl Subcommand for Infallible</code></li>
</ul>
<h3>Fixes</h3>
<ul>
<li><em>(derive)</em> Update runtime error text to match
<code>clap</code></li>
</ul>
<h2>v4.5.46</h2>
<h2>[4.5.46] - 2025-08-26</h2>
<h3>Features</h3>
<ul>
<li>Expose <code>StyledStr::push_str</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/clap-rs/clap/blob/master/CHANGELOG.md">clap's
changelog</a>.</em></p>
<blockquote>
<h2>[4.5.47] - 2025-09-02</h2>
<h3>Features</h3>
<ul>
<li>Added <code>impl FromArgMatches for ()</code></li>
<li>Added <code>impl Args for ()</code></li>
<li>Added <code>impl Subcommand for ()</code></li>
<li>Added <code>impl FromArgMatches for Infallible</code></li>
<li>Added <code>impl Subcommand for Infallible</code></li>
</ul>
<h3>Fixes</h3>
<ul>
<li><em>(derive)</em> Update runtime error text to match
<code>clap</code></li>
</ul>
<h2>[4.5.46] - 2025-08-26</h2>
<h3>Features</h3>
<ul>
<li>Expose <code>StyledStr::push_str</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="f046ca6a2b"><code>f046ca6</code></a>
chore: Release</li>
<li><a
href="436949dde1"><code>436949d</code></a>
docs: Update changelog</li>
<li><a
href="1ddab84c32"><code>1ddab84</code></a>
Merge pull request <a
href="https://redirect.github.com/clap-rs/clap/issues/5954">#5954</a>
from epage/tests</li>
<li><a
href="8a66dbf7c2"><code>8a66dbf</code></a>
test(complete): Add more native cases</li>
<li><a
href="76465cf223"><code>76465cf</code></a>
test(complete): Make things more consistent</li>
<li><a
href="232cedbe76"><code>232cedb</code></a>
test(complete): Remove redundant index</li>
<li><a
href="02244a69a3"><code>02244a6</code></a>
Merge pull request <a
href="https://redirect.github.com/clap-rs/clap/issues/5949">#5949</a>
from krobelus/option-name-completions-after-positionals</li>
<li><a
href="2e13847533"><code>2e13847</code></a>
fix(complete): Missing options in multi-val arg</li>
<li><a
href="74388d784b"><code>74388d7</code></a>
test(complete): Multi-valued, unbounded positional</li>
<li><a
href="5b3d45f72c"><code>5b3d45f</code></a>
refactor(complete): Extract function for options</li>
<li>Additional commits viewable in <a
href="https://github.com/clap-rs/clap/compare/clap_complete-v4.5.45...clap_complete-v4.5.47">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=clap&package-manager=cargo&previous-version=4.5.45&new-version=4.5.47)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 08:24:36 -07:00
dependabot[bot]
6b878bea01 chore(deps): bump tree-sitter from 0.25.8 to 0.25.9 in /codex-rs (#3295)
Bumps [tree-sitter](https://github.com/tree-sitter/tree-sitter) from
0.25.8 to 0.25.9.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/tree-sitter/tree-sitter/releases">tree-sitter's
releases</a>.</em></p>
<blockquote>
<h2>v0.25.9</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix: add wasm32 support to portable/endian.h by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4613">tree-sitter/tree-sitter#4613</a></li>
<li>Replace deprecated function on build.zig by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4621">tree-sitter/tree-sitter#4621</a></li>
<li>perf(generate): reserve more <code>Vec</code> capacities by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4629">tree-sitter/tree-sitter#4629</a></li>
<li>fix(rust): prevent overflow in error message calculation by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4634">tree-sitter/tree-sitter#4634</a></li>
<li>fix(bindings): use parser title in lib.rs description by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4638">tree-sitter/tree-sitter#4638</a></li>
<li>fix(bindings): only include top level LICENSE file by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4639">tree-sitter/tree-sitter#4639</a></li>
<li>fix(bindings): improve python platform detection by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4640">tree-sitter/tree-sitter#4640</a></li>
<li>test(python): improve bindings test to detect ABI incompatibilities
by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4641">tree-sitter/tree-sitter#4641</a></li>
<li>fix(query): prevent cycles when analyzing hidden children by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4659">tree-sitter/tree-sitter#4659</a></li>
<li>Reserved word dsl declarations by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4661">tree-sitter/tree-sitter#4661</a></li>
<li>fix(cli): improve error message in cases where a langauge can't be
found for one of many paths by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4662">tree-sitter/tree-sitter#4662</a></li>
<li>fix(bindings): correct indices for <code>Node::utf16_text</code> by
<a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4663">tree-sitter/tree-sitter#4663</a></li>
<li>fix(rust): ignore new mismatched-lifetime-syntaxes lint by <a
href="https://github.com/ObserverOfTime"><code>@​ObserverOfTime</code></a>
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4680">tree-sitter/tree-sitter#4680</a></li>
<li>fix(bindings): use custom class name by <a
href="https://github.com/ObserverOfTime"><code>@​ObserverOfTime</code></a>
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4679">tree-sitter/tree-sitter#4679</a></li>
<li>fix(bindings): update zig template files (<a
href="https://redirect.github.com/tree-sitter/tree-sitter/issues/4637">#4637</a>)
by <a
href="https://github.com/ObserverOfTime"><code>@​ObserverOfTime</code></a>
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4684">tree-sitter/tree-sitter#4684</a></li>
<li>Update build.zig.zon by <a
href="https://github.com/Omar-xt"><code>@​Omar-xt</code></a> in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4709">tree-sitter/tree-sitter#4709</a></li>
<li>Backport build.zig.zon fixes by <a
href="https://github.com/ObserverOfTime"><code>@​ObserverOfTime</code></a>
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4717">tree-sitter/tree-sitter#4717</a></li>
<li>portable/endian: Add Haiku support by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4724">tree-sitter/tree-sitter#4724</a></li>
<li>fix(wasm): delete <code>var_i32_type</code> after initializing
global stack pointer value by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4732">tree-sitter/tree-sitter#4732</a></li>
<li>fix(rust): EqCapture accepted cases where number of captured nodes
differed by one by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4737">tree-sitter/tree-sitter#4737</a></li>
<li>fix(bindings): improve zig dependency fetching logic by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4741">tree-sitter/tree-sitter#4741</a></li>
<li>fix(bindings): add tree-sitter as npm dev dependency by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4738">tree-sitter/tree-sitter#4738</a></li>
<li>[backport] build.zig improvements by <a
href="https://github.com/ObserverOfTime"><code>@​ObserverOfTime</code></a>
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4743">tree-sitter/tree-sitter#4743</a></li>
<li>fix(lib): check if an <code>ERROR</code> node is named before
assuming it's the builtin error node by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4746">tree-sitter/tree-sitter#4746</a></li>
<li>fix(lib): allow error nodes to match when they are child nodes by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4748">tree-sitter/tree-sitter#4748</a></li>
<li>build(zig): support wasmtime for ARM64 Windows (MSVC) by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4749">tree-sitter/tree-sitter#4749</a></li>
<li>fix(bindings): properly detect MSVC compiler by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4751">tree-sitter/tree-sitter#4751</a></li>
<li>fix(generate): warn users when extra rule can lead to parser hang by
<a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4763">tree-sitter/tree-sitter#4763</a></li>
<li>fix(cli): fix DSL type declarations by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4770">tree-sitter/tree-sitter#4770</a></li>
<li>fix(npm): add directory to repository fields by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4773">tree-sitter/tree-sitter#4773</a></li>
<li>fix(web): correct type errors, improve build by <a
href="https://github.com/ObserverOfTime"><code>@​ObserverOfTime</code></a>
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4774">tree-sitter/tree-sitter#4774</a></li>
<li>fix(generate): return error when single state transitions have
indirectly recursive cycles by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4790">tree-sitter/tree-sitter#4790</a></li>
<li>fix(generate): use correct state id when adding terminal states to
non terminal extras by <a
href="https://github.com/tree-sitter-ci-bot"><code>@​tree-sitter-ci-bot</code></a>[bot]
in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4794">tree-sitter/tree-sitter#4794</a></li>
<li>release v0.25.9 by <a
href="https://github.com/clason"><code>@​clason</code></a> in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4798">tree-sitter/tree-sitter#4798</a></li>
<li>fix(rust): correct crate versions in root Cargo.toml file by <a
href="https://github.com/WillLillis"><code>@​WillLillis</code></a> in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4800">tree-sitter/tree-sitter#4800</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Omar-xt"><code>@​Omar-xt</code></a> made
their first contribution in <a
href="https://redirect.github.com/tree-sitter/tree-sitter/pull/4709">tree-sitter/tree-sitter#4709</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/tree-sitter/tree-sitter/compare/v0.25.8...v0.25.9">https://github.com/tree-sitter/tree-sitter/compare/v0.25.8...v0.25.9</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="a467ea8502"><code>a467ea8</code></a>
fix(rust): correct crate versions in root Cargo.toml file</li>
<li><a
href="6cd25aadd5"><code>6cd25aa</code></a>
0.25.9</li>
<li><a
href="027136c98a"><code>027136c</code></a>
fix(generate): use correct state id when adding terminal states to</li>
<li><a
href="14c4d2f8ca"><code>14c4d2f</code></a>
fix(generate): return error when single state transitions have</li>
<li><a
href="8e2b5ad2a4"><code>8e2b5ad</code></a>
fix(test): improve readability of corpus error message mismatch</li>
<li><a
href="bb82b94ded"><code>bb82b94</code></a>
fix(web): correct type errors, improve build</li>
<li><a
href="59f3cb91c2"><code>59f3cb9</code></a>
fix(npm): add directory to repository fields</li>
<li><a
href="a80cd86d47"><code>a80cd86</code></a>
fix(cli): fix DSL type declarations</li>
<li><a
href="253003ccf8"><code>253003c</code></a>
fix(generate): warn users when extra rule can lead to parser hang</li>
<li><a
href="e61407cc36"><code>e61407c</code></a>
fix(bindings): properly detect MSVC compiler</li>
<li>Additional commits viewable in <a
href="https://github.com/tree-sitter/tree-sitter/compare/v0.25.8...v0.25.9">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tree-sitter&package-manager=cargo&previous-version=0.25.8&new-version=0.25.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 08:22:59 -07:00
dependabot[bot]
ca46510fd3 chore(deps): bump insta from 1.43.1 to 1.43.2 in /codex-rs (#3294)
Bumps [insta](https://github.com/mitsuhiko/insta) from 1.43.1 to 1.43.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/mitsuhiko/insta/releases">insta's
releases</a>.</em></p>
<blockquote>
<h2>1.43.2</h2>
<h2>Release Notes</h2>
<ul>
<li>Fix panics when <code>cargo metadata</code> fails to execute or
parse (e.g., when cargo is not in PATH or returns invalid output). Now
falls back to using the manifest directory as the workspace root. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/798">#798</a>
(<a href="https://github.com/adriangb"><code>@​adriangb</code></a>)</li>
<li>Fix clippy <code>uninlined_format_args</code> lint warnings. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/801">#801</a></li>
<li>Changed diff line numbers to 1-based indexing. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/799">#799</a></li>
<li>Preserve snapshot names with <code>INSTA_GLOB_FILTER</code>. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/786">#786</a></li>
<li>Bumped <code>libc</code> crate to <code>0.2.174</code>, fixing
building on musl targets, and increasing the MSRV of
<code>insta</code> to <code>1.64.0</code> (released Sept 2022). <a
href="https://redirect.github.com/mitsuhiko/insta/issues/784">#784</a></li>
<li>Fix clippy 1.88 errors. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/783">#783</a></li>
<li>Fix source path in snapshots for non-child workspaces. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/778">#778</a></li>
<li>Add lifetime to Selector in redaction iterator. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/779">#779</a></li>
</ul>
<h2>Install cargo-insta 1.43.2</h2>
<h3>Install prebuilt binaries via shell script</h3>
<pre lang="sh"><code>curl --proto '=https' --tlsv1.2 -LsSf
https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-installer.sh
| sh
</code></pre>
<h3>Install prebuilt binaries via powershell script</h3>
<pre lang="sh"><code>powershell -ExecutionPolicy ByPass -c &quot;irm
https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-installer.ps1
| iex&quot;
</code></pre>
<h2>Download cargo-insta 1.43.2</h2>
<table>
<thead>
<tr>
<th>File</th>
<th>Platform</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
<tr>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-aarch64-apple-darwin.tar.xz">cargo-insta-aarch64-apple-darwin.tar.xz</a></td>
<td>Apple Silicon macOS</td>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-aarch64-apple-darwin.tar.xz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-apple-darwin.tar.xz">cargo-insta-x86_64-apple-darwin.tar.xz</a></td>
<td>Intel macOS</td>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-apple-darwin.tar.xz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-pc-windows-msvc.zip">cargo-insta-x86_64-pc-windows-msvc.zip</a></td>
<td>x64 Windows</td>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-pc-windows-msvc.zip.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-unknown-linux-gnu.tar.xz">cargo-insta-x86_64-unknown-linux-gnu.tar.xz</a></td>
<td>x64 Linux</td>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-unknown-linux-gnu.tar.xz.sha256">checksum</a></td>
</tr>
<tr>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-unknown-linux-musl.tar.xz">cargo-insta-x86_64-unknown-linux-musl.tar.xz</a></td>
<td>x64 MUSL Linux</td>
<td><a
href="https://github.com/mitsuhiko/insta/releases/download/1.43.2/cargo-insta-x86_64-unknown-linux-musl.tar.xz.sha256">checksum</a></td>
</tr>
</tbody>
</table>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/mitsuhiko/insta/blob/master/CHANGELOG.md">insta's
changelog</a>.</em></p>
<blockquote>
<h2>1.43.2</h2>
<ul>
<li>Fix panics when <code>cargo metadata</code> fails to execute or
parse (e.g., when cargo is not in PATH or returns invalid output). Now
falls back to using the manifest directory as the workspace root. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/798">#798</a>
(<a href="https://github.com/adriangb"><code>@​adriangb</code></a>)</li>
<li>Fix clippy <code>uninlined_format_args</code> lint warnings. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/801">#801</a></li>
<li>Changed diff line numbers to 1-based indexing. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/799">#799</a></li>
<li>Preserve snapshot names with <code>INSTA_GLOB_FILTER</code>. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/786">#786</a></li>
<li>Bumped <code>libc</code> crate to <code>0.2.174</code>, fixing
building on musl targets, and increasing the MSRV of
<code>insta</code> to <code>1.64.0</code> (released Sept 2022). <a
href="https://redirect.github.com/mitsuhiko/insta/issues/784">#784</a></li>
<li>Fix clippy 1.88 errors. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/783">#783</a></li>
<li>Fix source path in snapshots for non-child workspaces. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/778">#778</a></li>
<li>Add lifetime to Selector in redaction iterator. <a
href="https://redirect.github.com/mitsuhiko/insta/issues/779">#779</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="01fc57f115"><code>01fc57f</code></a>
Fix Windows runner configuration for releases</li>
<li><a
href="88c9a2f020"><code>88c9a2f</code></a>
Prepare CHANGELOG for 1.43.2 release (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/802">#802</a>)</li>
<li><a
href="d03c2a67b5"><code>d03c2a6</code></a>
Improve error handling for cargo workspace detection (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/800">#800</a>)</li>
<li><a
href="55987acdb6"><code>55987ac</code></a>
Fix clippy uninlined_format_args lint warnings (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/801">#801</a>)</li>
<li><a
href="ae26e810a3"><code>ae26e81</code></a>
Change diff line numbers to 1-based indexing (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/799">#799</a>)</li>
<li><a
href="26efb60d08"><code>26efb60</code></a>
Release insta 1.43.2 (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/791">#791</a>)</li>
<li><a
href="7793782476"><code>7793782</code></a>
Preserve snapshot names with INSTA_GLOB_FILTER (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/786">#786</a>)</li>
<li><a
href="1d6e0c7156"><code>1d6e0c7</code></a>
chore: bump libc crate (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/784">#784</a>)</li>
<li><a
href="1a17ea9552"><code>1a17ea9</code></a>
chore: fix clippy 1.88 errors (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/783">#783</a>)</li>
<li><a
href="7d0de48695"><code>7d0de48</code></a>
Fix source path in snapshots for non-child workspaces (<a
href="https://redirect.github.com/mitsuhiko/insta/issues/778">#778</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/mitsuhiko/insta/compare/1.43.1...1.43.2">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=insta&package-manager=cargo&previous-version=1.43.1&new-version=1.43.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 08:21:17 -07:00
dolan
6efb52e545 feat(mcp): per-server startup timeout (#3182)
Seeing timeouts on certain, slow mcp server starting up when codex is
invoked. Before this change, the timeout was a hard-coded 10s. Need the
ability to define arbitrary timeouts on a per-server basis.

## Summary of changes

- Add startup_timeout_ms to McpServerConfig with 10s default when unset
- Use per-server timeout for initialize and tools/list
- Introduce ManagedClient to store client and timeout; rename
LIST_TOOLS_TIMEOUT to DEFAULT_STARTUP_TIMEOUT
- Update docs to document startup_timeout_ms with example and options
table

---------

Co-authored-by: Matthew Dolan <dolan-openai@users.noreply.github.com>
2025-09-08 08:12:08 -07:00
Aleksandr Kondrashov
d84a799ec0 docs: fix broken link to the "Memory with AGENTS.md" section in codex/README.md (#3300)
Fixes https://github.com/openai/codex/issues/3299

Updated the link in README.md so that it correctly points to the [Memory
with
AGENTS.md](https://github.com/openai/codex/blob/main/docs/getting-started.md#memory-with-agentsmd)
section, ensuring users are directed to the right location.
2025-09-08 14:15:12 +00:00
Gabriel Peal
c8fab51372 Use ConversationId instead of raw Uuids (#3282)
We're trying to migrate from `session_id: Uuid` to `conversation_id:
ConversationId`. Not only does this give us more type safety but it
unifies our terminology across Codex and with the implementation of
session resuming, a conversation (which can span multiple sessions) is
more appropriate.

I started this impl on https://github.com/openai/codex/pull/3219 as part
of getting resume working in the extension but it's big enough that it
should be broken out.
2025-09-07 23:22:25 -04:00
Gabriel Peal
58d77ca4e7 Clear non-empty prompts with ctrl + c (#3285)
This updates the ctrl + c behavior to clear the current prompt if there
is text and you press ctrl + c.

I also updated the ctrl + c hint text to show `^c to interrupt` instead
of `^c to quit` if there is an active conversation.

Two things I don't love:
1. You can currently interrupt a conversation with escape or ctrl + c
(not related to this PR and maybe fine)
2. The bottom row hint text always says `^c to quit` but this PR doesn't
really make that worse.




https://github.com/user-attachments/assets/6eddadec-0d84-4fa7-abcb-d6f5a04e5748


Fixes https://github.com/openai/codex/issues/3126
2025-09-07 23:21:53 -04:00
pakrym-oai
0269096229 Move token usage/context information to session level (#3221)
Move context information into the main loop so it can be used to
interrupt the loop or start auto-compaction.
2025-09-06 15:19:23 +00:00
Michael Bolin
70a6d4b1b4 fix: change create_github_release to take either --publish-alpha or --publish-release (#3231)
No more picking out version numbers by hand! Now we let the script do
it:

```
$ ./codex-rs/scripts/create_github_release --dry-run --publish-alpha
Running gh api GET /repos/openai/codex/releases/latest
Running gh api GET /repos/openai/codex/releases?per_page=100
Publishing version 0.31.0-alpha.3
$ ./codex-rs/scripts/create_github_release --dry-run --publish-release
Running gh api GET /repos/openai/codex/releases/latest
Publishing version 0.31.0
```

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3230).
* __->__ #3231
* #3230
* #3228
* #3226
2025-09-05 22:08:34 -07:00
Michael Bolin
b1d5f7c0bd chore: use gh instead of git to do work to avoid overhead of a local clone (#3230)
The advantage of this implementation is that it can be run from
"anywhere" so long as the user has `gh` installed with the appropriate
credentials to write to the `openai/codex` repo. Unlike the previous
implementation, it avoids the overhead of creating a local clone of the
repo.

Ran:

```
./codex-rs/scripts/create_github_release 0.31.0-alpha.2
```

which appeared to work as expected:

- workflow https://github.com/openai/codex/actions/runs/17508564352
- release
https://github.com/openai/codex/releases/tag/rust-v0.31.0-alpha.2

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3230).
* #3231
* __->__ #3230
* #3228
* #3226
2025-09-05 21:58:42 -07:00
Michael Bolin
066c6cce02 chore: change create_github_release to create a fresh clone in a temp directory (#3228)
Ran:

```
./codex-rs/scripts/create_github_release 0.31.0-alpha.1
```

which appeared to work as expected:

- workflow https://github.com/openai/codex/actions/runs/17508403922
- release
https://github.com/openai/codex/releases/tag/rust-v0.31.0-alpha.1

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3228).
* #3231
* #3230
* __->__ #3228
* #3226
2025-09-05 21:57:11 -07:00
Michael Bolin
bd65f81e54 chore: rewrite codex-rs/scripts/create_github_release.sh in Python (#3226)
Migrating to Python to make this script easier to iterate on.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/3226).
* #3231
* #3230
* #3228
* __->__ #3226
2025-09-05 21:54:18 -07:00
Anton Panasenko
ba9620aea7 [codex] respect overrides for model family configuration from toml file (#3176) 2025-09-05 16:56:58 -07:00
Eric Traut
45c3b20041 Added CLI version to /status output (#3223)
This PR adds the CLI version to the `/status` output.

This addresses feature request #2767
2025-09-05 16:27:31 -07:00
Enrique Moreno Tent
6cfc012e9d feat(tui): show minutes/hours in thinking timer (#3220)
What
  
- Show compact elapsed time in the TUI status indicator: Xs, MmSSs,
HhMMmSSs.
  - Add private helper fmt_elapsed_compact with a unit test.
  
  Why
  
- Seconds‑only becomes hard to read during longer runs; minutes/hours
improve clarity without extra noise.
  
  How
  
  - Implemented in codex-rs/tui/src/status_indicator_widget.rs only.
- The helper is used when rendering the existing “Working/Thinking”
timer.
- No changes to codex-common::elapsed::format_duration or other crates.
  
  Scope/Impact
  
  - TUI‑only; no public API changes; minimal risk.
  - Snapshot tests should remain unchanged (most show “0s”).
  
  Before/After
  
- Working (65s • Esc to interrupt) → Working (1m05s • Esc to interrupt)
  - Working (3723s • …) → Working (1h02m03s • …)
  
  Tests
  
  - Unit: fmt_elapsed_compact_formats_seconds_minutes_hours.
- Local checks: cargo fmt --all, cargo clippy -p codex-tui -- -D
warnings, cargo test -p codex-tui.
  
  Notes
  
- Open to adjusting the exact format or moving the helper if maintainers
prefer a shared location.

Signed-off-by: Enrique Moreno Tent <enriquemorenotent@gmail.com>
2025-09-05 22:06:36 +00:00
Eric Traut
17a80d43c8 Added logic to cancel pending oauth login to free up localhost port (#3217)
This PR addresses an issue that several users have reported. If the
local oauth login server in one codex instance is left running (e.g. the
user abandons the oauth flow), a subsequent codex instance will receive
an error when attempting to log in because the localhost port is already
in use by the dangling web server from the first instance.

This PR adds a cancelation mechanism that the second instance can use to
abort the first login attempt and free up the port.
2025-09-05 14:29:00 -07:00
Ahmed Ibrahim
c11696f6b1 hide resume until it's complete (#3218)
Hide resume functionality until it's fully done.
2025-09-05 13:12:46 -07:00
pakrym-oai
5775174ec2 Never store requests (#3212)
When item ids are sent to Responses API it will load them from the
database ignoring the provided values. This adds extra latency.

Not having the mode to store requests also allows us to simplify the
code.

## Breaking change

The `disable_response_storage` configuration option is removed.
2025-09-05 10:41:47 -07:00
jif-oai
ba631e7928 ZSH on UNIX system and better detection (#3187) 2025-09-05 09:51:01 -07:00
pakrym-oai
db3834733a [BREAKING] Stop loading project .env files (#3184)
Loading project local .env often loads settings that break codex cli.

Fixes: https://github.com/openai/codex/issues/3174
2025-09-05 09:10:41 -07:00
Jeremy Rose
d6182becbe syntax-highlight bash lines (#3142)
i'm not yet convinced i have the best heuristics for what to highlight,
but this feels like a useful step towards something a bit easier to
read, esp. when the model is producing large commands.

<img width="669" height="589" alt="Screenshot 2025-09-03 at 8 21 56 PM"
src="https://github.com/user-attachments/assets/b9cbcc43-80e8-4d41-93c8-daa74b84b331"
/>

also a fairly significant refactor of our line wrapping logic.
2025-09-05 14:10:32 +00:00
Jeremy Rose
323a5cb7e7 refactor: remove AttachImage tui event (#3191)
TuiEvent is supposed to be purely events that come from the "driver",
i.e. events from the terminal. Everything app-specific should be an
AppEvent. In this case, it didn't need to be an event at all.
2025-09-05 07:02:11 -07:00
Michael Bolin
3f40fbc0a8 chore: improve serialization of ServerNotification (#3193)
This PR introduces introduces a new
`OutgoingMessage::AppServerNotification` variant that is designed to
wrap a `ServerNotification`, which makes the serialization more
straightforward compared to
`OutgoingMessage::Notification(OutgoingNotification)`. We still use the
latter for serializing an `Event` as a `JSONRPCMessage::Notification`,
but I will try to get away from that in the near future.

With this change, now the generated TypeScript type for
`ServerNotification` is:

```typescript
export type ServerNotification =
  | { "method": "authStatusChange", "params": AuthStatusChangeNotification }
  | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification };
```

whereas before it was:

```typescript
export type ServerNotification =
  | { type: "auth_status_change"; data: AuthStatusChangeNotification }
  | { type: "login_chat_gpt_complete"; data: LoginChatGptCompleteNotification };
```

Once the `Event`s are migrated to the `ServerNotification` enum in Rust,
it should be considerably easier to work with notifications on the
TypeScript side, as it will be possible to `switch (message.method)` and
check for exhaustiveness.

Though we will probably need to introduce:

```typescript
export type ServerMessage = ServerRequest | ServerNotification;
```

and then we still need to group all of the `ServerResponse` types
together, as well.
2025-09-04 17:49:50 -07:00
Jeremy Rose
742feaf40f tui: fix approval dialog for large commands (#3087)
#### Summary
- Emit a “Proposed Command” history cell when an ExecApprovalRequest
arrives (parity with proposed patches).
- Simplify the approval dialog: show only the reason/instructions; move
the command preview into history.
- Make approval/abort decision history concise:
  - Single line snippet; if multiline, show first line + " ...".
  - Truncate to 80 graphemes with ellipsis for very long commands.

#### Details
- History
- Add `new_proposed_command` to render a header and indented command
preview.
  - Use shared `prefix_lines` helper for first/subsequent line prefixes.
- Approval UI
- `UserApprovalWidget` no longer renders the command in the modal; shows
optional `reason` text only.
  - Decision history renders an inline, dimmed snippet per rules above.
- Tests (snapshot-based)
  - Proposed/decision flow for short command.
  - Proposed multi-line + aborted decision snippet with “ ...”.
  - Very long one-line command -> truncated snippet with “…”.
  - Updated existing exec approval snapshots and test reasons.

<img width="1053" height="704" alt="Screenshot 2025-09-03 at 11 57
35 AM"
src="https://github.com/user-attachments/assets/9ed4c316-9daf-4ac1-80ff-7ae1f481dd10"
/>

after approving:

<img width="1053" height="704" alt="Screenshot 2025-09-03 at 11 58
18 AM"
src="https://github.com/user-attachments/assets/a44e243f-eb9d-42ea-87f4-171b3fb481e7"
/>

rejection:

<img width="1053" height="207" alt="Screenshot 2025-09-03 at 11 58
45 AM"
src="https://github.com/user-attachments/assets/a022664b-ae0e-4b70-a388-509208707934"
/>

big command:


https://github.com/user-attachments/assets/2dd976e5-799f-4af7-9682-a046e66cc494
2025-09-04 23:54:53 +00:00
Ahmed Ibrahim
907d3dd348 MCP: add session resume + history listing; (#3185)
# 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.
2025-09-04 23:44:18 +00:00
pakrym-oai
7df9e9c664 Correctly calculate remaining context size (#3190)
We had multiple issues with context size calculation:
1. `initial_prompt_tokens` calculation based on cache size is not
reliable, cache misses might set it to much higher value. For now
hardcoded to a safer constant.
2. Input context size for GPT-5 is 272k (that's where 33% came from).

Fixes.
2025-09-04 23:34:14 +00:00
dependabot[bot]
b795fbe244 chore(deps): bump uuid from 1.17.0 to 1.18.0 in /codex-rs (#2493)
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.17.0 to 1.18.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuid-rs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v1.18.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix up mismatched_lifetime_syntaxes lint by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/837">uuid-rs/uuid#837</a></li>
<li>Conversions between <code>Timestamp</code> and
<code>std::time::SystemTime</code> by <a
href="https://github.com/dcormier"><code>@​dcormier</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/835">uuid-rs/uuid#835</a></li>
<li>Wrap the error type used in time conversions by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/838">uuid-rs/uuid#838</a></li>
<li>Prepare for 1.18.0 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/839">uuid-rs/uuid#839</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/dcormier"><code>@​dcormier</code></a>
made their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/835">uuid-rs/uuid#835</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0">https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="60a49eb94f"><code>60a49eb</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/839">#839</a> from
uuid-rs/cargo/v1.18.0</li>
<li><a
href="eb8c697083"><code>eb8c697</code></a>
prepare for 1.18.0 release</li>
<li><a
href="281f26fcd9"><code>281f26f</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/838">#838</a> from
uuid-rs/chore/time-conversion</li>
<li><a
href="2d67ab2b5e"><code>2d67ab2</code></a>
don't use allocated values in errors</li>
<li><a
href="c284ed562f"><code>c284ed5</code></a>
wrap the error type used in time conversions</li>
<li><a
href="87a4359f25"><code>87a4359</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/835">#835</a> from
dcormier/main</li>
<li><a
href="8927396625"><code>8927396</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/837">#837</a> from
uuid-rs/fix/lifetime-syntaxes</li>
<li><a
href="6dfb4b135c"><code>6dfb4b1</code></a>
Conversions between <code>Timestamp</code> and
<code>std::time::SystemTime</code></li>
<li><a
href="b508383aff"><code>b508383</code></a>
fix up mismatched_lifetime_syntaxes lint</li>
<li>See full diff in <a
href="https://github.com/uuid-rs/uuid/compare/v1.17.0...v1.18.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=uuid&package-manager=cargo&previous-version=1.17.0&new-version=1.18.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 16:30:34 -07:00
Dylan
82ed7bd285 [mcp-server] Update read config interface (#3093)
## Summary
Follow-up to #3056

This PR updates the mcp-server interface for reading the config settings
saved by the user. At risk of introducing _another_ Config struct, I
think it makes sense to avoid tying our protocol to ConfigToml, as its
become a bit unwieldy. GetConfigTomlResponse was a de-facto struct for
this already - better to make it explicit, in my opinion.

This is technically a breaking change of the mcp-server protocol, but
given the previous interface was introduced so recently in #2725, and we
have not yet even started to call it, I propose proceeding with the
breaking change - but am open to preserving the old endpoint.

## Testing
- [x] Added additional integration test coverage
2025-09-04 16:26:41 -07:00
Jeremy Rose
1c04e1314d AGENTS.md: clarify test approvals for codex-rs (#3132)
Clarifies codex-rs testing approvals in AGENTS.md:

- Allow running project-specific or individual tests without asking.
- Require asking before running the complete test suite.
- Keep `just fmt` always allowed without approval.
2025-09-04 13:36:12 -07:00
Jeremy Rose
bef7ed0ccc prompt to read AGENTS.md files (#3122) 2025-09-04 13:30:12 -07:00
Jeremy Rose
be23fe1353 Pause status timer while modals are open (#3131)
Summary:
- pause the status timer while waiting on approval modals
- expose deterministic pause/resume helpers to avoid sleep-based tests
- simplify bottom pane timer handling now that the widget owns the clock
2025-09-04 12:37:43 -07:00
Jeremy Rose
2073fa7139 tui: pager pins scroll to bottom (#3167)
when the pager is scrolled to the bottom of the buffer, keep it there.

this should make transcript mode feel a bit more "alive". i've also seen
some confusion about what transcript mode does/doesn't show that i think
has been related to it not pinning scroll.
2025-09-04 11:50:49 -07:00
Anton Panasenko
e60a44cbab [codex] move configuration for reasoning summary format to model family config type (#3171) 2025-09-04 11:00:01 -07:00
Jeremy Rose
075e385969 Use ⌥⇧⌃ glyphs for key hints on mac (#3143)
#### Summary
- render the edit queued message shortcut with the ⌥ modifier on macOS
builds
- add a helper for status indicator snapshot suffixes
- record macOS-specific snapshots for the status indicator widget
2025-09-04 10:55:50 -07:00
Michael Bolin
aa083b795d chore: add rust-lang.rust-analyzer and vadimcn.vscode-lldb to the list of recommended extensions (#3172)
`rust-lang.rust-analyzer` is clearly something all contributors should
install.

`vadimcn.vscode-lldb` is maybe debatable, but I think this is often
better that print-debugging.
2025-09-04 10:47:46 -07:00
Michael Bolin
91708bb031 fix: fix serde_as annotation and verify with test (#3170)
I didn't do https://github.com/openai/codex/pull/3163 correctly the
first time: now verified with a test.
2025-09-04 10:38:00 -07:00
Anton Panasenko
82dfec5b10 [codex] improve handling of reasoning summary (#3138)
<img width="1474" height="289" alt="Screenshot 2025-09-03 at 5 27 19 PM"
src="https://github.com/user-attachments/assets/d6febcdd-fd9c-488c-9e82-348600b1f757"
/>

Fallback to standard behavior when there is no summary in cot, and also
added tests to codify this behavior.
2025-09-04 09:45:14 -07:00
Jeremy Rose
1e82bf9d98 tui: avoid panic when active exec cell area is zero height (#3133)
#### Summary
Avoid a potential panic when rendering the active execution cell when
the allocated area has zero height.

#### Changes
- Guard rendering with `active_cell_area.height > 0` and presence of
`active_exec_cell`.
- Use `saturating_add(1)` for the Y offset to avoid overflow.
- Render via `active_exec_cell.as_ref().unwrap().render_ref(...)` after
the explicit `is_some` check.
2025-09-04 15:51:02 +00:00
Michael Bolin
0a83db5512 fix: use a more efficient wire format for ExecCommandOutputDeltaEvent.chunk (#3163)
When serializing to JSON, the existing solution created an enormous
array of ints, which is far more bytes on the wire than a base64-encoded
string would be.
2025-09-04 08:21:58 -07:00
Michael Bolin
bd4fa85507 fix: add callback to map before sending request to fix race condition (#3146)
Last week, I thought I found the smoking gun in our flaky integration
tests where holding these locks could have led to potential deadlock:

- https://github.com/openai/codex/pull/2876
- https://github.com/openai/codex/pull/2878

Yet even after those PRs went in, we continued to see flakinees in our
integration tests! Though with the additional logging added as part of
debugging those tests, I now saw things like:

```
read message from stdout: Notification(JSONRPCNotification { jsonrpc: "2.0", method: "codex/event/exec_approval_request", params: Some(Object {"id": String("0"), "msg": Object {"type": String("exec_approval_request"), "call_id": String("call1"), "command": Array [String("python3"), String("-c"), String("print(42)")], "cwd": String("/tmp/.tmpFj2zwi/workdir")}, "conversationId": String("c67b32c5-9475-41bf-8680-f4b4834ebcc6")}) })
notification: Notification(JSONRPCNotification { jsonrpc: "2.0", method: "codex/event/exec_approval_request", params: Some(Object {"id": String("0"), "msg": Object {"type": String("exec_approval_request"), "call_id": String("call1"), "command": Array [String("python3"), String("-c"), String("print(42)")], "cwd": String("/tmp/.tmpFj2zwi/workdir")}, "conversationId": String("c67b32c5-9475-41bf-8680-f4b4834ebcc6")}) })
read message from stdout: Request(JSONRPCRequest { id: Integer(0), jsonrpc: "2.0", method: "execCommandApproval", params: Some(Object {"conversation_id": String("c67b32c5-9475-41bf-8680-f4b4834ebcc6"), "call_id": String("call1"), "command": Array [String("python3"), String("-c"), String("print(42)")], "cwd": String("/tmp/.tmpFj2zwi/workdir")}) })
writing message to stdin: Response(JSONRPCResponse { id: Integer(0), jsonrpc: "2.0", result: Object {"decision": String("approved")} })
in read_stream_until_notification_message(codex/event/task_complete)
[mcp stderr] 2025-09-04T00:00:59.738585Z  INFO codex_mcp_server::message_processor: <- response: JSONRPCResponse { id: Integer(0), jsonrpc: "2.0", result: Object {"decision": String("approved")} }
[mcp stderr] 2025-09-04T00:00:59.738740Z DEBUG codex_core::codex: Submission sub=Submission { id: "1", op: ExecApproval { id: "0", decision: Approved } }
[mcp stderr] 2025-09-04T00:00:59.738832Z  WARN codex_core::codex: No pending approval found for sub_id: 0
```

That is, a response was sent for a request, but no callback was in place
to handle the response!

This time, I think I may have found the underlying issue (though the
fixes for holding locks for too long may have also been part of it),
which is I found cases where we were sending the request:


234c0a0469/codex-rs/core/src/codex.rs (L597)

before inserting the `Sender` into the `pending_approvals` map (which
has to wait on acquiring a mutex):


234c0a0469/codex-rs/core/src/codex.rs (L598-L601)

so it is possible the request could go out and the client could respond
before `pending_approvals` was updated!

Note this was happening in both `request_command_approval()` and
`request_patch_approval()`, which maps to the sorts of errors we have
been seeing when these integration tests have been flaking on us.

While here, I am also adding some extra logging that prints if inserting
into `pending_approvals` overwrites an entry as opposed to purely
inserting one. Today, a conversation can have only one pending request
at a time, but as we are planning to support parallel tool calls, this
invariant may not continue to hold, in which case we need to revisit
this abstraction.
2025-09-04 07:38:28 -07:00
Ahmed Ibrahim
234c0a0469 TUI: Add session resume picker (--resume) and quick resume (--continue) (#3135)
Adds a TUI resume flow with an interactive picker and quick resume.

- CLI: 
  - --resume / -r: open picker to resume a prior session
  - --continue   / -l: resume the most recent session (no picker)
- Behavior on resume: initial history is replayed, welcome banner
hidden, and the first redraw is suppressed to avoid flicker.
- Implementation:
- New tui/src/resume_picker.rs (paginated listing via
RolloutRecorder::list_conversations)
  - App::run accepts ResumeSelection; resumes from disk when requested
- ChatWidget refactor with ChatWidgetInit and new_from_existing; replays
initial messages
- Tests: cover picker sorting/preview extraction and resumed-history
rendering.
- Docs: getting-started updated with flags and picker usage.



https://github.com/user-attachments/assets/1bb6469b-e5d1-42f6-bec6-b1ae6debda3b
2025-09-04 06:20:40 +00:00
dependabot[bot]
0f4ae1b5b0 chore(deps): bump wiremock from 0.6.4 to 0.6.5 in /codex-rs (#2666)
Bumps [wiremock](https://github.com/LukeMathWalker/wiremock-rs) from
0.6.4 to 0.6.5.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="6b193047bf"><code>6b19304</code></a>
chore: Release wiremock version 0.6.5</li>
<li><a
href="ebaa70b024"><code>ebaa70b</code></a>
feat: Make method and MethodExactMatcher case in-sensitive (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/165">#165</a>)</li>
<li><a
href="613b4f9135"><code>613b4f9</code></a>
Make <code>BodyPrintLimit</code> public (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/167">#167</a>)</li>
<li><a
href="abfafd2227"><code>abfafd2</code></a>
chore: Upgrade all deps to their latest version (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/170">#170</a>)</li>
<li><a
href="60688cfdde"><code>60688cf</code></a>
ci: Upgrade actions. Upgrade dependencies. (<a
href="https://redirect.github.com/LukeMathWalker/wiremock-rs/issues/169">#169</a>)</li>
<li>See full diff in <a
href="https://github.com/LukeMathWalker/wiremock-rs/compare/v0.6.4...v0.6.5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=wiremock&package-manager=cargo&previous-version=0.6.4&new-version=0.6.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 23:18:42 -07:00
Ahmed Ibrahim
2b96f9f569 Dividing UserMsgs into categories to send it back to the tui (#3127)
This PR does the following:

- divides user msgs into 3 categories: plain, user instructions, and
environment context
- Centralizes adding user instructions and environment context to a
degree
- Improve the integration testing

Building on top of #3123

Specifically this
[comment](https://github.com/openai/codex/pull/3123#discussion_r2319885089).
We need to send the user message while ignoring the User Instructions
and Environment Context we attach.
2025-09-04 05:34:50 +00:00
Ahmed Ibrahim
f2036572b6 Replay EventMsgs from Response Items when resuming a session with history. (#3123)
### Overview

This PR introduces the following changes:
	1.	Adds a unified mechanism to convert ResponseItem into EventMsg.
2. Ensures that when a session is initialized with initial history, a
vector of EventMsg is sent along with the session configuration. This
allows clients to re-render the UI accordingly.
	3. 	Added integration testing

### Caveats

This implementation does not send every EventMsg that was previously
dispatched to clients. The excluded events fall into two categories:
	•	“Arguably” rolled-out events
Examples include tool calls and apply-patch calls. While these events
are conceptually rolled out, we currently only roll out ResponseItems.
These events are already being handled elsewhere and transformed into
EventMsg before being sent.
	•	Non-rolled-out events
Certain events such as TurnDiff, Error, and TokenCount are not rolled
out at all.

### Future Directions

At present, resuming a session involves maintaining two states:
	•	UI State
Clients can replay most of the important UI from the provided EventMsg
history.
	•	Model State
The model receives the complete session history to reconstruct its
internal state.

This design provides a solid foundation. If, in the future, more precise
UI reconstruction is needed, we have two potential paths:
1. Introduce a third data structure that allows us to derive both
ResponseItems and EventMsgs.
2. Clearly divide responsibilities: the core system ensures the
integrity of the model state, while clients are responsible for
reconstructing the UI.
2025-09-04 04:47:00 +00:00
jif-oai
bea64569c1 MCP sandbox call (#3128)
I have read the CLA Document and I hereby sign the CLA
2025-09-03 17:05:03 -07:00
pakrym-oai
e83c5f429c Include originator in authentication URL parameters (#3117)
Associates the client with an authentication session.
2025-09-03 16:51:00 -07:00
Dylan
ed0d23d560 [tui] Update /mcp output (#3134)
# Summary
Quick update to clean up MCP output

## Testing
- [x] Ran locally, confirmed output looked good
2025-09-03 23:38:09 +00:00
Jeremy Rose
4ae45a6c8d remove bold the keyword from prompt (#3121)
the model was often including the literal text "Bold the keyword" in
lists.
this guidance doesn't seem particularly useful to me, so just drop it.
2025-09-03 16:00:33 -07:00
Ahmed Ibrahim
6b83c1c3f3 Fix failing CI (#3130)
In this test, the ChatGPT token path is used, and the auth layer tries
to refresh the token if it thinks the token is “old.” Your helper writes
a fixed last_refresh timestamp that has now aged past the 28‑day
threshold, so the code attempts a real refresh against auth.openai.com,
never reaches the mock, and you end up with
received_requests().await.unwrap() being empty.
2025-09-03 22:38:32 +00:00
Dylan
db5276f8e6 chore: Clean up verbosity config (#3056)
## Summary
It appears that #2108 hit a merge conflict with #2355 - I failed to
notice the path difference when re-reviewing the former. This PR
rectifies that, and consolidates it into the protocol package, in line
with our philosophy of specifying types in one place.

## Testing
- [x] Adds config test for model_verbosity
2025-09-03 12:20:31 -07:00
Anton Panasenko
77fb9f3465 [codex] document use_experimental_reasoning_summary toml key config (#3118)
Follow up on https://github.com/openai/codex/issues/3101
2025-09-03 11:16:07 -07:00
Sing303
0e827b6598 Auto-approve DangerFullAccess patches on non-sandboxed platforms (#2988)
**What?**
Auto-approve patches when `SandboxPolicy::DangerFullAccess` is enabled
on platforms without sandbox support.
Changes in `codex-rs/core/src/safety.rs`: return
`SafetyCheck::AutoApprove { sandbox_type: SandboxType::None }` when no
sandbox is available and DangerFullAccess is set.

**Why?**
On platforms lacking sandbox support, requiring explicit user approval
despite `DangerFullAccess` being explicitly enabled adds friction
without additional safety. This aligns behavior with the stated policy
intent.

**How?**
Extend `assess_patch_safety` match:

* If `get_platform_sandbox()` returns `Some`, keep `AutoApprove {
sandbox_type }`.
* If `None` **and** `SandboxPolicy::DangerFullAccess`, return
`AutoApprove { SandboxType::None }`.
* Otherwise, fall back to `AskUser`.

**Tests**

* Local checks:
  ```bash
cargo test && cargo clippy --tests && cargo fmt -- --config
imports_granularity=Item
  ```
(Additionally: `just fmt`, `just fix -p codex-core`, `cargo check -p
codex-core`.)

**Docs**
No user-facing CLI changes. No README/help updates needed.

**Risk/Impact**
Reduces prompts on non-sandboxed platforms when DangerFullAccess is
explicitly chosen; consistent with policy semantics.

---------

Co-authored-by: Michael Bolin <bolinfest@gmail.com>
2025-09-03 10:57:47 -07:00
Ahmed Ibrahim
daaadfb260 Introduce Rollout Policy (#3116)
Have a helper function for deciding if we are rolling out a function or
not
2025-09-03 17:37:07 +00:00
pakrym-oai
c636f821ae Add a common way to create HTTP client (#3110)
Ensure User-Agent and originator are always sent.
2025-09-03 10:11:02 -07:00
Lionel Cheng
af338cc505 Improve @ file search: include specific hidden dirs such as .github, .gitlab (#2981)
# Improve @ file search: include specific hidden dirs

This should close #2980

## What
- Extend `@` fuzzy file search to include select top-level hidden
directories:
`.github`, `.gitlab`, `.circleci`, `.devcontainer`, `.azuredevops`,
`.vscode`, `.cursor`.
- Keep all other hidden directories excluded to avoid noise and heavy
traversals.

## Why
- Common project config lives under these dot-dirs (CI, editor,
devcontainer); users expect `@.github/...` and similar paths to resolve.
- Prior behavior hid all dot-dirs, making these files undiscoverable.

## How
- In `codex-file-search` walker:
  - Enable hidden entries via `WalkBuilder.hidden(false)`.
- Add `filter_entry` to only allow those specific root dot-directories;
other hidden paths remain filtered out.
  - Preserve `.gitignore` semantics and existing exclude handling.

## Local checks
- Ran formatting: `just fmt`
- Ran lint (scoped): `just fix -p codex-file-search`
- Ran tests:
  - `cargo test -p codex-file-search`
  - `cargo test -p codex-tui`

## Readiness
- Branch is up-to-date locally; tests pass; lint/format applied.
- No merge conflicts expected.
- Marking Ready for review.

---------

Signed-off-by: lionelchg <lionel.cheng@hotmail.fr>
2025-09-03 10:03:57 -07:00
Jeremy Rose
97000c6e6d core: correct sandboxed shell tool description (reads allowed anywhere) (#3069)
Correct the `shell` tool description for sandboxed runs and add targeted
tests.

- Fix the WorkspaceWrite description to clearly state that writes
outside the writable roots require escalated permissions; reads are not
restricted. The previous wording/formatting could be read as restricting
reads outside the workspace.
- Render the writable roots list on its own lines under a newline after
"writable roots:" for clarity.
- Show the "Commands that require network access" note only in
WorkspaceWrite when network is disabled.
- Add focused tests that call `create_shell_tool_for_sandbox` directly
and assert the exact description text for WorkspaceWrite, ReadOnly, and
DangerFullAccess.
- Update AGENTS.md to note that `just fmt` can be run automatically
without asking.
2025-09-03 10:02:34 -07:00
Gabriel Peal
fb5dfe3396 Update guidance on API key permissions (#3112)
Fixes https://github.com/openai/codex/issues/3108
2025-09-03 12:44:16 -04:00
Kazuhiro Sera
f08b08680f Merge branch 'main' into user-friendly-error-handling 2025-08-25 10:12:02 +09:00
Kazuhiro Sera
55d876404b Merge branch 'main' into user-friendly-error-handling 2025-08-24 10:37:37 +09:00
Kazuhiro Sera
efd82025a5 fix: #2606 better user experience with unrecoverable errors 2025-08-23 13:04:06 +09:00
145 changed files with 7239 additions and 3392 deletions

View File

@@ -14,33 +14,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "store_path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
path: ${{ steps.pnpm-cache.outputs.store_path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version: 22
- name: Install dependencies
run: pnpm install
run: pnpm install --frozen-lockfile
# Run all tasks using workspace filters

View File

@@ -63,6 +63,24 @@ jobs:
- name: cargo fmt
run: cargo fmt -- --config imports_granularity=Item --check
cargo_shear:
name: cargo shear
runs-on: ubuntu-24.04
needs: changed
if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }}
defaults:
run:
working-directory: codex-rs
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.89
- uses: taiki-e/install-action@v2
with:
tool: cargo-shear
version: 1.5.1
- name: cargo shear
run: cargo shear
# --- CI to validate on different os/targets --------------------------------
lint_build_test:
name: ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }}
@@ -182,7 +200,7 @@ jobs:
# --- Gatherer job that you mark as the ONLY required status -----------------
results:
name: CI results (required)
needs: [changed, general, lint_build_test]
needs: [changed, general, cargo_shear, lint_build_test]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -190,6 +208,7 @@ jobs:
shell: bash
run: |
echo "general: ${{ needs.general.result }}"
echo "shear : ${{ needs.cargo_shear.result }}"
echo "matrix : ${{ needs.lint_build_test.result }}"
# If nothing relevant changed (PR touching only root README, etc.),
@@ -201,4 +220,5 @@ jobs:
# Otherwise require the jobs to have succeeded
[[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; }
[[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; }
[[ '${{ needs.lint_build_test.result }}' == 'success' ]] || { echo 'matrix failed'; exit 1; }

View File

@@ -1,5 +1,11 @@
{
"recommendations": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
// Useful if touching files in .github/workflows, though most
// contributors will not be doing that?
// "github.vscode-github-actions",
]
}

View File

@@ -8,10 +8,10 @@ In the codex-rs folder where the rust code lives:
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspacewide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspacewide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
When running interactively, ask the user before running these commands to finalize.
When running interactively, ask the user before running `just fix` to finalize. `just fmt` does not require approval. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite.
## TUI style conventions
@@ -37,7 +37,15 @@ See `codex-rs/tui/styles.md`.
- Avoid churn: dont refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow filelocal conventions and do not introduce type annotations solely to satisfy .into().
- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines.
## Snapshot tests
### Text wrapping
- Always use textwrap::wrap to wrap plain strings.
- If you have a ratatui Line and you want to wrap it, use the helpers in tui/src/wrapping.rs, e.g. word_wrap_lines / word_wrap_line.
- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic.
- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils.
## Tests
### Snapshot tests
This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. When UI or text output changes intentionally, update the snapshots as follows:
@@ -52,3 +60,7 @@ This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to va
If you dont have the tool:
- `cargo install cargo-insta`
### Test assertions
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.

View File

@@ -75,7 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored
- [CLI usage](./docs/getting-started.md#cli-usage)
- [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input)
- [Example prompts](./docs/getting-started.md#example-prompts)
- [Memory with AGENTS.md](./docs/getting-started.md#memory--project-docs)
- [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd)
- [Configuration](./docs/config.md)
- [**Sandbox & approvals**](./docs/sandbox.md)
- [**Authentication**](./docs/authentication.md)

801
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ workspace = true
anyhow = "1"
similar = "2.7.0"
thiserror = "2.0.16"
tree-sitter = "0.25.8"
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
once_cell = "1"

View File

@@ -21,8 +21,7 @@ const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
/// `codex-linux-sandbox` we *directly* execute
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Use [`dotenvy::from_path`] and [`dotenvy::dotenv`] to modify the
/// environment before creating any threads.
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
@@ -106,7 +105,7 @@ where
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
/// Load env vars from ~/.codex/.env.
///
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
@@ -116,10 +115,6 @@ fn load_dotenv() {
{
set_filtered(iter);
}
if let Ok(iter) = dotenvy::dotenv_iter() {
set_filtered(iter);
}
}
/// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys.

View File

@@ -12,7 +12,6 @@ clap = { version = "4", features = ["derive"] }
codex-common = { path = "../common", features = ["cli"] }
codex-core = { path = "../core" }
codex-protocol = { path = "../protocol" }
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

View File

@@ -31,7 +31,7 @@ pub async fn run_apply_command(
ConfigOverrides::default(),
)?;
init_chatgpt_token_from_auth(&config.codex_home).await?;
init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?;
let task_response = get_task(&config, apply_cli.task_id).await?;
apply_diff_from_task(task_response, cwd).await

View File

@@ -1,5 +1,5 @@
use codex_core::config::Config;
use codex_core::user_agent::get_codex_user_agent;
use codex_core::default_client::create_client;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -13,10 +13,10 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
path: String,
) -> anyhow::Result<T> {
let chatgpt_base_url = &config.chatgpt_base_url;
init_chatgpt_token_from_auth(&config.codex_home).await?;
init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?;
// Make direct HTTP request to ChatGPT backend API with the token
let client = reqwest::Client::new();
let client = create_client(&config.responses_originator_header);
let url = format!("{chatgpt_base_url}{path}");
let token =
@@ -31,7 +31,6 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.header("User-Agent", get_codex_user_agent(None))
.send()
.await
.context("Failed to send request")?;

View File

@@ -19,8 +19,11 @@ pub fn set_chatgpt_token_data(value: TokenData) {
}
/// Initialize the ChatGPT token from auth.json file
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> {
let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT)?;
pub async fn init_chatgpt_token_from_auth(
codex_home: &Path,
originator: &str,
) -> std::io::Result<()> {
let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT, originator)?;
if let Some(auth) = auth {
let token_data = auth.get_token_data().await?;
set_chatgpt_token_data(token_data);

View File

@@ -12,8 +12,8 @@ use codex_protocol::mcp_protocol::AuthMode;
use std::env;
use std::path::PathBuf;
pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string());
pub async fn login_with_chatgpt(codex_home: PathBuf, originator: String) -> std::io::Result<()> {
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string(), originator);
let server = run_login_server(opts)?;
eprintln!(
@@ -27,7 +27,12 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match login_with_chatgpt(config.codex_home).await {
match login_with_chatgpt(
config.codex_home,
config.responses_originator_header.clone(),
)
.await
{
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);
@@ -60,7 +65,11 @@ pub async fn run_login_with_api_key(
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) {
match CodexAuth::from_codex_home(
&config.codex_home,
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => match auth.mode {
AuthMode::ApiKey => match auth.get_token().await {
Ok(api_key) => {

View File

@@ -40,6 +40,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
));
let NewConversation {
conversation_id: _,

View File

@@ -26,14 +26,12 @@ eventsource-stream = "0.2.3"
futures = "0.3"
libc = "0.2.175"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0"
os_info = "3.12.0"
portable-pty = "0.9.0"
rand = "0.9"
regex-lite = "0.1.7"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
sha1 = "0.10.6"
shlex = "1.3.0"
@@ -53,10 +51,9 @@ tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.4"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
uuid = { version = "1", features = ["serde", "v4"] }
whoami = "1.6.1"
wildmatch = "2.4.0"
@@ -85,3 +82,6 @@ tempfile = "3"
tokio-test = "0.4"
walkdir = "2.5.0"
wiremock = "0.6"
[package.metadata.cargo-shear]
ignored = ["openssl-sys"]

View File

@@ -14,6 +14,18 @@ Within this context, Codex refers to the open-source agentic coding interface (n
Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
# AGENTS.md spec
- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.
- These files are a way for humans to give you (the agent) instructions or tips for working within the container.
- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.
- Instructions in AGENTS.md files:
- The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.
- For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.
- Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.
- More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.
## Responsiveness
### Preamble messages
@@ -228,7 +240,6 @@ You are producing plain text that will later be styled by the CLI. Follow these
**Bullets**
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
- Keep bullets to one line unless breaking for clarity is unavoidable.
- Group into short lists (46 bullets) ordered by importance.

View File

@@ -27,6 +27,7 @@ pub struct CodexAuth {
pub(crate) api_key: Option<String>,
pub(crate) auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
pub(crate) auth_file: PathBuf,
pub(crate) client: reqwest::Client,
}
impl PartialEq for CodexAuth {
@@ -36,22 +37,13 @@ impl PartialEq for CodexAuth {
}
impl CodexAuth {
pub fn from_api_key(api_key: &str) -> Self {
Self {
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
auth_file: PathBuf::new(),
auth_dot_json: Arc::new(Mutex::new(None)),
}
}
pub async fn refresh_token(&self) -> Result<String, std::io::Error> {
let token_data = self
.get_current_token_data()
.ok_or(std::io::Error::other("Token data is not available."))?;
let token = token_data.refresh_token;
let refresh_response = try_refresh_token(token)
let refresh_response = try_refresh_token(token, &self.client)
.await
.map_err(std::io::Error::other)?;
@@ -83,8 +75,9 @@ impl CodexAuth {
pub fn from_codex_home(
codex_home: &Path,
preferred_auth_method: AuthMode,
originator: &str,
) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home, true, preferred_auth_method)
load_auth(codex_home, true, preferred_auth_method, originator)
}
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
@@ -98,7 +91,7 @@ impl CodexAuth {
if last_refresh < Utc::now() - chrono::Duration::days(28) {
let refresh_response = tokio::time::timeout(
Duration::from_secs(60),
try_refresh_token(tokens.refresh_token.clone()),
try_refresh_token(tokens.refresh_token.clone(), &self.client),
)
.await
.map_err(|_| {
@@ -180,8 +173,26 @@ impl CodexAuth {
mode: AuthMode::ChatGPT,
auth_file: PathBuf::new(),
auth_dot_json,
client: crate::default_client::create_client("codex_cli_rs"),
}
}
fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self {
Self {
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
auth_file: PathBuf::new(),
auth_dot_json: Arc::new(Mutex::new(None)),
client,
}
}
pub fn from_api_key(api_key: &str) -> Self {
Self::from_api_key_with_client(
api_key,
crate::default_client::create_client(crate::default_client::DEFAULT_ORIGINATOR),
)
}
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
@@ -221,18 +232,20 @@ fn load_auth(
codex_home: &Path,
include_env_var: bool,
preferred_auth_method: AuthMode,
originator: &str,
) -> std::io::Result<Option<CodexAuth>> {
// First, check to see if there is a valid auth.json file. If not, we fall
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
// (if it is set).
let auth_file = get_auth_file(codex_home);
let client = crate::default_client::create_client(originator);
let auth_dot_json = match try_read_auth_json(&auth_file) {
Ok(auth) => auth,
// If auth.json does not exist, try to read the OPENAI_API_KEY from the
// environment variable.
Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
return match read_openai_api_key_from_env() {
Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))),
None => Ok(None),
};
}
@@ -258,7 +271,7 @@ fn load_auth(
match &tokens {
Some(tokens) => {
if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) {
return Ok(Some(CodexAuth::from_api_key(api_key)));
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
} else {
// Ignore the API key and fall through to ChatGPT auth.
}
@@ -268,7 +281,7 @@ fn load_auth(
// Perhaps the user ran `codex login --api-key <KEY>` or updated
// auth.json by hand. Either way, let's assume they are trying
// to use their API key.
return Ok(Some(CodexAuth::from_api_key(api_key)));
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
}
}
}
@@ -284,6 +297,7 @@ fn load_auth(
tokens,
last_refresh,
}))),
client,
}))
}
@@ -333,7 +347,10 @@ async fn update_tokens(
Ok(auth_dot_json)
}
async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
async fn try_refresh_token(
refresh_token: String,
client: &reqwest::Client,
) -> std::io::Result<RefreshResponse> {
let refresh_request = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
@@ -341,7 +358,7 @@ async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResp
scope: "openid profile email",
};
let client = reqwest::Client::new();
// Use shared client factory to include standard headers
let response = client
.post("https://auth.openai.com/oauth/token")
.header("Content-Type", "application/json")
@@ -455,7 +472,8 @@ mod tests {
mode,
auth_dot_json,
auth_file: _,
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
..
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(None, api_key);
@@ -506,7 +524,8 @@ mod tests {
mode,
auth_dot_json,
auth_file: _,
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
..
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(None, api_key);
@@ -556,7 +575,8 @@ mod tests {
mode,
auth_dot_json,
auth_file: _,
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
..
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(Some("sk-test-key".to_string()), api_key);
@@ -576,7 +596,7 @@ mod tests {
)
.unwrap();
let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT)
let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey);
@@ -660,6 +680,7 @@ mod tests {
#[derive(Debug)]
pub struct AuthManager {
codex_home: PathBuf,
originator: String,
inner: RwLock<CachedAuth>,
}
@@ -668,12 +689,13 @@ impl AuthManager {
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode)
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator)
.ok()
.flatten();
Self {
codex_home,
originator,
inner: RwLock::new(CachedAuth {
preferred_auth_mode,
auth,
@@ -690,6 +712,7 @@ impl AuthManager {
};
Arc::new(Self {
codex_home: PathBuf::new(),
originator: "codex_cli_rs".to_string(),
inner: RwLock::new(cached),
})
}
@@ -711,7 +734,7 @@ impl AuthManager {
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let preferred = self.preferred_auth_method();
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred)
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator)
.ok()
.flatten();
if let Ok(mut guard) = self.inner.write() {
@@ -732,8 +755,12 @@ impl AuthManager {
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc<Self> {
Arc::new(Self::new(codex_home, preferred_auth_mode))
pub fn shared(
codex_home: PathBuf,
preferred_auth_mode: AuthMode,
originator: String,
) -> Arc<Self> {
Arc::new(Self::new(codex_home, preferred_auth_mode, originator))
}
/// Attempt to refresh the current auth token (if any). On success, reload

View File

@@ -6,6 +6,7 @@ use std::time::Duration;
use crate::AuthManager;
use bytes::Bytes;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ConversationId;
use eventsource_stream::Eventsource;
use futures::prelude::*;
use regex_lite::Regex;
@@ -19,7 +20,6 @@ use tokio_util::io::ReaderStream;
use tracing::debug;
use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use crate::chat_completions::AggregateStreamExt;
use crate::chat_completions::stream_chat_completions;
@@ -30,6 +30,7 @@ use crate::client_common::ResponsesApiRequest;
use crate::client_common::create_reasoning_param_for_request;
use crate::client_common::create_text_param_for_request;
use crate::config::Config;
use crate::default_client::create_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::UsageLimitReachedError;
@@ -40,7 +41,6 @@ use crate::model_provider_info::WireApi;
use crate::openai_model_info::get_model_info;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::TokenUsage;
use crate::user_agent::get_codex_user_agent;
use crate::util::backoff;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -70,7 +70,7 @@ pub struct ModelClient {
auth_manager: Option<Arc<AuthManager>>,
client: reqwest::Client,
provider: ModelProviderInfo,
session_id: Uuid,
conversation_id: ConversationId,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
}
@@ -82,14 +82,16 @@ impl ModelClient {
provider: ModelProviderInfo,
effort: ReasoningEffortConfig,
summary: ReasoningSummaryConfig,
session_id: Uuid,
conversation_id: ConversationId,
) -> Self {
let client = create_client(&config.responses_originator_header);
Self {
config,
auth_manager,
client: reqwest::Client::new(),
client,
provider,
session_id,
conversation_id,
effort,
summary,
}
@@ -155,14 +157,6 @@ impl ModelClient {
let auth_manager = self.auth_manager.clone();
let auth_mode = auth_manager
.as_ref()
.and_then(|m| m.auth())
.as_ref()
.map(|a| a.mode);
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
let tools_json = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = create_reasoning_param_for_request(
@@ -171,9 +165,7 @@ impl ModelClient {
self.summary,
);
// Request encrypted COT if we are not storing responses,
// otherwise reasoning items will be referenced by ID
let include: Vec<String> = if !store && reasoning.is_some() {
let include: Vec<String> = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
vec![]
@@ -202,10 +194,10 @@ impl ModelClient {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning,
store,
store: false,
stream: true,
include,
prompt_cache_key: Some(self.session_id.to_string()),
prompt_cache_key: Some(self.conversation_id.to_string()),
text,
};
@@ -231,7 +223,9 @@ impl ModelClient {
req_builder = req_builder
.header("OpenAI-Beta", "responses=experimental")
.header("session_id", self.session_id.to_string())
// Send session_id for compatibility.
.header("conversation_id", self.conversation_id.to_string())
.header("session_id", self.conversation_id.to_string())
.header(reqwest::header::ACCEPT, "text/event-stream")
.json(&payload);
@@ -242,10 +236,6 @@ impl ModelClient {
req_builder = req_builder.header("chatgpt-account-id", account_id);
}
let originator = &self.config.responses_originator_header;
req_builder = req_builder.header("originator", originator);
req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
let res = req_builder.send().await;
if let Ok(resp) = &res {
trace!(
@@ -330,6 +320,9 @@ impl ModelClient {
if status == StatusCode::INTERNAL_SERVER_ERROR {
return Err(CodexErr::InternalServerError);
}
if status == StatusCode::UNAUTHORIZED {
return Err(CodexErr::UnauthorizedError);
}
return Err(CodexErr::RetryLimit(status));
}
@@ -410,9 +403,15 @@ impl From<ResponseCompletedUsage> for TokenUsage {
fn from(val: ResponseCompletedUsage) -> Self {
TokenUsage {
input_tokens: val.input_tokens,
cached_input_tokens: val.input_tokens_details.map(|d| d.cached_tokens),
cached_input_tokens: val
.input_tokens_details
.map(|d| d.cached_tokens)
.unwrap_or(0),
output_tokens: val.output_tokens,
reasoning_output_tokens: val.output_tokens_details.map(|d| d.reasoning_tokens),
reasoning_output_tokens: val
.output_tokens_details
.map(|d| d.reasoning_tokens)
.unwrap_or(0),
total_tokens: val.total_tokens,
}
}

View File

@@ -1,4 +1,3 @@
use crate::config_types::Verbosity as VerbosityConfig;
use crate::error::Result;
use crate::model_family::ModelFamily;
use crate::openai_tools::OpenAiTool;
@@ -6,7 +5,7 @@ use crate::protocol::TokenUsage;
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ResponseItem;
use futures::Stream;
use serde::Serialize;
@@ -20,19 +19,12 @@ use tokio::sync::mpsc;
/// with this content.
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
/// wraps user instructions message in a tag for the model to parse more easily.
const USER_INSTRUCTIONS_START: &str = "<user_instructions>\n\n";
const USER_INSTRUCTIONS_END: &str = "\n\n</user_instructions>";
/// API request payload for a single model turn
#[derive(Default, Debug, Clone)]
pub struct Prompt {
/// Conversation context input items.
pub input: Vec<ResponseItem>,
/// Whether to store response on server side (disable_response_storage = !store).
pub store: bool,
/// Tools available to the model, including additional tools sourced from
/// external MCP servers.
pub(crate) tools: Vec<OpenAiTool>,
@@ -68,17 +60,6 @@ impl Prompt {
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
self.input.clone()
}
/// Creates a formatted user instructions message from a string
pub(crate) fn format_user_instructions_message(ui: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!("{USER_INSTRUCTIONS_START}{ui}{USER_INSTRUCTIONS_END}"),
}],
}
}
}
#[derive(Debug)]
@@ -144,7 +125,6 @@ pub(crate) struct ResponsesApiRequest<'a> {
pub(crate) tool_choice: &'static str,
pub(crate) parallel_tool_calls: bool,
pub(crate) reasoning: Option<Reasoning>,
/// true when using the Responses API.
pub(crate) store: bool,
pub(crate) stream: bool,
pub(crate) include: Vec<String>,
@@ -215,7 +195,7 @@ mod tests {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: true,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,
@@ -245,7 +225,7 @@ mod tests {
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: None,
store: true,
store: false,
stream: true,
include: vec![],
prompt_cache_key: None,

View File

@@ -9,11 +9,13 @@ use std::sync::atomic::AtomicU64;
use std::time::Duration;
use crate::AuthManager;
use crate::event_mapping::map_response_item_to_event_messages;
use async_channel::Receiver;
use async_channel::Sender;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::protocol::ConversationHistoryResponseEvent;
use codex_protocol::protocol::TaskStartedEvent;
use codex_protocol::protocol::TurnAbortReason;
@@ -29,7 +31,6 @@ use tracing::error;
use tracing::info;
use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use crate::ModelProviderInfo;
use crate::apply_patch;
@@ -75,9 +76,7 @@ use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningDeltaEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentDeltaEvent;
use crate::protocol::AgentReasoningRawContentEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
@@ -100,15 +99,17 @@ use crate::protocol::SessionConfiguredEvent;
use crate::protocol::StreamErrorEvent;
use crate::protocol::Submission;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TokenUsageInfo;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::protocol::WebSearchEndEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
use crate::safety::SafetyCheck;
use crate::safety::assess_command_safety;
use crate::safety::assess_safety_for_untrusted_command;
use crate::shell;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::user_instructions::UserInstructions;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
@@ -117,12 +118,9 @@ use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::models::WebSearchAction;
// A convenience extension trait for acquiring mutex locks where poisoning is
// unrecoverable and should abort the program. This avoids scattered `.unwrap()`
@@ -152,7 +150,7 @@ pub struct Codex {
/// unique session id.
pub struct CodexSpawnOk {
pub codex: Codex,
pub session_id: Uuid,
pub conversation_id: ConversationId,
}
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
@@ -188,7 +186,6 @@ impl Codex {
base_instructions: config.base_instructions.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
disable_response_storage: config.disable_response_storage,
notify: config.notify.clone(),
cwd: config.cwd.clone(),
};
@@ -199,6 +196,7 @@ impl Codex {
config.clone(),
auth_manager.clone(),
tx_event.clone(),
conversation_history.clone(),
)
.await
.map_err(|e| {
@@ -208,7 +206,7 @@ impl Codex {
session
.record_initial_history(&turn_context, conversation_history)
.await;
let session_id = session.session_id;
let conversation_id = session.conversation_id;
// This task will run until Op::Shutdown is received.
tokio::spawn(submission_loop(
@@ -223,7 +221,10 @@ impl Codex {
rx_event,
};
Ok(CodexSpawnOk { codex, session_id })
Ok(CodexSpawnOk {
codex,
conversation_id,
})
}
/// Submit the `op` wrapped in a `Submission` with a unique ID.
@@ -265,13 +266,14 @@ struct State {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
history: ConversationHistory,
token_info: Option<TokenUsageInfo>,
}
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
session_id: Uuid,
conversation_id: ConversationId,
tx_event: Sender<Event>,
/// Manager for external MCP servers/tools.
@@ -304,7 +306,6 @@ pub(crate) struct TurnContext {
pub(crate) approval_policy: AskForApproval,
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) disable_response_storage: bool,
pub(crate) tools_config: ToolsConfig,
}
@@ -337,8 +338,6 @@ struct ConfigureSession {
approval_policy: AskForApproval,
/// How to sandbox commands executed in the system
sandbox_policy: SandboxPolicy,
/// Disable server-side response storage (send full context each request)
disable_response_storage: bool,
/// Optional external notifier command tokens. Present only when the
/// client wants the agent to spawn a program after each completed
@@ -361,8 +360,8 @@ impl Session {
config: Arc<Config>,
auth_manager: Arc<AuthManager>,
tx_event: Sender<Event>,
initial_history: InitialHistory,
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
let session_id = Uuid::new_v4();
let ConfigureSession {
provider,
model,
@@ -372,7 +371,6 @@ impl Session {
base_instructions,
approval_policy,
sandbox_policy,
disable_response_storage,
notify,
cwd,
} = configure_session;
@@ -381,6 +379,20 @@ impl Session {
return Err(anyhow::anyhow!("cwd is not absolute: {cwd:?}"));
}
let (conversation_id, rollout_params) = match &initial_history {
InitialHistory::New | InitialHistory::Forked(_) => {
let conversation_id = ConversationId::default();
(
conversation_id,
RolloutRecorderParams::new(conversation_id, user_instructions.clone()),
)
}
InitialHistory::Resumed(resumed_history) => (
resumed_history.conversation_id,
RolloutRecorderParams::resume(resumed_history.rollout_path.clone()),
),
};
// Error messages to dispatch after SessionConfigured is sent.
let mut post_session_configured_error_events = Vec::<Event>::new();
@@ -390,10 +402,10 @@ impl Session {
// - spin up MCP connection manager
// - perform default shell discovery
// - load history metadata
let rollout_fut = RolloutRecorder::new(&config, session_id, user_instructions.clone());
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
let mcp_fut = McpConnectionManager::new(config.mcp_servers.clone());
let default_shell_fut = shell::default_user_shell();
let default_shell_fut = shell::default_user_shell(conversation_id.0, &config.codex_home);
let history_meta_fut = crate::message_history::history_metadata(&config);
// Join all independent futures.
@@ -436,7 +448,7 @@ impl Session {
}
}
// Now that `session_id` is final (may have been updated by resume),
// Now that the conversation id is final (may have been updated by resume),
// construct the model client.
let client = ModelClient::new(
config.clone(),
@@ -444,7 +456,7 @@ impl Session {
provider.clone(),
model_reasoning_effort,
model_reasoning_summary,
session_id,
conversation_id,
);
let turn_context = TurnContext {
client,
@@ -464,10 +476,10 @@ impl Session {
sandbox_policy,
shell_environment_policy: config.shell_environment_policy.clone(),
cwd,
disable_response_storage,
};
let sess = Arc::new(Session {
session_id,
conversation_id,
tx_event: tx_event.clone(),
mcp_connection_manager,
session_manager: ExecSessionManager::default(),
@@ -480,13 +492,23 @@ impl Session {
});
// Dispatch the SessionConfiguredEvent first and then report any errors.
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
let initial_messages = match &initial_history {
InitialHistory::New => None,
InitialHistory::Forked(items) => Some(sess.build_initial_messages(items)),
InitialHistory::Resumed(resumed_history) => {
Some(sess.build_initial_messages(&resumed_history.history))
}
};
let events = std::iter::once(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id,
session_id: conversation_id,
model,
history_log_id,
history_entry_count,
initial_messages,
}),
})
.chain(post_session_configured_error_events.into_iter());
@@ -525,8 +547,12 @@ impl Session {
InitialHistory::New => {
self.record_initial_history_new(turn_context).await;
}
InitialHistory::Resumed(items) => {
self.record_initial_history_resumed(items).await;
InitialHistory::Forked(items) => {
self.record_initial_history_from_items(items).await;
}
InitialHistory::Resumed(resumed_history) => {
self.record_initial_history_from_items(resumed_history.history)
.await;
}
}
}
@@ -537,7 +563,7 @@ impl Session {
// TODO: Those items shouldn't be "user messages" IMO. Maybe developer messages.
let mut conversation_items = Vec::<ResponseItem>::with_capacity(2);
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
conversation_items.push(Prompt::format_user_instructions_message(user_instructions));
conversation_items.push(UserInstructions::new(user_instructions.to_string()).into());
}
conversation_items.push(ResponseItem::from(EnvironmentContext::new(
Some(turn_context.cwd.clone()),
@@ -548,8 +574,19 @@ impl Session {
self.record_conversation_items(&conversation_items).await;
}
async fn record_initial_history_resumed(&self, items: Vec<ResponseItem>) {
self.record_conversation_items(&items).await;
async fn record_initial_history_from_items(&self, items: Vec<ResponseItem>) {
self.record_conversation_items_internal(&items, false).await;
}
/// build the initial messages vector for SessionConfigured by converting
/// ResponseItems into EventMsg.
fn build_initial_messages(&self, items: &[ResponseItem]) -> Vec<EventMsg> {
items
.iter()
.flat_map(|item| {
map_response_item_to_event_messages(item, self.show_raw_agent_reasoning)
})
.collect()
}
/// Sends the given event to the client and swallows the send event, if
@@ -568,9 +605,19 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve)
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let event = Event {
id: sub_id.clone(),
id: event_id,
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id,
command,
@@ -579,10 +626,6 @@ impl Session {
}),
};
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve);
}
rx_approve
}
@@ -594,9 +637,19 @@ impl Session {
reason: Option<String>,
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve)
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let event = Event {
id: sub_id.clone(),
id: event_id,
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
changes: convert_apply_patch_to_protocol(action),
@@ -605,10 +658,6 @@ impl Session {
}),
};
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve);
}
rx_approve
}
@@ -635,8 +684,14 @@ impl Session {
/// Records items to both the rollout and the chat completions/ZDR
/// transcript, if enabled.
async fn record_conversation_items(&self, items: &[ResponseItem]) {
self.record_conversation_items_internal(items, true).await;
}
async fn record_conversation_items_internal(&self, items: &[ResponseItem], persist: bool) {
debug!("Recording items for conversation: {items:?}");
self.record_state_snapshot(items).await;
if persist {
self.record_state_snapshot(items).await;
}
self.state.lock_unchecked().history.record_items(items);
}
@@ -1060,7 +1115,7 @@ async fn submission_loop(
provider,
effective_effort,
effective_summary,
sess.session_id,
sess.conversation_id,
);
let new_approval_policy = approval_policy.unwrap_or(prev.approval_policy);
@@ -1089,7 +1144,6 @@ async fn submission_loop(
sandbox_policy: new_sandbox_policy.clone(),
shell_environment_policy: prev.shell_environment_policy.clone(),
cwd: new_cwd.clone(),
disable_response_storage: prev.disable_response_storage,
};
// Install the new persistent context for subsequent tasks/turns.
@@ -1149,7 +1203,7 @@ async fn submission_loop(
provider,
effort,
summary,
sess.session_id,
sess.conversation_id,
);
let fresh_turn_context = TurnContext {
@@ -1171,7 +1225,6 @@ async fn submission_loop(
sandbox_policy,
shell_environment_policy: turn_context.shell_environment_policy.clone(),
cwd,
disable_response_storage: turn_context.disable_response_storage,
};
// TODO: record the new environment context in the conversation history
// no current task, spawn a new one with the perturn context
@@ -1193,7 +1246,7 @@ async fn submission_loop(
other => sess.notify_approval(&id, other),
},
Op::AddToHistory { text } => {
let id = sess.session_id;
let id = sess.conversation_id;
let config = config.clone();
tokio::spawn(async move {
if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await
@@ -1224,7 +1277,7 @@ async fn submission_loop(
log_id,
entry: entry_opt.map(|e| {
codex_protocol::message_history::HistoryEntry {
session_id: e.session_id,
conversation_id: e.session_id,
ts: e.ts,
text: e.text,
}
@@ -1330,7 +1383,7 @@ async fn submission_loop(
let event = Event {
id: sub_id.clone(),
msg: EventMsg::ConversationHistory(ConversationHistoryResponseEvent {
conversation_id: sess.session_id,
conversation_id: sess.conversation_id,
entries: sess.state.lock_unchecked().history.contents(),
}),
};
@@ -1576,7 +1629,6 @@ async fn run_turn(
let prompt = Prompt {
input,
store: !turn_context.disable_response_storage,
tools,
base_instructions_override: turn_context.base_instructions.clone(),
};
@@ -1587,7 +1639,14 @@ async fn run_turn(
Ok(output) => return Ok(output),
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)),
Err(e @ (CodexErr::UsageLimitReached(_) | CodexErr::UsageNotIncluded)) => {
Err(
e @ (CodexErr::UsageLimitReached(_)
| CodexErr::UsageNotIncluded
| CodexErr::UnexpectedStatus(_, _)
| CodexErr::RetryLimit(_)
| CodexErr::UnauthorizedError
| CodexErr::InternalServerError),
) => {
return Err(e);
}
Err(e) => {
@@ -1748,15 +1807,23 @@ async fn try_run_turn(
response_id: _,
token_usage,
} => {
if let Some(token_usage) = token_usage {
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage),
})
.await
.ok();
}
let info = {
let mut st = sess.state.lock_unchecked();
let info = TokenUsageInfo::new_or_append(
&st.token_info,
&token_usage,
turn_context.client.get_model_context_window(),
);
st.token_info = info.clone();
info
};
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }),
})
.await
.ok();
let unified_diff = turn_diff_tracker.get_unified_diff();
if let Ok(Some(unified_diff)) = unified_diff {
@@ -1830,7 +1897,6 @@ async fn run_compact_task(
let prompt = Prompt {
input: turn_input,
store: !turn_context.disable_response_storage,
tools: Vec::new(),
base_instructions_override: Some(compact_instructions.clone()),
};
@@ -1903,53 +1969,6 @@ async fn handle_response_item(
) -> CodexResult<Option<ResponseInputItem>> {
debug!(?item, "Output item");
let output = match item {
ResponseItem::Message { content, .. } => {
for item in content {
if let ContentItem::OutputText { text } = item {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentMessage(AgentMessageEvent { message: text }),
};
sess.tx_event.send(event).await.ok();
}
}
None
}
ResponseItem::Reasoning {
id: _,
summary,
content,
encrypted_content: _,
} => {
for item in summary {
let text = match item {
ReasoningItemReasoningSummary::SummaryText { text } => text,
};
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoning(AgentReasoningEvent { text }),
};
sess.tx_event.send(event).await.ok();
}
if sess.show_raw_agent_reasoning
&& let Some(content) = content
{
for item in content {
let text = match item {
ReasoningItemContent::ReasoningText { text } => text,
ReasoningItemContent::Text { text } => text,
};
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent {
text,
}),
};
sess.tx_event.send(event).await.ok();
}
}
None
}
ResponseItem::FunctionCall {
name,
arguments,
@@ -2039,12 +2058,14 @@ async fn handle_response_item(
debug!("unexpected CustomToolCallOutput from stream");
None
}
ResponseItem::WebSearchCall { id, action, .. } => {
if let WebSearchAction::Search { query } = action {
let call_id = id.unwrap_or_else(|| "".to_string());
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. } => {
let msgs = map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning);
for msg in msgs {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::WebSearchEnd(WebSearchEndEvent { call_id, query }),
msg,
};
sess.tx_event.send(event).await.ok();
}
@@ -2310,13 +2331,25 @@ pub struct ExecInvokeArgs<'a> {
pub stdout_stream: Option<StdoutStream>,
}
fn should_translate_shell_command(
shell: &crate::shell::Shell,
shell_policy: &ShellEnvironmentPolicy,
) -> bool {
matches!(shell, crate::shell::Shell::PowerShell(_))
|| shell_policy.use_profile
|| matches!(
shell,
crate::shell::Shell::Posix(shell) if shell.shell_snapshot.is_some()
)
}
fn maybe_translate_shell_command(
params: ExecParams,
sess: &Session,
turn_context: &TurnContext,
) -> ExecParams {
let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_))
|| turn_context.shell_environment_policy.use_profile;
let should_translate =
should_translate_shell_command(&sess.user_shell, &turn_context.shell_environment_policy);
if should_translate
&& let Some(command) = sess
@@ -2868,13 +2901,21 @@ async fn drain_to_completed(
response_id: _,
token_usage,
}) => {
// some providers don't return token usage, so we default
// TODO: consider approximate token usage
let token_usage = token_usage.unwrap_or_default();
let info = {
let mut st = sess.state.lock_unchecked();
let info = TokenUsageInfo::new_or_append(
&st.token_info,
&token_usage,
turn_context.client.get_model_context_window(),
);
st.token_info = info.clone();
info
};
sess.tx_event
.send(Event {
id: sub_id.to_string(),
msg: EventMsg::TokenCount(token_usage),
msg: EventMsg::TokenCount(crate::protocol::TokenCountEvent { info }),
})
.await
.ok();
@@ -2925,10 +2966,15 @@ fn convert_call_tool_result_to_function_call_output_payload(
#[cfg(test)]
mod tests {
use super::*;
use crate::config_types::ShellEnvironmentPolicyInherit;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use pretty_assertions::assert_eq;
use serde_json::json;
use shell::ShellSnapshot;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration as StdDuration;
fn text_block(s: &str) -> ContentBlock {
@@ -2939,6 +2985,48 @@ mod tests {
})
}
fn shell_policy_with_profile(use_profile: bool) -> ShellEnvironmentPolicy {
ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: false,
exclude: Vec::new(),
r#set: HashMap::new(),
include_only: Vec::new(),
use_profile,
}
}
fn zsh_shell(shell_snapshot: Option<Arc<ShellSnapshot>>) -> shell::Shell {
shell::Shell::Posix(shell::PosixShell {
shell_path: "/bin/zsh".to_string(),
rc_path: "/Users/example/.zshrc".to_string(),
shell_snapshot,
})
}
#[test]
fn translates_commands_when_shell_policy_requests_profile() {
let policy = shell_policy_with_profile(true);
let shell = zsh_shell(None);
assert!(should_translate_shell_command(&shell, &policy));
}
#[test]
fn translates_commands_for_zsh_with_snapshot() {
let policy = shell_policy_with_profile(false);
let shell = zsh_shell(Some(Arc::new(ShellSnapshot::new(PathBuf::from(
"/tmp/snapshot",
)))));
assert!(should_translate_shell_command(&shell, &policy));
}
#[test]
fn bypasses_translation_for_zsh_without_snapshot_or_profile() {
let policy = shell_policy_with_profile(false);
let shell = zsh_shell(None);
assert!(!should_translate_shell_command(&shell, &policy));
}
#[test]
fn prefers_structured_content_when_present() {
let ctr = CallToolResult {

View File

@@ -1,12 +1,12 @@
use crate::config_profile::ConfigProfile;
use crate::config_types::History;
use crate::config_types::McpServerConfig;
use crate::config_types::ReasoningSummaryFormat;
use crate::config_types::SandboxWorkspaceWrite;
use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::ShellEnvironmentPolicyToml;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use crate::config_types::Verbosity;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_family::ModelFamily;
use crate::model_family::find_family_for_model;
@@ -18,7 +18,10 @@ use crate::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use dirs::home_dir;
use serde::Deserialize;
use std::collections::HashMap;
@@ -75,11 +78,6 @@ pub struct Config {
/// Defaults to `false`.
pub show_raw_agent_reasoning: bool,
/// Disable server-side response storage (sends the full conversation
/// context with every request). Currently necessary for OpenAI customers
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: bool,
/// User-provided instructions from AGENTS.md.
pub user_instructions: Option<String>,
@@ -185,8 +183,6 @@ pub struct Config {
/// All characters are inserted as they are received, and no buffering
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: bool,
pub use_experimental_reasoning_summary: bool,
}
impl Config {
@@ -416,11 +412,6 @@ pub struct ConfigToml {
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
/// Disable server-side response storage (sends the full conversation
/// context with every request). Currently necessary for OpenAI customers
/// who have opted into Zero Data Retention (ZDR).
pub disable_response_storage: Option<bool>,
/// Optional external command to spawn for end-user notifications.
#[serde(default)]
pub notify: Option<Vec<String>>,
@@ -473,6 +464,9 @@ pub struct ConfigToml {
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// Override to force reasoning summary format for the configured model.
pub model_reasoning_summary_format: Option<ReasoningSummaryFormat>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: Option<String>,
@@ -484,8 +478,6 @@ pub struct ConfigToml {
pub experimental_use_exec_command_tool: Option<bool>,
pub use_experimental_reasoning_summary: Option<bool>,
/// The value for the `originator` header included with Responses API requests.
pub responses_originator_header_internal_override: Option<String>,
@@ -503,6 +495,29 @@ pub struct ConfigToml {
pub disable_paste_burst: Option<bool>,
}
impl From<ConfigToml> for UserSavedConfig {
fn from(config_toml: ConfigToml) -> Self {
let profiles = config_toml
.profiles
.into_iter()
.map(|(k, v)| (k, v.into()))
.collect();
Self {
approval_policy: config_toml.approval_policy,
sandbox_mode: config_toml.sandbox_mode,
sandbox_settings: config_toml.sandbox_workspace_write.map(From::from),
model: config_toml.model,
model_reasoning_effort: config_toml.model_reasoning_effort,
model_reasoning_summary: config_toml.model_reasoning_summary,
model_verbosity: config_toml.model_verbosity,
tools: config_toml.tools.map(From::from),
profile: config_toml.profile,
profiles,
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProjectConfig {
pub trust_level: Option<String>,
@@ -518,6 +533,15 @@ pub struct ToolsToml {
pub view_image: Option<bool>,
}
impl From<ToolsToml> for Tools {
fn from(tools_toml: ToolsToml) -> Self {
Self {
web_search: tools_toml.web_search,
view_image: tools_toml.view_image,
}
}
}
impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
fn derive_sandbox_policy(&self, sandbox_mode_override: Option<SandboxMode>) -> SandboxPolicy {
@@ -606,7 +630,6 @@ pub struct ConfigOverrides {
pub include_plan_tool: Option<bool>,
pub include_apply_patch_tool: Option<bool>,
pub include_view_image_tool: Option<bool>,
pub disable_response_storage: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
}
@@ -634,7 +657,6 @@ impl Config {
include_plan_tool,
include_apply_patch_tool,
include_view_image_tool,
disable_response_storage,
show_raw_agent_reasoning,
tools_web_search_request: override_tools_web_search_request,
} = overrides;
@@ -710,19 +732,24 @@ impl Config {
.or(config_profile.model)
.or(cfg.model)
.unwrap_or_else(default_model);
let model_family = find_family_for_model(&model).unwrap_or_else(|| {
let supports_reasoning_summaries =
cfg.model_supports_reasoning_summaries.unwrap_or(false);
ModelFamily {
slug: model.clone(),
family: model.clone(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
}
let mut model_family = find_family_for_model(&model).unwrap_or_else(|| ModelFamily {
slug: model.clone(),
family: model.clone(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
});
if let Some(supports_reasoning_summaries) = cfg.model_supports_reasoning_summaries {
model_family.supports_reasoning_summaries = supports_reasoning_summaries;
}
if let Some(model_reasoning_summary_format) = cfg.model_reasoning_summary_format {
model_family.reasoning_summary_format = model_reasoning_summary_format;
}
let openai_model_info = get_model_info(&model_family);
let model_context_window = cfg
.model_context_window
@@ -764,11 +791,6 @@ impl Config {
.unwrap_or_else(AskForApproval::default),
sandbox_policy,
shell_environment_policy,
disable_response_storage: config_profile
.disable_response_storage
.or(cfg.disable_response_storage)
.or(disable_response_storage)
.unwrap_or(false),
notify: cfg.notify,
user_instructions,
base_instructions,
@@ -811,9 +833,6 @@ impl Config {
.unwrap_or(false),
include_view_image_tool,
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
use_experimental_reasoning_summary: cfg
.use_experimental_reasoning_summary
.unwrap_or(false),
};
Ok(config)
}
@@ -1036,7 +1055,6 @@ exclude_slash_tmp = true
let toml = r#"
model = "o3"
approval_policy = "untrusted"
disable_response_storage = false
# Can be used to determine which profile to use if not specified by
# `ConfigOverrides`.
@@ -1066,7 +1084,14 @@ model_provider = "openai-chat-completions"
model = "o3"
model_provider = "openai"
approval_policy = "on-failure"
disable_response_storage = true
[profiles.gpt5]
model = "gpt-5"
model_provider = "openai"
approval_policy = "on-failure"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
model_verbosity = "high"
"#;
let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed");
@@ -1156,7 +1181,6 @@ disable_response_storage = true
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1184,7 +1208,6 @@ disable_response_storage = true
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
},
o3_profile_config
);
@@ -1215,7 +1238,6 @@ disable_response_storage = true
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1243,7 +1265,6 @@ disable_response_storage = true
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -1289,7 +1310,6 @@ disable_response_storage = true
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: true,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
@@ -1317,7 +1337,6 @@ disable_response_storage = true
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
@@ -1325,6 +1344,64 @@ disable_response_storage = true
Ok(())
}
#[test]
fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let gpt5_profile_overrides = ConfigOverrides {
config_profile: Some("gpt5".to_string()),
cwd: Some(fixture.cwd()),
..Default::default()
};
let gpt5_profile_config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
gpt5_profile_overrides,
fixture.codex_home(),
)?;
let expected_gpt5_profile_config = Config {
model: "gpt-5".to_string(),
model_family: find_family_for_model("gpt-5").expect("known model slug"),
model_context_window: Some(272_000),
model_max_output_tokens: Some(128_000),
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
tui: Tui::default(),
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: ReasoningEffort::High,
model_reasoning_summary: ReasoningSummary::Detailed,
model_verbosity: Some(Verbosity::High),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
responses_originator_header: "codex_cli_rs".to_string(),
preferred_auth_method: AuthMode::ChatGPT,
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
Ok(())
}
#[test]
fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> {
let codex_home = TempDir::new().unwrap();

View File

@@ -1,10 +1,10 @@
use serde::Deserialize;
use std::path::PathBuf;
use crate::config_types::Verbosity;
use crate::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`.
@@ -15,10 +15,23 @@ pub struct ConfigProfile {
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub disable_response_storage: Option<bool>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub chatgpt_base_url: Option<String>,
pub experimental_instructions_file: Option<PathBuf>,
}
impl From<ConfigProfile> for codex_protocol::mcp_protocol::Profile {
fn from(config_profile: ConfigProfile) -> Self {
Self {
model: config_profile.model,
model_provider: config_profile.model_provider,
approval_policy: config_profile.approval_policy,
model_reasoning_effort: config_profile.model_reasoning_effort,
model_reasoning_summary: config_profile.model_reasoning_summary,
model_verbosity: config_profile.model_verbosity,
chatgpt_base_url: config_profile.chatgpt_base_url,
}
}
}

View File

@@ -8,8 +8,6 @@ use std::path::PathBuf;
use wildmatch::WildMatchPattern;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
@@ -20,6 +18,10 @@ pub struct McpServerConfig {
#[serde(default)]
pub env: Option<HashMap<String, String>>,
/// Startup timeout in milliseconds for initializing MCP server & initially listing tools.
#[serde(default)]
pub startup_timeout_ms: Option<u64>,
}
#[derive(Deserialize, Debug, Copy, Clone, PartialEq)]
@@ -90,6 +92,17 @@ pub struct SandboxWorkspaceWrite {
pub exclude_slash_tmp: bool,
}
impl From<SandboxWorkspaceWrite> for codex_protocol::mcp_protocol::SandboxSettings {
fn from(sandbox_workspace_write: SandboxWorkspaceWrite) -> Self {
Self {
writable_roots: sandbox_workspace_write.writable_roots,
network_access: Some(sandbox_workspace_write.network_access),
exclude_tmpdir_env_var: Some(sandbox_workspace_write.exclude_tmpdir_env_var),
exclude_slash_tmp: Some(sandbox_workspace_write.exclude_slash_tmp),
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
@@ -186,42 +199,10 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
}
}
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningEffort {
Low,
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum ReasoningSummaryFormat {
#[default]
Medium,
High,
/// Option to disable reasoning.
None,
}
/// A summary of the reasoning performed by the model. This can be useful for
/// debugging and understanding the model's reasoning process.
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningSummary {
#[default]
Auto,
Concise,
Detailed,
/// Option to disable reasoning summaries.
None,
}
/// Controls output length/detail on GPT-5 models via the Responses API.
/// Serialized with lowercase values to match the OpenAI API.
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verbosity {
Low,
#[default]
Medium,
High,
Experimental,
}

View File

@@ -1,12 +1,5 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::AuthManager;
use crate::CodexAuth;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::codex::Codex;
use crate::codex::CodexSpawnOk;
use crate::codex::INITIAL_SUBMIT_ID;
@@ -18,18 +11,31 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use crate::rollout::RolloutRecorder;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::models::ResponseItem;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq)]
pub struct ResumedHistory {
pub conversation_id: ConversationId,
pub history: Vec<ResponseItem>,
pub rollout_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InitialHistory {
New,
Resumed(Vec<ResponseItem>),
Resumed(ResumedHistory),
Forked(Vec<ResponseItem>),
}
/// Represents a newly created Codex conversation, including the first event
/// (which is [`EventMsg::SessionConfigured`]).
pub struct NewConversation {
pub conversation_id: Uuid,
pub conversation_id: ConversationId,
pub conversation: Arc<CodexConversation>,
pub session_configured: SessionConfiguredEvent,
}
@@ -37,7 +43,7 @@ pub struct NewConversation {
/// [`ConversationManager`] is responsible for creating conversations and
/// maintaining them in memory.
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<Uuid, Arc<CodexConversation>>>>,
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
}
@@ -70,14 +76,14 @@ impl ConversationManager {
let initial_history = RolloutRecorder::get_rollout_history(resume_path).await?;
let CodexSpawnOk {
codex,
session_id: conversation_id,
conversation_id,
} = Codex::spawn(config, auth_manager, initial_history).await?;
self.finalize_spawn(codex, conversation_id).await
} else {
let CodexSpawnOk {
codex,
session_id: conversation_id,
} = { Codex::spawn(config, auth_manager, InitialHistory::New).await? };
conversation_id,
} = Codex::spawn(config, auth_manager, InitialHistory::New).await?;
self.finalize_spawn(codex, conversation_id).await
}
}
@@ -85,7 +91,7 @@ impl ConversationManager {
async fn finalize_spawn(
&self,
codex: Codex,
conversation_id: Uuid,
conversation_id: ConversationId,
) -> CodexResult<NewConversation> {
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
@@ -116,7 +122,7 @@ impl ConversationManager {
pub async fn get_conversation(
&self,
conversation_id: Uuid,
conversation_id: ConversationId,
) -> CodexResult<Arc<CodexConversation>> {
let conversations = self.conversations.read().await;
conversations
@@ -134,12 +140,12 @@ impl ConversationManager {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
let CodexSpawnOk {
codex,
session_id: conversation_id,
conversation_id,
} = Codex::spawn(config, auth_manager, initial_history).await?;
self.finalize_spawn(codex, conversation_id).await
}
pub async fn remove_conversation(&self, conversation_id: Uuid) {
pub async fn remove_conversation(&self, conversation_id: ConversationId) {
self.conversations.write().await.remove(&conversation_id);
}
@@ -161,7 +167,7 @@ impl ConversationManager {
let auth_manager = self.auth_manager.clone();
let CodexSpawnOk {
codex,
session_id: conversation_id,
conversation_id,
} = Codex::spawn(config, auth_manager, history).await?;
self.finalize_spawn(codex, conversation_id).await
@@ -172,7 +178,7 @@ impl ConversationManager {
/// and all items that follow them.
fn truncate_after_dropping_last_messages(items: Vec<ResponseItem>, n: usize) -> InitialHistory {
if n == 0 {
return InitialHistory::Resumed(items);
return InitialHistory::Forked(items);
}
// Walk backwards counting only `user` Message items, find cut index.
@@ -194,7 +200,7 @@ fn truncate_after_dropping_last_messages(items: Vec<ResponseItem>, n: usize) ->
// No prefix remains after dropping; start a new conversation.
InitialHistory::New
} else {
InitialHistory::Resumed(items.into_iter().take(cut_index).collect())
InitialHistory::Forked(items.into_iter().take(cut_index).collect())
}
}
@@ -252,7 +258,7 @@ mod tests {
let truncated = truncate_after_dropping_last_messages(items.clone(), 1);
assert_eq!(
truncated,
InitialHistory::Resumed(vec![items[0].clone(), items[1].clone(), items[2].clone(),])
InitialHistory::Forked(vec![items[0].clone(), items[1].clone(), items[2].clone(),])
);
let truncated2 = truncate_after_dropping_last_messages(items, 2);

View File

@@ -0,0 +1,106 @@
pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
pub fn get_codex_user_agent(originator: Option<&str>) -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
format!(
"{}/{build_version} ({} {}; {}) {}",
originator.unwrap_or(DEFAULT_ORIGINATOR),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
crate::terminal::user_agent()
)
}
/// Create a reqwest client with default `originator` and `User-Agent` headers set.
pub fn create_client(originator: &str) -> reqwest::Client {
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
let mut headers = HeaderMap::new();
let originator_value = HeaderValue::from_str(originator)
.unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_ORIGINATOR));
headers.insert("originator", originator_value);
let ua = get_codex_user_agent(Some(originator));
match reqwest::Client::builder()
// Set UA via dedicated helper to avoid header validation pitfalls
.user_agent(ua)
.default_headers(headers)
.build()
{
Ok(client) => client,
Err(_) => reqwest::Client::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent(None);
assert!(user_agent.starts_with("codex_cli_rs/"));
}
#[tokio::test]
async fn test_create_client_sets_default_headers() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let originator = "test_originator";
let client = create_client(originator);
// Spin up a local mock server and capture a request.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let resp = client
.get(server.uri())
.send()
.await
.expect("failed to send request");
assert!(resp.status().is_success());
let requests = server
.received_requests()
.await
.expect("failed to fetch received requests");
assert!(!requests.is_empty());
let headers = &requests[0].headers;
// originator header is set to the provided value
let originator_header = headers
.get("originator")
.expect("originator header missing");
assert_eq!(originator_header.to_str().unwrap(), originator);
// User-Agent matches the computed Codex UA for that originator
let expected_ua = get_codex_user_agent(Some(originator));
let ua_header = headers
.get("user-agent")
.expect("user-agent header missing");
assert_eq!(ua_header.to_str().unwrap(), expected_ua);
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent(None);
let re = Regex::new(
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
)
.unwrap();
assert!(re.is_match(&user_agent));
}
}

View File

@@ -8,12 +8,10 @@ use crate::shell::Shell;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
use std::path::PathBuf;
/// wraps environment context message in a tag for the model to parse more easily.
pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "<environment_context>";
pub(crate) const ENVIRONMENT_CONTEXT_END: &str = "</environment_context>";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
@@ -79,7 +77,7 @@ impl EnvironmentContext {
/// </environment_context>
/// ```
pub fn serialize_to_xml(self) -> String {
let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()];
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
if let Some(cwd) = self.cwd {
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
}
@@ -101,7 +99,7 @@ impl EnvironmentContext {
{
lines.push(format!(" <shell>{shell_name}</shell>"));
}
lines.push(ENVIRONMENT_CONTEXT_END.to_string());
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
}

View File

@@ -1,10 +1,10 @@
use codex_protocol::mcp_protocol::ConversationId;
use reqwest::StatusCode;
use serde_json;
use std::io;
use std::time::Duration;
use thiserror::Error;
use tokio::task::JoinError;
use uuid::Uuid;
pub type Result<T> = std::result::Result<T, CodexErr>;
@@ -49,7 +49,7 @@ pub enum CodexErr {
Stream(String, Option<Duration>),
#[error("no conversation with id: {0}")]
ConversationNotFound(Uuid),
ConversationNotFound(ConversationId),
#[error("session configured event was not the first event in the stream")]
SessionConfiguredNotFirstEvent,
@@ -83,6 +83,9 @@ pub enum CodexErr {
#[error("We're currently experiencing high demand, which may cause temporary errors.")]
InternalServerError,
#[error("The API key is invalid or has expired. Please check your API key and try again.")]
UnauthorizedError,
/// Retry limit exceeded.
#[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode),

View File

@@ -0,0 +1,98 @@
use crate::protocol::AgentMessageEvent;
use crate::protocol::AgentReasoningEvent;
use crate::protocol::AgentReasoningRawContentEvent;
use crate::protocol::EventMsg;
use crate::protocol::InputMessageKind;
use crate::protocol::UserMessageEvent;
use crate::protocol::WebSearchEndEvent;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::WebSearchAction;
/// Convert a `ResponseItem` into zero or more `EventMsg` values that the UI can render.
///
/// When `show_raw_agent_reasoning` is false, raw reasoning content events are omitted.
pub(crate) fn map_response_item_to_event_messages(
item: &ResponseItem,
show_raw_agent_reasoning: bool,
) -> Vec<EventMsg> {
match item {
ResponseItem::Message { role, content, .. } => {
// Do not surface system messages as user events.
if role == "system" {
return Vec::new();
}
let events: Vec<EventMsg> = content
.iter()
.filter_map(|content_item| match content_item {
ContentItem::OutputText { text } => {
Some(EventMsg::AgentMessage(AgentMessageEvent {
message: text.clone(),
}))
}
ContentItem::InputText { text } => {
let trimmed = text.trim_start();
let kind = if trimmed.starts_with("<environment_context>") {
Some(InputMessageKind::EnvironmentContext)
} else if trimmed.starts_with("<user_instructions>") {
Some(InputMessageKind::UserInstructions)
} else {
Some(InputMessageKind::Plain)
};
Some(EventMsg::UserMessage(UserMessageEvent {
message: text.clone(),
kind,
}))
}
_ => None,
})
.collect();
events
}
ResponseItem::Reasoning {
summary, content, ..
} => {
let mut events = Vec::new();
for ReasoningItemReasoningSummary::SummaryText { text } in summary {
events.push(EventMsg::AgentReasoning(AgentReasoningEvent {
text: text.clone(),
}));
}
if let Some(items) = content.as_ref().filter(|_| show_raw_agent_reasoning) {
for c in items {
let text = match c {
ReasoningItemContent::ReasoningText { text }
| ReasoningItemContent::Text { text } => text,
};
events.push(EventMsg::AgentReasoningRawContent(
AgentReasoningRawContentEvent { text: text.clone() },
));
}
}
events
}
ResponseItem::WebSearchCall { id, action, .. } => match action {
WebSearchAction::Search { query } => {
let call_id = id.clone().unwrap_or_else(|| "".to_string());
vec![EventMsg::WebSearchEnd(WebSearchEndEvent {
call_id,
query: query.clone(),
})]
}
WebSearchAction::Other => Vec::new(),
},
// Variants that require side effects are handled by higher layers and do not emit events here.
ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Other => Vec::new(),
}
}

View File

@@ -26,7 +26,6 @@ use crate::protocol::SandboxPolicy;
use crate::seatbelt::spawn_command_under_seatbelt;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use serde_bytes::ByteBuf;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
@@ -369,7 +368,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
} else {
ExecOutputStream::Stdout
},
chunk: ByteBuf::from(chunk),
chunk,
});
let event = Event {
id: stream.sub_id.clone(),

View File

@@ -7,7 +7,7 @@
mod apply_patch;
pub mod auth;
mod bash;
pub mod bash;
mod chat_completions;
mod client;
mod client_common;
@@ -34,17 +34,20 @@ mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod parse_command;
mod user_instructions;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
pub use model_provider_info::create_oss_provider_with_base_url;
mod conversation_manager;
mod event_mapping;
pub use conversation_manager::ConversationManager;
pub use conversation_manager::NewConversation;
// Re-export common auth types for workspace consumers
pub use auth::AuthManager;
pub use auth::CodexAuth;
pub mod default_client;
pub mod model_family;
mod openai_model_info;
mod openai_tools;
@@ -58,8 +61,11 @@ pub mod spawn;
pub mod terminal;
mod tool_apply_patch;
pub mod turn_diff_tracker;
pub mod user_agent;
pub use rollout::RolloutRecorder;
pub use rollout::SessionMeta;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
mod user_notification;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;

View File

@@ -9,6 +9,7 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
@@ -36,8 +37,8 @@ use crate::config_types::McpServerConfig;
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
/// Timeout for the `tools/list` request.
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(10);
/// Default timeout for initializing MCP server & initially listing tools.
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
/// Map that holds a startup error for every MCP server that could **not** be
/// spawned successfully.
@@ -81,6 +82,11 @@ struct ToolInfo {
tool: Tool,
}
struct ManagedClient {
client: Arc<McpClient>,
startup_timeout: Duration,
}
/// A thin wrapper around a set of running [`McpClient`] instances.
#[derive(Default)]
pub(crate) struct McpConnectionManager {
@@ -88,7 +94,7 @@ pub(crate) struct McpConnectionManager {
///
/// The server name originates from the keys of the `mcp_servers` map in
/// the user configuration.
clients: HashMap<String, std::sync::Arc<McpClient>>,
clients: HashMap<String, ManagedClient>,
/// Fully qualified tool name -> tool instance.
tools: HashMap<String, ToolInfo>,
@@ -126,8 +132,15 @@ impl McpConnectionManager {
continue;
}
let startup_timeout = cfg
.startup_timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
join_set.spawn(async move {
let McpServerConfig { command, args, env } = cfg;
let McpServerConfig {
command, args, env, ..
} = cfg;
let client_res = McpClient::new_stdio_client(
command.into(),
args.into_iter().map(OsString::from).collect(),
@@ -154,12 +167,15 @@ impl McpConnectionManager {
protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_owned(),
};
let initialize_notification_params = None;
let timeout = Some(Duration::from_secs(10));
match client
.initialize(params, initialize_notification_params, timeout)
.initialize(
params,
initialize_notification_params,
Some(startup_timeout),
)
.await
{
Ok(_response) => (server_name, Ok(client)),
Ok(_response) => (server_name, Ok((client, startup_timeout))),
Err(e) => (server_name, Err(e)),
}
}
@@ -168,15 +184,26 @@ impl McpConnectionManager {
});
}
let mut clients: HashMap<String, std::sync::Arc<McpClient>> =
HashMap::with_capacity(join_set.len());
let mut clients: HashMap<String, ManagedClient> = HashMap::with_capacity(join_set.len());
while let Some(res) = join_set.join_next().await {
let (server_name, client_res) = res?; // JoinError propagation
let (server_name, client_res) = match res {
Ok((server_name, client_res)) => (server_name, client_res),
Err(e) => {
warn!("Task panic when starting MCP server: {e:#}");
continue;
}
};
match client_res {
Ok(client) => {
clients.insert(server_name, std::sync::Arc::new(client));
Ok((client, startup_timeout)) => {
clients.insert(
server_name,
ManagedClient {
client: Arc::new(client),
startup_timeout,
},
);
}
Err(e) => {
errors.insert(server_name, e);
@@ -184,7 +211,13 @@ impl McpConnectionManager {
}
}
let all_tools = list_all_tools(&clients).await?;
let all_tools = match list_all_tools(&clients).await {
Ok(tools) => tools,
Err(e) => {
warn!("Failed to list tools from some MCP servers: {e:#}");
Vec::new()
}
};
let tools = qualify_tools(all_tools);
@@ -212,6 +245,7 @@ impl McpConnectionManager {
.clients
.get(server)
.ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?
.client
.clone();
client
@@ -229,21 +263,18 @@ impl McpConnectionManager {
/// Query every server for its available tools and return a single map that
/// contains **all** tools. Each key is the fully-qualified name for the tool.
async fn list_all_tools(
clients: &HashMap<String, std::sync::Arc<McpClient>>,
) -> Result<Vec<ToolInfo>> {
async fn list_all_tools(clients: &HashMap<String, ManagedClient>) -> Result<Vec<ToolInfo>> {
let mut join_set = JoinSet::new();
// Spawn one task per server so we can query them concurrently. This
// keeps the overall latency roughly at the slowest server instead of
// the cumulative latency.
for (server_name, client) in clients {
for (server_name, managed_client) in clients {
let server_name_cloned = server_name.clone();
let client_clone = client.clone();
let client_clone = managed_client.client.clone();
let startup_timeout = managed_client.startup_timeout;
join_set.spawn(async move {
let res = client_clone
.list_tools(None, Some(LIST_TOOLS_TIMEOUT))
.await;
let res = client_clone.list_tools(None, Some(startup_timeout)).await;
(server_name_cloned, res)
});
}
@@ -251,8 +282,19 @@ async fn list_all_tools(
let mut aggregated: Vec<ToolInfo> = Vec::with_capacity(join_set.len());
while let Some(join_res) = join_set.join_next().await {
let (server_name, list_result) = join_res?;
let list_result = list_result?;
let (server_name, list_result) = if let Ok(result) = join_res {
result
} else {
warn!("Task panic when listing tools for MCP server: {join_res:#?}");
continue;
};
let list_result = if let Ok(result) = list_result {
result
} else {
warn!("Failed to list tools for MCP server '{server_name}': {list_result:#?}");
continue;
};
for tool in list_result.tools {
let tool_info = ToolInfo {

View File

@@ -5,7 +5,7 @@
//! JSON-Lines tooling. Each record has the following schema:
//!
//! ````text
//! {"session_id":"<uuid>","ts":<unix_seconds>,"text":"<message>"}
//! {"conversation_id":"<uuid>","ts":<unix_seconds>,"text":"<message>"}
//! ````
//!
//! To minimise the chance of interleaved writes when multiple processes are
@@ -22,14 +22,15 @@ use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use std::time::Duration;
use tokio::fs;
use tokio::io::AsyncReadExt;
use uuid::Uuid;
use crate::config::Config;
use crate::config_types::HistoryPersistence;
use codex_protocol::mcp_protocol::ConversationId;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
@@ -54,10 +55,14 @@ fn history_filepath(config: &Config) -> PathBuf {
path
}
/// Append a `text` entry associated with `session_id` to the history file. Uses
/// Append a `text` entry associated with `conversation_id` to the history file. Uses
/// advisory file locking to ensure that concurrent writes do not interleave,
/// which entails a small amount of blocking I/O internally.
pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config) -> Result<()> {
pub(crate) async fn append_entry(
text: &str,
conversation_id: &ConversationId,
config: &Config,
) -> Result<()> {
match config.history.persistence {
HistoryPersistence::SaveAll => {
// Save everything: proceed.
@@ -84,7 +89,7 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
// Construct the JSON line first so we can write it in a single syscall.
let entry = HistoryEntry {
session_id: session_id.to_string(),
session_id: conversation_id.to_string(),
ts,
text: text.to_string(),
};

View File

@@ -1,3 +1,4 @@
use crate::config_types::ReasoningSummaryFormat;
use crate::tool_apply_patch::ApplyPatchToolType;
/// A model family is a group of models that share certain characteristics.
@@ -20,6 +21,9 @@ pub struct ModelFamily {
// `summary` is optional).
pub supports_reasoning_summaries: bool,
// Define if we need a special handling of reasoning summary
pub reasoning_summary_format: ReasoningSummaryFormat,
// This should be set to true when the model expects a tool named
// "local_shell" to be provided. Its contract must be understood natively by
// the model such that its description can be omitted.
@@ -41,6 +45,7 @@ macro_rules! model_family {
family: $family.to_string(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
};
@@ -61,6 +66,7 @@ macro_rules! simple_model_family {
family: $family.to_string(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
apply_patch_tool_type: None,
})
@@ -90,6 +96,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
)
} else if slug.starts_with("gpt-4.1") {
model_family!(

View File

@@ -79,12 +79,12 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
}),
"gpt-5" => Some(ModelInfo {
context_window: 400_000,
context_window: 272_000,
max_output_tokens: 128_000,
}),
_ if slug.starts_with("codex-") => Some(ModelInfo {
context_window: 400_000,
context_window: 272_000,
max_output_tokens: 128_000,
}),

View File

@@ -240,15 +240,17 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
let description = match sandbox_policy {
SandboxPolicy::WorkspaceWrite {
network_access,
writable_roots,
..
} => {
format!(
r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges:
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges:
- Types of actions that require escalated privileges:
- Reading files outside the current directory
- Writing files outside the current directory, and protected folders like .git or .env{}
- Writing files other than those in the writable roots
- writable roots:
{}{}
- Examples of commands that require escalated privileges:
- git commit
- npm install or pnpm install
@@ -257,8 +259,9 @@ The shell tool is used to execute shell commands.
- When invoking a command that will require escalated privileges:
- Provide the with_escalated_permissions parameter with the boolean value true
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#,
writable_roots.iter().map(|wr| format!(" - {}", wr.to_string_lossy())).collect::<Vec<String>>().join("\n"),
if !network_access {
"\n - Commands that require network access\n"
"\n - Commands that require network access\n"
} else {
""
}
@@ -270,9 +273,8 @@ The shell tool is used to execute shell commands.
SandboxPolicy::ReadOnly => {
r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands (including apply_patch) will require escalated permissions:
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions:
- Types of actions that require escalated privileges:
- Reading files outside the current directory
- Writing files
- Applying patches
- Examples of commands that require escalated privileges:
@@ -1081,4 +1083,84 @@ mod tests {
})
);
}
#[test]
fn test_shell_tool_for_sandbox_workspace_write() {
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec!["workspace".into()],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let tool = super::create_shell_tool_for_sandbox(&sandbox_policy);
let OpenAiTool::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
let expected = r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges:
- Types of actions that require escalated privileges:
- Writing files other than those in the writable roots
- writable roots:
- workspace
- Commands that require network access
- Examples of commands that require escalated privileges:
- git commit
- npm install or pnpm install
- cargo build
- cargo test
- When invoking a command that will require escalated privileges:
- Provide the with_escalated_permissions parameter with the boolean value true
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#;
assert_eq!(description, expected);
}
#[test]
fn test_shell_tool_for_sandbox_readonly() {
let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly);
let OpenAiTool::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
let expected = r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions:
- Types of actions that require escalated privileges:
- Writing files
- Applying patches
- Examples of commands that require escalated privileges:
- apply_patch
- git commit
- npm install or pnpm install
- cargo build
- cargo test
- When invoking a command that will require escalated privileges:
- Provide the with_escalated_permissions parameter with the boolean value true
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#;
assert_eq!(description, expected);
}
#[test]
fn test_shell_tool_for_sandbox_danger_full_access() {
let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::DangerFullAccess);
let OpenAiTool::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
assert_eq!(description, "Runs a shell command and returns its output.");
}
}

View File

@@ -34,7 +34,8 @@ pub struct ConversationItem {
}
/// Hard cap to bound worstcase work per request.
const MAX_SCAN_FILES: usize = 50_000;
const MAX_SCAN_FILES: usize = 10_000;
const HEAD_RECORD_LIMIT: usize = 10;
/// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -166,7 +167,9 @@ async fn traverse_directories_for_paths(
if items.len() == page_size {
break 'outer;
}
let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default();
let head = read_first_jsonl_records(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or_default();
items.push(ConversationItem { path, head });
}
}

View File

@@ -3,9 +3,12 @@
pub(crate) const SESSIONS_SUBDIR: &str = "sessions";
pub mod list;
pub(crate) mod policy;
pub mod recorder;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;
pub use recorder::SessionMeta;
pub use recorder::SessionStateSnapshot;
#[cfg(test)]

View File

@@ -0,0 +1,16 @@
use codex_protocol::models::ResponseItem;
/// Whether a `ResponseItem` should be persisted in rollout files.
#[inline]
pub(crate) fn is_persisted_response_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. } => true,
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
}
}

View File

@@ -4,7 +4,9 @@ use std::fs::File;
use std::fs::{self};
use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::ConversationId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
@@ -17,21 +19,22 @@ use tokio::sync::mpsc::{self};
use tokio::sync::oneshot;
use tracing::info;
use tracing::warn;
use uuid::Uuid;
use super::SESSIONS_SUBDIR;
use super::list::ConversationsPage;
use super::list::Cursor;
use super::list::get_conversations;
use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::conversation_manager::InitialHistory;
use crate::conversation_manager::ResumedHistory;
use crate::git_info::GitInfo;
use crate::git_info::collect_git_info;
use codex_protocol::models::ResponseItem;
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct SessionMeta {
pub id: Uuid,
pub id: ConversationId,
pub timestamp: String,
pub instructions: Option<String>,
}
@@ -54,7 +57,7 @@ pub struct SavedSession {
pub items: Vec<ResponseItem>,
#[serde(default)]
pub state: SessionStateSnapshot,
pub session_id: Uuid,
pub session_id: ConversationId,
}
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
@@ -71,12 +74,36 @@ pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
}
#[derive(Clone)]
pub enum RolloutRecorderParams {
Create {
conversation_id: ConversationId,
instructions: Option<String>,
},
Resume {
path: PathBuf,
},
}
enum RolloutCmd {
AddItems(Vec<ResponseItem>),
UpdateState(SessionStateSnapshot),
Shutdown { ack: oneshot::Sender<()> },
}
impl RolloutRecorderParams {
pub fn new(conversation_id: ConversationId, instructions: Option<String>) -> Self {
Self::Create {
conversation_id,
instructions,
}
}
pub fn resume(path: PathBuf) -> Self {
Self::Resume { path }
}
}
impl RolloutRecorder {
#[allow(dead_code)]
/// List conversations (rollout files) under the provided Codex home directory.
@@ -91,23 +118,43 @@ impl RolloutRecorder {
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
/// cannot be created or the rollout file cannot be opened we return the
/// error so the caller can decide whether to disable persistence.
pub async fn new(
config: &Config,
uuid: Uuid,
instructions: Option<String>,
) -> std::io::Result<Self> {
let LogFileInfo {
file,
session_id,
timestamp,
} = create_log_file(config, uuid)?;
pub async fn new(config: &Config, params: RolloutRecorderParams) -> std::io::Result<Self> {
let (file, meta) = match params {
RolloutRecorderParams::Create {
conversation_id,
instructions,
} => {
let LogFileInfo {
file,
conversation_id: session_id,
timestamp,
} = create_log_file(config, conversation_id)?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = timestamp
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = timestamp
.to_offset(time::UtcOffset::UTC)
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
(
tokio::fs::File::from_std(file),
Some(SessionMeta {
timestamp,
id: session_id,
instructions,
}),
)
}
RolloutRecorderParams::Resume { path } => (
tokio::fs::OpenOptions::new()
.append(true)
.open(path)
.await?,
None,
),
};
// Clone the cwd for the spawned task to collect git info asynchronously
let cwd = config.cwd.clone();
@@ -120,16 +167,7 @@ impl RolloutRecorder {
// Spawn a Tokio task that owns the file handle and performs async
// writes. Using `tokio::fs::File` keeps everything on the async I/O
// driver instead of blocking the runtime.
tokio::task::spawn(rollout_writer(
tokio::fs::File::from_std(file),
rx,
Some(SessionMeta {
timestamp,
id: session_id,
instructions,
}),
cwd,
));
tokio::task::spawn(rollout_writer(file, rx, meta, cwd));
Ok(Self { tx })
}
@@ -137,21 +175,11 @@ impl RolloutRecorder {
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
let mut filtered = Vec::new();
for item in items {
match item {
// Note that function calls may look a bit strange if they are
// "fully qualified MCP tool calls," so we could consider
// reformatting them in that case.
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {
// These should never be serialized.
continue;
}
// Note that function calls may look a bit strange if they are
// "fully qualified MCP tool calls," so we could consider
// reformatting them in that case.
if is_persisted_response_item(item) {
filtered.push(item.clone());
}
}
if filtered.is_empty() {
@@ -172,13 +200,28 @@ impl RolloutRecorder {
pub async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
info!("Resuming rollout from {path:?}");
tracing::error!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?;
let mut lines = text.lines();
let _ = lines
let first_line = lines
.next()
.ok_or_else(|| IoError::other("empty session file"))?;
let mut items = Vec::new();
let conversation_id = match serde_json::from_str::<SessionMeta>(first_line) {
Ok(rollout_session_meta) => {
tracing::error!(
"Parsed conversation ID from rollout file: {:?}",
rollout_session_meta.id
);
Some(rollout_session_meta.id)
}
Err(e) => {
return Err(IoError::other(format!(
"failed to parse first line of rollout file as SessionMeta: {e}"
)));
}
};
let mut items = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
@@ -195,28 +238,35 @@ impl RolloutRecorder {
continue;
}
match serde_json::from_value::<ResponseItem>(v.clone()) {
Ok(item) => match item {
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => items.push(item),
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
},
Ok(item) => {
if is_persisted_response_item(&item) {
items.push(item);
}
}
Err(e) => {
warn!("failed to parse item: {v:?}, error: {e}");
}
}
}
info!("Resumed rollout successfully from {path:?}");
tracing::error!(
"Resumed rollout with {} items, conversation ID: {:?}",
items.len(),
conversation_id
);
let conversation_id = conversation_id
.ok_or_else(|| IoError::other("failed to parse conversation ID from rollout file"))?;
if items.is_empty() {
Ok(InitialHistory::New)
} else {
Ok(InitialHistory::Resumed(items))
return Ok(InitialHistory::New);
}
info!("Resumed rollout successfully from {path:?}");
Ok(InitialHistory::Resumed(ResumedHistory {
conversation_id,
history: items,
rollout_path: path.to_path_buf(),
}))
}
pub async fn shutdown(&self) -> std::io::Result<()> {
@@ -240,13 +290,16 @@ struct LogFileInfo {
file: File,
/// Session ID (also embedded in filename).
session_id: Uuid,
conversation_id: ConversationId,
/// Timestamp for the start of the session.
timestamp: OffsetDateTime,
}
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
fn create_log_file(
config: &Config,
conversation_id: ConversationId,
) -> std::io::Result<LogFileInfo> {
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
let timestamp = OffsetDateTime::now_local()
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
@@ -265,7 +318,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
.format(format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let filename = format!("rollout-{date_str}-{session_id}.jsonl");
let filename = format!("rollout-{date_str}-{conversation_id}.jsonl");
let path = dir.join(filename);
let file = std::fs::OpenOptions::new()
@@ -275,7 +328,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
Ok(LogFileInfo {
file,
session_id,
conversation_id,
timestamp,
})
}
@@ -305,17 +358,8 @@ async fn rollout_writer(
match cmd {
RolloutCmd::AddItems(items) => {
for item in items {
match item {
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
writer.write_line(&item).await?;
}
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
if is_persisted_response_item(&item) {
writer.write_line(&item).await?;
}
}
}
@@ -350,7 +394,7 @@ impl JsonlWriter {
async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> {
let mut json = serde_json::to_string(item)?;
json.push('\n');
let _ = self.file.write_all(json.as_bytes()).await;
self.file.write_all(json.as_bytes()).await?;
self.file.flush().await?;
Ok(())
}

View File

@@ -53,6 +53,13 @@ pub fn assess_patch_safety(
// paths outside the project.
match get_platform_sandbox() {
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
None if sandbox_policy == &SandboxPolicy::DangerFullAccess => {
// If the user has explicitly requested DangerFullAccess, then
// we can auto-approve even without a sandbox.
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
}
}
None => SafetyCheck::AskUser,
}
} else if policy == AskForApproval::Never {

View File

@@ -69,3 +69,8 @@
; Added on top of Chrome profile
; Needed for python multiprocessing on MacOS for the SemLock
(allow ipc-posix-sem)
; needed to look up user info, see https://crbug.com/792228
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo")
)

View File

@@ -1,12 +1,36 @@
use serde::Deserialize;
use serde::Serialize;
use shlex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::trace;
use uuid::Uuid;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
/// This structure cannot derive Clone or this will break the Drop implementation.
pub struct ShellSnapshot {
pub(crate) path: PathBuf,
}
impl ShellSnapshot {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for ShellSnapshot {
fn drop(&mut self) {
delete_shell_snapshot(&self.path);
}
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct ZshShell {
shell_path: String,
zshrc_path: String,
pub struct PosixShell {
pub(crate) shell_path: String,
pub(crate) rc_path: String,
#[serde(skip_serializing, skip_deserializing)]
pub(crate) shell_snapshot: Option<Arc<ShellSnapshot>>,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
@@ -17,7 +41,7 @@ pub struct PowerShellConfig {
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum Shell {
Zsh(ZshShell),
Posix(PosixShell),
PowerShell(PowerShellConfig),
Unknown,
}
@@ -25,23 +49,27 @@ pub enum Shell {
impl Shell {
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => {
if !std::path::Path::new(&zsh.zshrc_path).exists() {
return None;
}
let mut result = vec![zsh.shell_path.clone()];
result.push("-lc".to_string());
Shell::Posix(shell) => {
let joined = strip_bash_lc(&command)
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok());
.or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok())?;
if let Some(joined) = joined {
result.push(format!("source {} && ({joined})", zsh.zshrc_path));
let mut source_path = Path::new(&shell.rc_path);
let session_cmd = if let Some(shell_snapshot) = &shell.shell_snapshot
&& shell_snapshot.path.exists()
{
source_path = shell_snapshot.path.as_path();
"-c".to_string()
} else {
return None;
}
Some(result)
"-lc".to_string()
};
let source_path_str = source_path.to_string_lossy().to_string();
let quoted_source_path = shlex::try_quote(&source_path_str).ok()?;
let rc_command =
format!("[ -f {quoted_source_path} ] && . {quoted_source_path}; ({joined})");
Some(vec![shell.shell_path.clone(), session_cmd, rc_command])
}
Shell::PowerShell(ps) => {
// If model generated a bash command, prefer a detected bash fallback
@@ -94,13 +122,20 @@ impl Shell {
pub fn name(&self) -> Option<String> {
match self {
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
Shell::Posix(shell) => Path::new(&shell.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::PowerShell(ps) => Some(ps.exe.clone()),
Shell::Unknown => None,
}
}
pub fn get_snapshot(&self) -> Option<Arc<ShellSnapshot>> {
match self {
Shell::Posix(shell) => shell.shell_snapshot.clone(),
_ => None,
}
}
}
fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
@@ -116,48 +151,61 @@ fn strip_bash_lc(command: &Vec<String>) -> Option<String> {
}
}
#[cfg(target_os = "macos")]
pub async fn default_user_shell() -> Shell {
use tokio::process::Command;
use whoami;
#[cfg(unix)]
async fn detect_default_user_shell(session_id: Uuid, codex_home: &Path) -> Shell {
use libc::getpwuid;
use libc::getuid;
use std::ffi::CStr;
let user = whoami::username();
let home = format!("/Users/{user}");
let output = Command::new("dscl")
.args([".", "-read", &home, "UserShell"])
.output()
.await
.ok();
match output {
Some(o) => {
if !o.status.success() {
unsafe {
let uid = getuid();
let pw = getpwuid(uid);
if !pw.is_null() {
let shell_path = CStr::from_ptr((*pw).pw_shell)
.to_string_lossy()
.into_owned();
let home_path = CStr::from_ptr((*pw).pw_dir).to_string_lossy().into_owned();
let rc_path = if shell_path.ends_with("/zsh") {
format!("{home_path}/.zshrc")
} else if shell_path.ends_with("/bash") {
format!("{home_path}/.bashrc")
} else {
return Shell::Unknown;
}
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
if let Some(shell_path) = line.strip_prefix("UserShell: ")
&& shell_path.ends_with("/zsh")
{
return Shell::Zsh(ZshShell {
shell_path: shell_path.to_string(),
zshrc_path: format!("{home}/.zshrc"),
});
}
}
};
Shell::Unknown
let snapshot_path = snapshots::ensure_posix_snapshot(
&shell_path,
&rc_path,
Path::new(&home_path),
codex_home,
session_id,
)
.await;
if snapshot_path.is_none() {
trace!("failed to prepare posix snapshot; using live profile");
}
let shell_snapshot =
snapshot_path.map(|snapshot| Arc::new(ShellSnapshot::new(snapshot)));
return Shell::Posix(PosixShell {
shell_path,
rc_path,
shell_snapshot,
});
}
_ => Shell::Unknown,
}
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
pub async fn default_user_shell() -> Shell {
Shell::Unknown
}
#[cfg(unix)]
pub async fn default_user_shell(session_id: Uuid, codex_home: &Path) -> Shell {
detect_default_user_shell(session_id, codex_home).await
}
#[cfg(target_os = "windows")]
pub async fn default_user_shell() -> Shell {
pub async fn default_user_shell(_session_id: Uuid, _codex_home: &Path) -> Shell {
use tokio::process::Command;
// Prefer PowerShell 7+ (`pwsh`) if available, otherwise fall back to Windows PowerShell.
@@ -196,41 +244,344 @@ pub async fn default_user_shell() -> Shell {
}
}
#[cfg(test)]
#[cfg(target_os = "macos")]
mod tests {
#[cfg(all(not(target_os = "windows"), not(unix)))]
pub async fn default_user_shell(_session_id: Uuid, _codex_home: &Path) -> Shell {
Shell::Unknown
}
#[cfg(unix)]
mod snapshots {
use super::*;
use std::process::Command;
#[tokio::test]
async fn test_current_shell_detects_zsh() {
let shell = Command::new("sh")
.arg("-c")
.arg("echo $SHELL")
.output()
.unwrap();
fn zsh_profile_paths(home: &Path) -> Vec<PathBuf> {
[".zshenv", ".zprofile", ".zshrc", ".zlogin"]
.into_iter()
.map(|name| home.join(name))
.collect()
}
let home = std::env::var("HOME").unwrap();
let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
if shell_path.ends_with("/zsh") {
assert_eq!(
default_user_shell().await,
Shell::Zsh(ZshShell {
shell_path: shell_path.to_string(),
zshrc_path: format!("{home}/.zshrc",),
})
);
fn posix_profile_source_script(home: &Path) -> String {
zsh_profile_paths(home)
.into_iter()
.map(|profile| {
let profile_string = profile.to_string_lossy().into_owned();
let quoted = shlex::try_quote(&profile_string)
.map(|cow| cow.into_owned())
.unwrap_or(profile_string.clone());
format!("[ -f {quoted} ] && . {quoted}")
})
.collect::<Vec<_>>()
.join("; ")
}
pub(crate) async fn ensure_posix_snapshot(
shell_path: &str,
rc_path: &str,
home: &Path,
codex_home: &Path,
session_id: Uuid,
) -> Option<PathBuf> {
let snapshot_path = codex_home.join(format!("shell_snapshots/snapshot_{session_id}.zsh"));
// Check if an update in the profile requires to re-generate the snapshot.
let snapshot_is_stale = async {
let snapshot_metadata = tokio::fs::metadata(&snapshot_path).await.ok()?;
let snapshot_modified = snapshot_metadata.modified().ok()?;
for profile in zsh_profile_paths(home) {
let Ok(profile_metadata) = tokio::fs::metadata(&profile).await else {
continue;
};
let Ok(profile_modified) = profile_metadata.modified() else {
return Some(true);
};
if profile_modified > snapshot_modified {
return Some(true);
}
}
Some(false)
}
.await
.unwrap_or(true);
if !snapshot_is_stale {
return Some(snapshot_path);
}
match regenerate_posix_snapshot(shell_path, rc_path, home, &snapshot_path).await {
Ok(()) => Some(snapshot_path),
Err(err) => {
tracing::warn!("failed to generate posix snapshot: {err}");
None
}
}
}
async fn regenerate_posix_snapshot(
shell_path: &str,
rc_path: &str,
home: &Path,
snapshot_path: &Path,
) -> std::io::Result<()> {
// Use `emulate -L sh` instead of `set -o posix` so we work on zsh builds
// that disable that option. Guard `alias -p` with `|| true` so the script
// keeps a zero exit status even if aliases are disabled.
let mut capture_script = String::new();
let profile_sources = posix_profile_source_script(home);
if !profile_sources.is_empty() {
capture_script.push_str(&format!("{profile_sources}; "));
}
let zshrc = home.join(rc_path);
capture_script.push_str(
&format!(". {}; setopt posixbuiltins; export -p; {{ alias | sed 's/^/alias /'; }} 2>/dev/null || true", zshrc.display()),
);
let output = tokio::process::Command::new(shell_path)
.arg("-lc")
.arg(capture_script)
.env("HOME", home)
.output()
.await?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"snapshot capture exited with status {}",
output.status
)));
}
let mut contents = String::from("# Generated by Codex. Do not edit.\n");
contents.push_str(&String::from_utf8_lossy(&output.stdout));
contents.push('\n');
if let Some(parent) = snapshot_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp_path = snapshot_path.with_extension("tmp");
tokio::fs::write(&tmp_path, contents).await?;
// Restrict the snapshot to user read/write so that environment variables or aliases
// that may contain secrets are not exposed to other users on the system.
use std::os::unix::fs::PermissionsExt;
let permissions = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&tmp_path, permissions).await?;
tokio::fs::rename(&tmp_path, snapshot_path).await?;
Ok(())
}
}
pub(crate) fn delete_shell_snapshot(path: &Path) {
if let Err(err) = std::fs::remove_file(path) {
trace!("failed to delete shell snapshot {path:?}: {err}");
}
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use std::path::PathBuf;
#[tokio::test]
async fn test_run_with_profile_zshrc_not_exists() {
let shell = Shell::Zsh(ZshShell {
let shell = Shell::Posix(PosixShell {
shell_path: "/bin/zsh".to_string(),
zshrc_path: "/does/not/exist/.zshrc".to_string(),
rc_path: "/does/not/exist/.zshrc".to_string(),
shell_snapshot: None,
});
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
assert_eq!(actual_cmd, None);
assert_eq!(
actual_cmd,
Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"[ -f /does/not/exist/.zshrc ] && . /does/not/exist/.zshrc; (myecho)".to_string(),
])
);
}
#[tokio::test]
async fn test_run_with_profile_bash_escaping_and_execution() {
let shell_path = "/bin/bash";
let cases = vec![
(
vec!["myecho"],
vec![
shell_path,
"-lc",
"[ -f BASHRC_PATH ] && . BASHRC_PATH; (myecho)",
],
Some("It works!\n"),
),
(
vec!["bash", "-lc", "echo 'single' \"double\""],
vec![
shell_path,
"-lc",
"[ -f BASHRC_PATH ] && . BASHRC_PATH; (echo 'single' \"double\")",
],
Some("single double\n"),
),
];
for (input, expected_cmd, expected_output) in cases {
use std::collections::HashMap;
use crate::exec::ExecParams;
use crate::exec::SandboxType;
use crate::exec::process_exec_tool_call;
use crate::protocol::SandboxPolicy;
let temp_home = tempfile::tempdir().unwrap();
let bashrc_path = temp_home.path().join(".bashrc");
std::fs::write(
&bashrc_path,
r#"
set -x
function myecho {
echo 'It works!'
}
"#,
)
.unwrap();
let shell = Shell::Posix(PosixShell {
shell_path: shell_path.to_string(),
rc_path: bashrc_path.to_str().unwrap().to_string(),
shell_snapshot: None,
});
let actual_cmd = shell
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
let expected_cmd = expected_cmd
.iter()
.map(|s| {
s.replace("BASHRC_PATH", bashrc_path.to_str().unwrap())
.to_string()
})
.collect();
assert_eq!(actual_cmd, Some(expected_cmd));
let output = process_exec_tool_call(
ExecParams {
command: actual_cmd.unwrap(),
cwd: PathBuf::from(temp_home.path()),
timeout_ms: None,
env: HashMap::from([(
"HOME".to_string(),
temp_home.path().to_str().unwrap().to_string(),
)]),
with_escalated_permissions: None,
justification: None,
},
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
&None,
None,
)
.await
.unwrap();
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
output.stdout.text, expected,
"input: {input:?} output: {output:?}"
);
}
}
}
}
#[cfg(test)]
#[cfg(target_os = "macos")]
mod macos_tests {
use super::*;
use crate::shell::snapshots::ensure_posix_snapshot;
#[tokio::test]
async fn test_snapshot_generation_uses_session_id_and_cleanup() {
let shell_path = "/bin/zsh";
let temp_home = tempfile::tempdir().unwrap();
let codex_home = tempfile::tempdir().unwrap();
std::fs::write(
temp_home.path().join(".zshrc"),
"export SNAPSHOT_TEST_VAR=1\nalias snapshot_test_alias='echo hi'\n",
)
.unwrap();
let session_id = Uuid::new_v4();
let snapshot_path = ensure_posix_snapshot(
shell_path,
".zshrc",
temp_home.path(),
codex_home.path(),
session_id,
)
.await
.expect("snapshot path");
let filename = snapshot_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(filename.contains(&session_id.to_string()));
assert!(snapshot_path.exists());
let snapshot_path_second = ensure_posix_snapshot(
shell_path,
".zshrc",
temp_home.path(),
codex_home.path(),
session_id,
)
.await
.expect("snapshot path");
assert_eq!(snapshot_path, snapshot_path_second);
let contents = std::fs::read_to_string(&snapshot_path).unwrap();
assert!(contents.contains("alias snapshot_test_alias='echo hi'"));
assert!(contents.contains("SNAPSHOT_TEST_VAR=1"));
delete_shell_snapshot(&snapshot_path);
assert!(!snapshot_path.exists());
}
#[test]
fn format_default_shell_invocation_prefers_snapshot_when_available() {
let temp_dir = tempfile::tempdir().unwrap();
let snapshot_path = temp_dir.path().join("snapshot.zsh");
std::fs::write(&snapshot_path, "export SNAPSHOT_READY=1").unwrap();
let shell = Shell::Posix(PosixShell {
shell_path: "/bin/zsh".to_string(),
rc_path: {
let path = temp_dir.path().join(".zshrc");
std::fs::write(&path, "# test zshrc").unwrap();
path.to_string_lossy().to_string()
},
shell_snapshot: Some(Arc::new(ShellSnapshot::new(snapshot_path.clone()))),
});
let invocation = shell.format_default_shell_invocation(vec!["echo".to_string()]);
let expected_command = vec!["/bin/zsh".to_string(), "-c".to_string(), {
let snapshot_path = snapshot_path.to_string_lossy();
format!("[ -f {snapshot_path} ] && . {snapshot_path}; (echo)")
}];
assert_eq!(invocation, Some(expected_command));
}
#[tokio::test]
@@ -240,12 +591,20 @@ mod tests {
let cases = vec![
(
vec!["myecho"],
vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"],
vec![
shell_path,
"-lc",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (myecho)",
],
Some("It works!\n"),
),
(
vec!["myecho"],
vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"],
vec![
shell_path,
"-lc",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (myecho)",
],
Some("It works!\n"),
),
(
@@ -253,7 +612,7 @@ mod tests {
vec![
shell_path,
"-lc",
"source ZSHRC_PATH && (bash -c \"echo 'single' \\\"double\\\"\")",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (bash -c \"echo 'single' \\\"double\\\"\")",
],
Some("single double\n"),
),
@@ -262,7 +621,7 @@ mod tests {
vec![
shell_path,
"-lc",
"source ZSHRC_PATH && (echo 'single' \"double\")",
"[ -f ZSHRC_PATH ] && . ZSHRC_PATH; (echo 'single' \"double\")",
],
Some("single double\n"),
),
@@ -289,9 +648,10 @@ mod tests {
"#,
)
.unwrap();
let shell = Shell::Zsh(ZshShell {
let shell = Shell::Posix(PosixShell {
shell_path: shell_path.to_string(),
zshrc_path: zshrc_path.to_str().unwrap().to_string(),
rc_path: zshrc_path.to_str().unwrap().to_string(),
shell_snapshot: None,
});
let actual_cmd = shell

View File

@@ -1,37 +0,0 @@
const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
pub fn get_codex_user_agent(originator: Option<&str>) -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
format!(
"{}/{build_version} ({} {}; {}) {}",
originator.unwrap_or(DEFAULT_ORIGINATOR),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
crate::terminal::user_agent()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent(None);
assert!(user_agent.starts_with("codex_cli_rs/"));
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent(None);
let re = Regex::new(
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
)
.unwrap();
assert!(re.is_match(&user_agent));
}
}

View File

@@ -0,0 +1,42 @@
use serde::Deserialize;
use serde::Serialize;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG;
/// Wraps user instructions in a tag so the model can classify them easily.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
pub(crate) struct UserInstructions {
text: String,
}
impl UserInstructions {
pub fn new<T: Into<String>>(text: T) -> Self {
Self { text: text.into() }
}
/// Serializes the user instructions to an XML-like tagged block that starts
/// with <user_instructions> so clients can classify it.
pub fn serialize_to_xml(self) -> String {
format!(
"{USER_INSTRUCTIONS_OPEN_TAG}\n\n{}\n\n{USER_INSTRUCTIONS_CLOSE_TAG}",
self.text
)
}
}
impl From<UserInstructions> for ResponseItem {
fn from(ui: UserInstructions) -> Self {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: ui.serialize_to_xml(),
}],
}
}
}

View File

@@ -11,11 +11,11 @@ use codex_core::ReasoningItemContent;
use codex_core::ResponseItem;
use codex_core::WireApi;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::mcp_protocol::ConversationId;
use core_test_support::load_default_config_for_test;
use futures::StreamExt;
use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -76,7 +76,7 @@ async fn run_request(input: Vec<ResponseItem>) -> Value {
provider,
effort,
summary,
Uuid::new_v4(),
ConversationId::new(),
);
let mut prompt = Prompt::default();

View File

@@ -8,10 +8,10 @@ use codex_core::ResponseEvent;
use codex_core::ResponseItem;
use codex_core::WireApi;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::mcp_protocol::ConversationId;
use core_test_support::load_default_config_for_test;
use futures::StreamExt;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -69,7 +69,7 @@ async fn run_stream(sse_body: &str) -> Vec<ResponseEvent> {
provider,
effort,
summary,
Uuid::new_v4(),
ConversationId::new(),
);
let mut prompt = Prompt::default();

View File

@@ -388,8 +388,7 @@ async fn integration_creates_and_checks_session_file() {
"No message found in session file containing the marker"
);
// Second run: resume should create a NEW session file that contains both old and new history.
let orig_len = content.lines().count();
// Second run: resume should update the existing file.
let marker2 = format!("integration-resume-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
// Crossplatform safe resume override. On Windows, backslashes in a TOML string must be escaped
@@ -449,8 +448,8 @@ async fn integration_creates_and_checks_session_file() {
}
let resumed_path = resumed_path.expect("No resumed session file found containing the marker2");
// Resume should have written to a new file, not the original one.
assert_ne!(
// Resume should write to the existing log file.
assert_eq!(
resumed_path, path,
"resume should create a new session file"
);
@@ -464,14 +463,6 @@ async fn integration_creates_and_checks_session_file() {
resumed_content.contains(&marker2),
"resumed file missing resumed marker"
);
// Original file should remain unchanged.
let content_after = std::fs::read_to_string(&path).unwrap();
assert_eq!(
content_after.lines().count(),
orig_len,
"original rollout file should not change on resume"
);
}
/// Integration test to verify git info is collected and recorded in session files.

View File

@@ -13,7 +13,9 @@ use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use serde_json::json;
use std::io::Write;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -96,7 +98,7 @@ fn write_auth_json(
"OPENAI_API_KEY": openai_api_key,
"tokens": tokens,
// RFC3339 datetime; value doesn't matter for these tests
"last_refresh": "2025-08-06T20:41:36.232376Z",
"last_refresh": chrono::Utc::now(),
});
std::fs::write(
@@ -109,7 +111,138 @@ fn write_auth_json(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_session_id_and_model_headers_in_request() {
async fn resume_includes_initial_messages_and_sends_prior_items() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
// Create a fake rollout session file with prior user + system + assistant messages.
let tmpdir = TempDir::new().unwrap();
let session_path = tmpdir.path().join("resume-session.jsonl");
let mut f = std::fs::File::create(&session_path).unwrap();
writeln!(
f,
"{}",
json!({"meta":"test","instructions":"be nice", "id": Uuid::new_v4(), "timestamp": "2024-01-01T00:00:00Z"})
)
.unwrap();
// Prior item: user message (should be delivered)
let prior_user = codex_protocol::models::ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![codex_protocol::models::ContentItem::InputText {
text: "resumed user message".to_string(),
}],
};
writeln!(f, "{}", serde_json::to_string(&prior_user).unwrap()).unwrap();
// Prior item: system message (excluded from API history)
let prior_system = codex_protocol::models::ResponseItem::Message {
id: None,
role: "system".to_string(),
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "resumed system instruction".to_string(),
}],
};
writeln!(f, "{}", serde_json::to_string(&prior_system).unwrap()).unwrap();
// Prior item: assistant message
let prior_item = codex_protocol::models::ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![codex_protocol::models::ContentItem::OutputText {
text: "resumed assistant message".to_string(),
}],
};
writeln!(f, "{}", serde_json::to_string(&prior_item).unwrap()).unwrap();
drop(f);
// Mock server that will receive the resumed request
let server = MockServer::start().await;
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_raw(sse_completed("resp1"), "text/event-stream");
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(first)
.expect(1)
.mount(&server)
.await;
// Configure Codex to resume from our file
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
config.experimental_resume = Some(session_path.clone());
// Also configure user instructions to ensure they are NOT delivered on resume.
config.user_instructions = Some("be nice".to_string());
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let NewConversation {
conversation: codex,
session_configured,
..
} = conversation_manager
.new_conversation(config)
.await
.expect("create new conversation");
// 1) Assert initial_messages contains the prior user + assistant messages as EventMsg entries
let initial_msgs = session_configured
.initial_messages
.clone()
.expect("expected initial messages for resumed session");
let initial_json = serde_json::to_value(&initial_msgs).unwrap();
let expected_initial_json = json!([
{ "type": "user_message", "message": "resumed user message", "kind": "plain" },
{ "type": "agent_message", "message": "resumed assistant message" }
]);
assert_eq!(initial_json, expected_initial_json);
// 2) Submit new input; the request body must include the prior item followed by the new user input.
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let request = &server.received_requests().await.unwrap()[0];
let request_body = request.body_json::<serde_json::Value>().unwrap();
let expected_input = json!([
{
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "resumed user message" }]
},
{
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "resumed assistant message" }]
},
{
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": "hello" }]
}
]);
assert_eq!(request_body["input"], expected_input);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_conversation_id_and_model_headers_in_request() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
@@ -166,12 +299,12 @@ async fn includes_session_id_and_model_headers_in_request() {
// get request from the server
let request = &server.received_requests().await.unwrap()[0];
let request_session_id = request.headers.get("session_id").unwrap();
let request_conversation_id = request.headers.get("conversation_id").unwrap();
let request_authorization = request.headers.get("authorization").unwrap();
let request_originator = request.headers.get("originator").unwrap();
assert_eq!(
request_session_id.to_str().unwrap(),
request_conversation_id.to_str().unwrap(),
conversation_id.to_string()
);
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
@@ -344,14 +477,14 @@ async fn chatgpt_auth_sends_correct_request() {
// get request from the server
let request = &server.received_requests().await.unwrap()[0];
let request_session_id = request.headers.get("session_id").unwrap();
let request_conversation_id = request.headers.get("conversation_id").unwrap();
let request_authorization = request.headers.get("authorization").unwrap();
let request_originator = request.headers.get("originator").unwrap();
let request_chatgpt_account_id = request.headers.get("chatgpt-account-id").unwrap();
let request_body = request.body_json::<serde_json::Value>().unwrap();
assert_eq!(
request_session_id.to_str().unwrap(),
request_conversation_id.to_str().unwrap(),
conversation_id.to_string()
);
assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs");
@@ -360,7 +493,6 @@ async fn chatgpt_auth_sends_correct_request() {
"Bearer Access Token"
);
assert_eq!(request_chatgpt_account_id.to_str().unwrap(), "account_id");
assert!(!request_body["store"].as_bool().unwrap());
assert!(request_body["stream"].as_bool().unwrap());
assert_eq!(
request_body["include"][0].as_str().unwrap(),
@@ -414,12 +546,15 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
config.model_provider = model_provider;
config.preferred_auth_method = AuthMode::ChatGPT;
let auth_manager =
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let auth_manager = match CodexAuth::from_codex_home(
codex_home.path(),
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
@@ -439,14 +574,6 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// verify request body flags
let request = &server.received_requests().await.unwrap()[0];
let request_body = request.body_json::<serde_json::Value>().unwrap();
assert!(
!request_body["store"].as_bool().unwrap(),
"store should be false for ChatGPT auth"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -495,12 +622,15 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
config.model_provider = model_provider;
config.preferred_auth_method = AuthMode::ApiKey;
let auth_manager =
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let auth_manager = match CodexAuth::from_codex_home(
codex_home.path(),
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
@@ -520,14 +650,6 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// verify request body flags
let request = &server.received_requests().await.unwrap()[0];
let request_body = request.body_json::<serde_json::Value>().unwrap();
assert!(
request_body["store"].as_bool().unwrap(),
"store should be true for API key auth"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -845,34 +967,29 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)");
// Replace full-array compare with tail-only raw JSON compare using a single hard-coded value.
let r3_tail_expected = serde_json::json!([
let r3_tail_expected = json!([
{
"type": "message",
"id": null,
"role": "user",
"content": [{"type":"input_text","text":"U1"}]
},
{
"type": "message",
"id": null,
"role": "assistant",
"content": [{"type":"output_text","text":"Hey there!\n"}]
},
{
"type": "message",
"id": null,
"role": "user",
"content": [{"type":"input_text","text":"U2"}]
},
{
"type": "message",
"id": null,
"role": "assistant",
"content": [{"type":"output_text","text":"Hey there!\n"}]
},
{
"type": "message",
"id": null,
"role": "user",
"content": [{"type":"input_text","text":"U3"}]
}

View File

@@ -17,6 +17,7 @@ use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -269,7 +270,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2, "expected two POST requests");
let shell = default_user_shell().await;
let shell = default_user_shell(Uuid::new_v4(), codex_home.path()).await;
let expected_env_text = format!(
r#"<environment_context>
@@ -289,20 +290,17 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let expected_env_msg = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_env_text } ]
});
let expected_ui_msg = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_ui_text } ]
});
let expected_user_message_1 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 1" } ]
});
@@ -314,7 +312,6 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
@@ -424,7 +421,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
// as the prefix of the second request, ensuring cache hit potential.
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
@@ -438,7 +434,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() {
</environment_context>"#;
let expected_env_msg_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
});
@@ -543,7 +538,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
// as the prefix of the second request.
let expected_user_message_2 = serde_json::json!({
"type": "message",
"id": serde_json::Value::Null,
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});

View File

@@ -159,6 +159,41 @@ async fn read_only_forbids_all_writes() {
.await;
}
/// Verify that user lookups via `pwd.getpwuid(os.getuid())` work under the
/// seatbelt sandbox. Prior to allowing the necessary machlookup for
/// OpenDirectory libinfo, this would fail with `KeyError: getpwuid(): uid not found`.
#[tokio::test]
async fn python_getpwuid_works_under_seatbelt() {
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
return;
}
// ReadOnly is sufficient here since we are only exercising user lookup.
let policy = SandboxPolicy::ReadOnly;
let mut child = spawn_command_under_seatbelt(
vec![
"python3".to_string(),
"-c".to_string(),
// Print the passwd struct; success implies lookup worked.
"import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(),
],
&policy,
std::env::current_dir().expect("should be able to get current dir"),
StdioPolicy::RedirectForShellTool,
HashMap::new(),
)
.await
.expect("should be able to spawn python under seatbelt");
let status = child
.wait()
.await
.expect("should be able to wait for child process");
assert!(status.success(), "python exited with {status:?}");
}
#[expect(clippy::expect_used)]
fn create_test_scenario(tmp: &TempDir) -> TestScenario {
let repo_parent = tmp.path().to_path_buf();

View File

@@ -0,0 +1,88 @@
use std::time::Duration;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_login::CodexAuth;
use core_test_support::load_default_config_for_test;
use core_test_support::wait_for_event_with_timeout;
use serde_json::json;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fails_fast_on_unexpected_status() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let server = MockServer::start().await;
let err_body = json!({
"error": {"message": "bad request"}
});
let tmpl = ResponseTemplate::new(400)
.insert_header("content-type", "application/json")
.set_body_string(err_body.to_string());
Mock::given(method("POST"))
.and(path("/v1/responses"))
.respond_with(tmpl)
.expect(1)
.mount(&server)
.await;
let provider = ModelProviderInfo {
name: "openai".into(),
base_url: Some(format!("{}/v1", server.uri())),
env_key: Some("PATH".into()),
env_key_instructions: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(3),
stream_idle_timeout_ms: Some(2000),
requires_openai_auth: false,
};
let home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&home);
config.model_provider = provider;
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation(config)
.await
.unwrap()
.conversation;
codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: "hello".into(),
}],
})
.await
.unwrap();
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::Error(_)),
Duration::from_secs(5),
)
.await;
}

View File

@@ -25,7 +25,6 @@ codex-common = { path = "../common", features = [
"sandbox_summary",
] }
codex-core = { path = "../core" }
codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" }
codex-protocol = { path = "../protocol" }
owo-colors = "4.2.0"

View File

@@ -26,6 +26,7 @@ use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::num_format::format_with_separators;
use owo_colors::OwoColorize;
use owo_colors::Style;
use shlex::try_join;
@@ -189,8 +190,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(token_usage) => {
ts_println!(self, "tokens used: {}", token_usage.blended_total());
EventMsg::TokenCount(ev) => {
if let Some(usage_info) = ev.info {
ts_println!(
self,
"tokens used: {}",
format_with_separators(usage_info.total_token_usage.blended_total())
);
}
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
if !self.answer_started {
@@ -511,17 +518,18 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::SessionConfigured(session_configured_event) => {
let SessionConfiguredEvent {
session_id,
session_id: conversation_id,
model,
history_log_id: _,
history_entry_count: _,
initial_messages: _,
} = session_configured_event;
ts_println!(
self,
"{} {}",
"codex session".style(self.magenta).style(self.bold),
session_id.to_string().style(self.dimmed)
conversation_id.to_string().style(self.dimmed)
);
ts_println!(self, "model: {}", model);
@@ -551,6 +559,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
},
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
EventMsg::ConversationHistory(_) => {}
EventMsg::UserMessage(_) => {}
}
CodexStatus::Running
}

View File

@@ -149,7 +149,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
include_plan_tool: None,
include_apply_patch_tool: None,
include_view_image_tool: None,
disable_response_storage: oss.then_some(true),
show_raw_agent_reasoning: oss.then_some(true),
tools_web_search_request: None,
};
@@ -191,6 +190,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
));
let NewConversation {
conversation_id: _,

View File

@@ -151,7 +151,13 @@ pub fn run(
// Use the same tree-walker library that ripgrep uses. We use it directly so
// that we can leverage the parallelism it provides.
let mut walk_builder = WalkBuilder::new(search_directory);
walk_builder.threads(num_walk_builder_threads);
walk_builder
.threads(num_walk_builder_threads)
// Allow hidden entries.
.hidden(false)
// Don't require git to be present to apply to apply git-related ignore rules.
.require_git(false);
if !exclude.is_empty() {
let mut override_builder = OverrideBuilder::new(search_directory);
for exclude in exclude {

View File

@@ -30,3 +30,7 @@ fix *args:
install:
rustup show active-toolchain
cargo fetch
# Run the MCP server
mcp-server-run *args:
cargo run -p codex-mcp-server -- "$@"

View File

@@ -15,9 +15,7 @@ path = "src/lib.rs"
workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-common = { path = "../common", features = ["cli"] }
codex-core = { path = "../core" }
landlock = "0.4.1"
libc = "0.2.175"

View File

@@ -17,7 +17,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tempfile = "3"
thiserror = "2.0.16"
tiny_http = "0.12"
tokio = { version = "1", features = [
"io-std",
@@ -31,5 +30,4 @@ urlencoding = "2.1"
webbrowser = "1.0"
[dev-dependencies]
pretty_assertions = "1.4.1"
tempfile = "3"

View File

@@ -1,9 +1,14 @@
use std::io::Cursor;
use std::io::Read;
use std::io::Write;
use std::io::{self};
use std::net::SocketAddr;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce;
@@ -30,10 +35,11 @@ pub struct ServerOptions {
pub port: u16,
pub open_browser: bool,
pub force_state: Option<String>,
pub originator: String,
}
impl ServerOptions {
pub fn new(codex_home: PathBuf, client_id: String) -> Self {
pub fn new(codex_home: PathBuf, client_id: String, originator: String) -> Self {
Self {
codex_home,
client_id: client_id.to_string(),
@@ -41,6 +47,7 @@ impl ServerOptions {
port: DEFAULT_PORT,
open_browser: true,
force_state: None,
originator,
}
}
}
@@ -83,7 +90,7 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let pkce = generate_pkce();
let state = opts.force_state.clone().unwrap_or_else(generate_state);
let server = Server::http(format!("127.0.0.1:{}", opts.port)).map_err(io::Error::other)?;
let server = bind_server(opts.port)?;
let actual_port = match server.server_addr().to_ip() {
Some(addr) => addr.port(),
None => {
@@ -96,7 +103,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let server = Arc::new(server);
let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state);
let auth_url = build_authorize_url(
&opts.issuer,
&opts.client_id,
&redirect_uri,
&pkce,
&state,
&opts.originator,
);
if opts.open_browser {
let _ = webbrowser::open(&auth_url);
@@ -136,19 +150,24 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let response =
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await;
let is_login_complete = matches!(response, HandledRequest::ResponseAndExit(_));
match response {
HandledRequest::Response(r) | HandledRequest::ResponseAndExit(r) => {
let _ = tokio::task::spawn_blocking(move || req.respond(r)).await;
let exit_result = match response {
HandledRequest::Response(response) => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None
}
HandledRequest::ResponseAndExit { response, result } => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
Some(result)
}
HandledRequest::RedirectWithHeader(header) => {
let redirect = Response::empty(302).with_header(header);
let _ = tokio::task::spawn_blocking(move || req.respond(redirect)).await;
None
}
}
};
if is_login_complete {
break Ok(());
if let Some(result) = exit_result {
break result;
}
}
}
@@ -172,7 +191,10 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit(Response<Cursor<Vec<u8>>>),
ResponseAndExit {
response: Response<Cursor<Vec<u8>>>,
result: io::Result<()>,
},
}
async fn process_request(
@@ -267,8 +289,18 @@ async fn process_request(
) {
resp.add_header(h);
}
HandledRequest::ResponseAndExit(resp)
HandledRequest::ResponseAndExit {
response: resp,
result: Ok(()),
}
}
"/cancel" => HandledRequest::ResponseAndExit {
response: Response::from_string("Login cancelled"),
result: Err(io::Error::new(
io::ErrorKind::Interrupted,
"Login cancelled",
)),
},
_ => HandledRequest::Response(Response::from_string("Not Found").with_status_code(404)),
}
}
@@ -279,6 +311,7 @@ fn build_authorize_url(
redirect_uri: &str,
pkce: &PkceCodes,
state: &str,
originator: &str,
) -> String {
let query = vec![
("response_type", "code"),
@@ -290,6 +323,7 @@ fn build_authorize_url(
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("state", state),
("originator", originator),
];
let qs = query
.into_iter()
@@ -305,6 +339,68 @@ fn generate_state() -> String {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn send_cancel_request(port: u16) -> io::Result<()> {
let addr: SocketAddr = format!("127.0.0.1:{port}")
.parse()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(b"GET /cancel HTTP/1.1\r\n")?;
stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?;
stream.write_all(b"Connection: close\r\n\r\n")?;
let mut buf = [0u8; 64];
let _ = stream.read(&mut buf);
Ok(())
}
fn bind_server(port: u16) -> io::Result<Server> {
let bind_address = format!("127.0.0.1:{port}");
let mut cancel_attempted = false;
let mut attempts = 0;
const MAX_ATTEMPTS: u32 = 10;
const RETRY_DELAY: Duration = Duration::from_millis(200);
loop {
match Server::http(&bind_address) {
Ok(server) => return Ok(server),
Err(err) => {
attempts += 1;
let is_addr_in_use = err
.downcast_ref::<io::Error>()
.map(|io_err| io_err.kind() == io::ErrorKind::AddrInUse)
.unwrap_or(false);
// If the address is in use, there is probably another instance of the login server
// running. Attempt to cancel it and retry.
if is_addr_in_use {
if !cancel_attempted {
cancel_attempted = true;
if let Err(cancel_err) = send_cancel_request(port) {
eprintln!("Failed to cancel previous login server: {cancel_err}");
}
}
thread::sleep(RETRY_DELAY);
if attempts >= MAX_ATTEMPTS {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("Port {bind_address} is already in use"),
));
}
continue;
}
return Err(io::Error::other(err));
}
}
}
}
struct ExchangedTokens {
id_token: String,
access_token: String,

View File

@@ -1,7 +1,9 @@
#![allow(clippy::unwrap_used)]
use std::io;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
use base64::Engine;
use codex_login::ServerOptions;
@@ -100,6 +102,7 @@ async fn end_to_end_login_flow_persists_auth_json() {
port: 0,
open_browser: false,
force_state: Some(state),
originator: "test_originator".to_string(),
};
let server = run_login_server(opts).unwrap();
let login_port = server.actual_port;
@@ -158,6 +161,7 @@ async fn creates_missing_codex_home_dir() {
port: 0,
open_browser: false,
force_state: Some(state),
originator: "test_originator".to_string(),
};
let server = run_login_server(opts).unwrap();
let login_port = server.actual_port;
@@ -175,3 +179,67 @@ async fn creates_missing_codex_home_dir() {
"auth.json should be created even if parent dir was missing"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cancels_previous_login_server_when_port_is_in_use() {
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
println!(
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
);
return;
}
let (issuer_addr, _issuer_handle) = start_mock_issuer();
let issuer = format!("http://{}:{}", issuer_addr.ip(), issuer_addr.port());
let first_tmp = tempdir().unwrap();
let first_codex_home = first_tmp.path().to_path_buf();
let first_opts = ServerOptions {
codex_home: first_codex_home,
client_id: codex_login::CLIENT_ID.to_string(),
issuer: issuer.clone(),
port: 0,
open_browser: false,
force_state: Some("cancel_state".to_string()),
originator: "test_originator".to_string(),
};
let first_server = run_login_server(first_opts).unwrap();
let login_port = first_server.actual_port;
let first_server_task = tokio::spawn(async move { first_server.block_until_done().await });
tokio::time::sleep(Duration::from_millis(100)).await;
let second_tmp = tempdir().unwrap();
let second_codex_home = second_tmp.path().to_path_buf();
let second_opts = ServerOptions {
codex_home: second_codex_home,
client_id: codex_login::CLIENT_ID.to_string(),
issuer,
port: login_port,
open_browser: false,
force_state: Some("cancel_state_2".to_string()),
originator: "test_originator".to_string(),
};
let second_server = run_login_server(second_opts).unwrap();
assert_eq!(second_server.actual_port, login_port);
let cancel_result = first_server_task
.await
.expect("first login server task panicked")
.expect_err("login server should report cancellation");
assert_eq!(cancel_result.kind(), io::ErrorKind::Interrupted);
let client = reqwest::Client::new();
let cancel_url = format!("http://127.0.0.1:{login_port}/cancel");
let resp = client.get(cancel_url).send().await.unwrap();
assert!(resp.status().is_success());
second_server
.block_until_done()
.await
.expect_err("second login server should report cancellation");
}

View File

@@ -26,7 +26,6 @@ schemars = "0.8.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shlex = "1.3.0"
strum_macros = "0.27.2"
tokio = { version = "1", features = [
"io-std",
"macros",
@@ -44,5 +43,4 @@ assert_cmd = "2"
mcp_test_support = { path = "tests/common" }
pretty_assertions = "1.4.1"
tempfile = "3"
tokio-test = "0.4"
wiremock = "0.6"

View File

@@ -1,39 +1,32 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::load_config_as_toml;
use codex_core::git_info::git_diff_to_remote;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ReviewDecision;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::json_to_toml::json_to_toml;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::Cursor as RolloutCursor;
use codex_core::NewConversation;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
use codex_core::auth::CLIENT_ID;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::load_config_as_toml;
use codex_core::default_client::get_codex_user_agent;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::get_platform_sandbox;
use codex_core::git_info::git_diff_to_remote;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::InputItem as CoreInputItem;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
@@ -42,27 +35,51 @@ use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ApplyPatchApprovalParams;
use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::AuthStatusChangeNotification;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::ConversationSummary;
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse;
use codex_protocol::mcp_protocol::ExecCommandApprovalParams;
use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
use codex_protocol::mcp_protocol::GetConfigTomlResponse;
use codex_protocol::mcp_protocol::ExecOneOffCommandParams;
use codex_protocol::mcp_protocol::GetUserAgentResponse;
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::InterruptConversationResponse;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
use codex_protocol::mcp_protocol::LoginChatGptResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SendUserTurnResponse;
use codex_protocol::mcp_protocol::ServerNotification;
use codex_protocol::mcp_protocol::UserSavedConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InputMessageKind;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
// Duration before a ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
@@ -88,7 +105,7 @@ pub(crate) struct CodexMessageProcessor {
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
// Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives.
pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
pending_interrupts: Arc<Mutex<HashMap<ConversationId, Vec<RequestId>>>>,
}
impl CodexMessageProcessor {
@@ -119,6 +136,12 @@ impl CodexMessageProcessor {
// created before processing any subsequent messages.
self.process_new_conversation(request_id, params).await;
}
ClientRequest::ListConversations { request_id, params } => {
self.handle_list_conversations(request_id, params).await;
}
ClientRequest::ResumeConversation { request_id, params } => {
self.handle_resume_conversation(request_id, params).await;
}
ClientRequest::SendUserMessage { request_id, params } => {
self.send_user_message(request_id, params).await;
}
@@ -149,8 +172,14 @@ impl CodexMessageProcessor {
ClientRequest::GetAuthStatus { request_id, params } => {
self.get_auth_status(request_id, params).await;
}
ClientRequest::GetConfigToml { request_id } => {
self.get_config_toml(request_id).await;
ClientRequest::GetUserSavedConfig { request_id } => {
self.get_user_saved_config(request_id).await;
}
ClientRequest::GetUserAgent { request_id } => {
self.get_user_agent(request_id).await;
}
ClientRequest::ExecOneOffCommand { request_id, params } => {
self.exec_one_off_command(request_id, params).await;
}
}
}
@@ -160,7 +189,11 @@ impl CodexMessageProcessor {
let opts = LoginServerOptions {
open_browser: false,
..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
..LoginServerOptions::new(
config.codex_home.clone(),
CLIENT_ID.to_string(),
config.responses_originator_header.clone(),
)
};
enum LoginChatGptReply {
@@ -360,7 +393,13 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
async fn get_config_toml(&self, request_id: RequestId) {
async fn get_user_agent(&self, request_id: RequestId) {
let user_agent = get_codex_user_agent(Some(&self.config.responses_originator_header));
let response = GetUserAgentResponse { user_agent };
self.outgoing.send_response(request_id, response).await;
}
async fn get_user_saved_config(&self, request_id: RequestId) {
let toml_value = match load_config_as_toml(&self.config.codex_home) {
Ok(val) => val,
Err(err) => {
@@ -387,33 +426,82 @@ impl CodexMessageProcessor {
}
};
let profiles: HashMap<String, codex_protocol::config_types::ConfigProfile> = cfg
.profiles
.into_iter()
.map(|(k, v)| {
(
k,
// Define this explicitly here to avoid the need to
// implement `From<codex_core::config_profile::ConfigProfile>`
// for the `ConfigProfile` type and introduce a dependency on codex_core
codex_protocol::config_types::ConfigProfile {
model: v.model,
approval_policy: v.approval_policy,
model_reasoning_effort: v.model_reasoning_effort,
},
)
})
.collect();
let user_saved_config: UserSavedConfig = cfg.into();
let response = GetConfigTomlResponse {
approval_policy: cfg.approval_policy,
sandbox_mode: cfg.sandbox_mode,
model_reasoning_effort: cfg.model_reasoning_effort,
profile: cfg.profile,
profiles: Some(profiles),
let response = GetUserSavedConfigResponse {
config: user_saved_config,
};
self.outgoing.send_response(request_id, response).await;
}
async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) {
tracing::debug!("ExecOneOffCommand params: {params:?}");
if params.command.is_empty() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "command must not be empty".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
let env = create_env(&self.config.shell_environment_policy);
let timeout_ms = params.timeout_ms;
let exec_params = ExecParams {
command: params.command,
cwd,
timeout_ms,
env,
with_escalated_permissions: None,
justification: None,
};
self.outgoing.send_response(request_id, response).await;
let effective_policy = params
.sandbox_policy
.unwrap_or_else(|| self.config.sandbox_policy.clone());
let sandbox_type = match &effective_policy {
codex_core::protocol::SandboxPolicy::DangerFullAccess => {
codex_core::exec::SandboxType::None
}
_ => get_platform_sandbox().unwrap_or(codex_core::exec::SandboxType::None),
};
tracing::debug!("Sandbox type: {sandbox_type:?}");
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
let req_id = request_id;
tokio::spawn(async move {
match codex_core::exec::process_exec_tool_call(
exec_params,
sandbox_type,
&effective_policy,
&codex_linux_sandbox_exe,
None,
)
.await
{
Ok(output) => {
let response = ExecArbitraryCommandResponse {
exit_code: output.exit_code,
stdout: output.stdout.text,
stderr: output.stderr.text,
};
outgoing.send_response(req_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("exec failed: {err}"),
data: None,
};
outgoing.send_error(req_id, error).await;
}
}
});
}
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
@@ -438,7 +526,7 @@ impl CodexMessageProcessor {
..
} = conversation_id;
let response = NewConversationResponse {
conversation_id: ConversationId(conversation_id),
conversation_id,
model: session_configured.model,
};
self.outgoing.send_response(request_id, response).await;
@@ -454,6 +542,133 @@ impl CodexMessageProcessor {
}
}
async fn handle_list_conversations(
&self,
request_id: RequestId,
params: ListConversationsParams,
) {
let page_size = params.page_size.unwrap_or(25);
// Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string)
let cursor_obj: Option<RolloutCursor> = match params.cursor {
Some(s) => serde_json::from_str::<RolloutCursor>(&format!("\"{s}\"")).ok(),
None => None,
};
let cursor_ref = cursor_obj.as_ref();
let page = match RolloutRecorder::list_conversations(
&self.config.codex_home,
page_size,
cursor_ref,
)
.await
{
Ok(p) => p,
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to list conversations: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let items = page
.items
.into_iter()
.filter_map(|it| extract_conversation_summary(it.path, &it.head))
.collect();
// Encode next_cursor as a plain string
let next_cursor = match page.next_cursor {
Some(c) => match serde_json::to_value(&c) {
Ok(serde_json::Value::String(s)) => Some(s),
_ => None,
},
None => None,
};
let response = ListConversationsResponse { items, next_cursor };
self.outgoing.send_response(request_id, response).await;
}
async fn handle_resume_conversation(
&self,
request_id: RequestId,
params: ResumeConversationParams,
) {
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match params.overrides {
Some(overrides) => {
derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone())
}
None => Ok(self.config.as_ref().clone()),
};
let config = match config {
Ok(cfg) => cfg,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.conversation_manager
.resume_conversation_from_rollout(
config,
params.path.clone(),
self.auth_manager.clone(),
)
.await
{
Ok(NewConversation {
conversation_id,
session_configured,
..
}) => {
let event = Event {
id: "".to_string(),
msg: EventMsg::SessionConfigured(session_configured.clone()),
};
self.outgoing.send_event_as_notification(&event, None).await;
let initial_messages = session_configured.initial_messages.map(|msgs| {
msgs.into_iter()
.filter(|event| {
// Don't send non-plain user messages (like user instructions
// or environment context) back so they don't get rendered.
if let EventMsg::UserMessage(user_message) = event {
return matches!(user_message.kind, Some(InputMessageKind::Plain));
}
true
})
.collect()
});
// Reply with conversation id + model and initial messages (when present)
let response = codex_protocol::mcp_protocol::ResumeConversationResponse {
conversation_id,
model: session_configured.model.clone(),
initial_messages,
};
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("error resuming conversation: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) {
let SendUserMessageParams {
conversation_id,
@@ -461,7 +676,7 @@ impl CodexMessageProcessor {
} = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id.0)
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
@@ -509,7 +724,7 @@ impl CodexMessageProcessor {
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id.0)
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
@@ -555,7 +770,7 @@ impl CodexMessageProcessor {
let InterruptConversationParams { conversation_id } = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id.0)
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
@@ -570,7 +785,7 @@ impl CodexMessageProcessor {
// Record the pending interrupt so we can reply when TurnAborted arrives.
{
let mut map = self.pending_interrupts.lock().await;
map.entry(conversation_id.0).or_default().push(request_id);
map.entry(conversation_id).or_default().push(request_id);
}
// Submit the interrupt; we'll respond upon TurnAborted.
@@ -585,12 +800,12 @@ impl CodexMessageProcessor {
let AddConversationListenerParams { conversation_id } = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id.0)
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {}", conversation_id.0),
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
@@ -620,18 +835,18 @@ impl CodexMessageProcessor {
};
// For now, we send a notification for every event,
// JSON-serializing the `Event` as-is, but we will move
// to creating a special enum for notifications with a
// stable wire format.
// JSON-serializing the `Event` as-is, but these should
// be migrated to be variants of `ServerNotification`
// instead.
let method = format!("codex/event/{}", event.msg);
let mut params = match serde_json::to_value(event.clone()) {
Ok(serde_json::Value::Object(map)) => map,
Ok(_) => {
tracing::error!("event did not serialize to an object");
error!("event did not serialize to an object");
continue;
}
Err(err) => {
tracing::error!("failed to serialize event: {err}");
error!("failed to serialize event: {err}");
continue;
}
};
@@ -703,7 +918,7 @@ async fn apply_bespoke_event_handling(
conversation_id: ConversationId,
conversation: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
pending_interrupts: Arc<Mutex<HashMap<Uuid, Vec<RequestId>>>>,
pending_interrupts: Arc<Mutex<HashMap<ConversationId, Vec<RequestId>>>>,
) {
let Event { id: event_id, msg } = event;
match msg {
@@ -756,7 +971,7 @@ async fn apply_bespoke_event_handling(
EventMsg::TurnAborted(turn_aborted_event) => {
let pending = {
let mut map = pending_interrupts.lock().await;
map.remove(&conversation_id.0).unwrap_or_default()
map.remove(&conversation_id).unwrap_or_default()
};
if !pending.is_empty() {
let response = InterruptConversationResponse {
@@ -799,7 +1014,6 @@ fn derive_config_from_params(
include_plan_tool,
include_apply_patch_tool,
include_view_image_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
tools_web_search_request: None,
};
@@ -815,7 +1029,7 @@ fn derive_config_from_params(
async fn on_patch_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
receiver: oneshot::Receiver<mcp_types::Result>,
codex: Arc<CodexConversation>,
) {
let response = receiver.await;
@@ -857,14 +1071,14 @@ async fn on_patch_approval_response(
async fn on_exec_approval_response(
event_id: String,
receiver: tokio::sync::oneshot::Receiver<mcp_types::Result>,
receiver: oneshot::Receiver<mcp_types::Result>,
conversation: Arc<CodexConversation>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Err(err) => {
tracing::error!("request failed: {err:?}");
error!("request failed: {err:?}");
return;
}
};
@@ -890,3 +1104,100 @@ async fn on_exec_approval_response(
error!("failed to submit ExecApproval: {err}");
}
}
fn extract_conversation_summary(
path: PathBuf,
head: &[serde_json::Value],
) -> Option<ConversationSummary> {
let session_meta = match head.first() {
Some(first_line) => match serde_json::from_value::<SessionMeta>(first_line.clone()) {
Ok(session_meta) => session_meta,
Err(..) => return None,
},
None => return None,
};
let preview = head
.iter()
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
.find_map(|item| match item {
ResponseItem::Message { content, .. } => {
content.into_iter().find_map(|content| match content {
ContentItem::InputText { text } => {
match InputMessageKind::from(("user", &text)) {
InputMessageKind::Plain => Some(text),
_ => None,
}
}
_ => None,
})
}
_ => None,
})?;
let preview = match preview.find(USER_MESSAGE_BEGIN) {
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(),
None => preview.as_str(),
};
let timestamp = if session_meta.timestamp.is_empty() {
None
} else {
Some(session_meta.timestamp.clone())
};
Some(ConversationSummary {
conversation_id: session_meta.id,
timestamp,
path,
preview: preview.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn extract_conversation_summary_prefers_plain_user_messages() {
let conversation_id =
ConversationId(Uuid::parse_str("3f941c35-29b3-493b-b0a4-e25800d9aeb0").unwrap());
let timestamp = Some("2025-09-05T16:53:11.850Z".to_string());
let path = PathBuf::from("rollout.jsonl");
let head = vec![
json!({
"id": conversation_id.0,
"timestamp": timestamp,
}),
json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "<user_instructions>\n<AGENTS.md contents>\n</user_instructions>".to_string(),
}],
}),
json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": format!("<prior context> {USER_MESSAGE_BEGIN}Count to 5"),
}],
}),
];
let summary = extract_conversation_summary(path.clone(), &head).expect("summary");
assert_eq!(summary.conversation_id, conversation_id);
assert_eq!(
summary.timestamp,
Some("2025-09-05T16:53:11.850Z".to_string())
);
assert_eq!(summary.path, path);
assert_eq!(summary.preview, "Count to 5");
}
}

View File

@@ -162,7 +162,6 @@ impl CodexToolCallParam {
include_plan_tool,
include_apply_patch_tool: None,
include_view_image_tool: None,
disable_response_storage: None,
show_raw_agent_reasoning: None,
tools_web_search_request: None,
};
@@ -182,8 +181,8 @@ impl CodexToolCallParam {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CodexToolCallReplyParam {
/// The *session id* for this conversation.
pub session_id: String,
/// The conversation id for this Codex session.
pub conversation_id: String,
/// The *next user prompt* to continue the Codex conversation.
pub prompt: String,
@@ -214,7 +213,8 @@ pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool {
input_schema: tool_input_schema,
output_schema: None,
description: Some(
"Continue a Codex session by providing the session id and prompt.".to_string(),
"Continue a Codex conversation by providing the conversation id and prompt."
.to_string(),
),
annotations: None,
}
@@ -309,21 +309,21 @@ mod tests {
let tool = create_tool_for_codex_tool_call_reply_param();
let tool_json = serde_json::to_value(&tool).expect("tool serializes");
let expected_tool_json = serde_json::json!({
"description": "Continue a Codex session by providing the session id and prompt.",
"description": "Continue a Codex conversation by providing the conversation id and prompt.",
"inputSchema": {
"properties": {
"conversationId": {
"description": "The conversation id for this Codex session.",
"type": "string"
},
"prompt": {
"description": "The *next user prompt* to continue the Codex conversation.",
"type": "string"
},
"sessionId": {
"description": "The *session id* for this conversation.",
"type": "string"
},
},
"required": [
"conversationId",
"prompt",
"sessionId",
],
"type": "object",
},

View File

@@ -5,6 +5,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::exec_approval::handle_exec_approval_request;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotificationMeta;
use crate::patch_approval::handle_patch_approval_request;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
@@ -18,18 +22,13 @@ use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::Submission;
use codex_core::protocol::TaskCompleteEvent;
use codex_protocol::mcp_protocol::ConversationId;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::RequestId;
use mcp_types::TextContent;
use serde_json::json;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::exec_approval::handle_exec_approval_request;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotificationMeta;
use crate::patch_approval::handle_patch_approval_request;
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
@@ -43,7 +42,7 @@ pub async fn run_codex_tool_session(
config: CodexConfig,
outgoing: Arc<OutgoingMessageSender>,
conversation_manager: Arc<ConversationManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
) {
let NewConversation {
conversation_id,
@@ -119,13 +118,13 @@ pub async fn run_codex_tool_session_reply(
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
prompt: String,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
session_id: Uuid,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
conversation_id: ConversationId,
) {
running_requests_id_to_codex_uuid
.lock()
.await
.insert(request_id.clone(), session_id);
.insert(request_id.clone(), conversation_id);
if let Err(e) = conversation
.submit(Op::UserInput {
items: vec![InputItem::Text { text: prompt }],
@@ -154,7 +153,7 @@ async fn run_codex_tool_session_inner(
codex: Arc<CodexConversation>,
outgoing: Arc<OutgoingMessageSender>,
request_id: RequestId,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
) {
let request_id_str = match &request_id {
RequestId::String(s) => s.clone(),
@@ -279,6 +278,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::PlanUpdate(_)
| EventMsg::TurnAborted(_)
| EventMsg::ConversationHistory(_)
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete => {
// For now, we do not do anything extra for these
// events. Note that

View File

@@ -9,6 +9,7 @@ use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -41,7 +42,7 @@ pub(crate) struct MessageProcessor {
initialized: bool,
codex_linux_sandbox_exe: Option<PathBuf>,
conversation_manager: Arc<ConversationManager>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
}
impl MessageProcessor {
@@ -53,8 +54,11 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager =
AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
@@ -433,7 +437,10 @@ impl MessageProcessor {
tracing::info!("tools/call -> params: {:?}", arguments);
// parse arguments
let CodexToolCallReplyParam { session_id, prompt } = match arguments {
let CodexToolCallReplyParam {
conversation_id,
prompt,
} = match arguments {
Some(json_val) => match serde_json::from_value::<CodexToolCallReplyParam>(json_val) {
Ok(params) => params,
Err(e) => {
@@ -454,12 +461,12 @@ impl MessageProcessor {
},
None => {
tracing::error!(
"Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required."
"Missing arguments for codex-reply tool-call; the `conversation_id` and `prompt` fields are required."
);
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: "Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required.".to_owned(),
text: "Missing arguments for codex-reply tool-call; the `conversation_id` and `prompt` fields are required.".to_owned(),
annotations: None,
})],
is_error: Some(true),
@@ -470,14 +477,14 @@ impl MessageProcessor {
return;
}
};
let session_id = match Uuid::parse_str(&session_id) {
Ok(id) => id,
let conversation_id = match Uuid::parse_str(&conversation_id) {
Ok(id) => ConversationId::from(id),
Err(e) => {
tracing::error!("Failed to parse session_id: {e}");
tracing::error!("Failed to parse conversation_id: {e}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to parse session_id: {e}"),
text: format!("Failed to parse conversation_id: {e}"),
annotations: None,
})],
is_error: Some(true),
@@ -493,14 +500,18 @@ impl MessageProcessor {
let outgoing = self.outgoing.clone();
let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone();
let codex = match self.conversation_manager.get_conversation(session_id).await {
let codex = match self
.conversation_manager
.get_conversation(conversation_id)
.await
{
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for session_id: {session_id}");
tracing::warn!("Session not found for conversation_id: {conversation_id}");
let result = CallToolResult {
content: vec![ContentBlock::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Session not found for session_id: {session_id}"),
text: format!("Session not found for conversation_id: {conversation_id}"),
annotations: None,
})],
is_error: Some(true),
@@ -525,7 +536,7 @@ impl MessageProcessor {
request_id,
prompt,
running_requests_id_to_codex_uuid,
session_id,
conversation_id,
)
.await;
}
@@ -561,24 +572,28 @@ impl MessageProcessor {
RequestId::Integer(i) => i.to_string(),
};
// Obtain the session_id while holding the first lock, then release.
let session_id = {
// Obtain the conversation id while holding the first lock, then release.
let conversation_id = {
let map_guard = self.running_requests_id_to_codex_uuid.lock().await;
match map_guard.get(&request_id) {
Some(id) => *id, // Uuid is Copy
Some(id) => *id,
None => {
tracing::warn!("Session not found for request_id: {}", request_id_string);
return;
}
}
};
tracing::info!("session_id: {session_id}");
tracing::info!("conversation_id: {conversation_id}");
// Obtain the Codex conversation from the server.
let codex_arc = match self.conversation_manager.get_conversation(session_id).await {
let codex_arc = match self
.conversation_manager
.get_conversation(conversation_id)
.await
{
Ok(c) => c,
Err(_) => {
tracing::warn!("Session not found for session_id: {session_id}");
tracing::warn!("Session not found for conversation_id: {conversation_id}");
return;
}
};

View File

@@ -97,6 +97,9 @@ impl OutgoingMessageSender {
}
}
/// This is used with the MCP server, but not the more general JSON-RPC app
/// server. Prefer [`OutgoingMessageSender::send_server_notification`] where
/// possible.
pub(crate) async fn send_event_as_notification(
&self,
event: &Event,
@@ -123,14 +126,9 @@ impl OutgoingMessageSender {
}
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
let method = format!("codex/event/{notification}");
let params = match serde_json::to_value(&notification) {
Ok(serde_json::Value::Object(mut map)) => map.remove("data"),
_ => None,
};
let outgoing_message =
OutgoingMessage::Notification(OutgoingNotification { method, params });
let _ = self.sender.send(outgoing_message);
let _ = self
.sender
.send(OutgoingMessage::AppServerNotification(notification));
}
pub(crate) async fn send_notification(&self, notification: OutgoingNotification) {
@@ -148,6 +146,9 @@ impl OutgoingMessageSender {
pub(crate) enum OutgoingMessage {
Request(OutgoingRequest),
Notification(OutgoingNotification),
/// AppServerNotification is specific to the case where this is run as an
/// "app server" as opposed to an MCP server.
AppServerNotification(ServerNotification),
Response(OutgoingResponse),
Error(OutgoingError),
}
@@ -171,6 +172,21 @@ impl From<OutgoingMessage> for JSONRPCMessage {
params,
})
}
AppServerNotification(notification) => {
let method = notification.to_string();
let params = match notification.to_params() {
Ok(params) => Some(params),
Err(err) => {
warn!("failed to serialize notification params: {err}");
None
}
};
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method,
params,
})
}
Response(OutgoingResponse { id, result }) => {
JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
@@ -242,6 +258,8 @@ pub(crate) struct OutgoingError {
mod tests {
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use uuid::Uuid;
@@ -253,13 +271,15 @@ mod tests {
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let conversation_id = ConversationId::new();
let event = Event {
id: "1".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: Uuid::new_v4(),
session_id: conversation_id,
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
}),
};
@@ -284,11 +304,13 @@ mod tests {
let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::<OutgoingMessage>();
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let conversation_id = ConversationId::new();
let session_configured_event = SessionConfiguredEvent {
session_id: Uuid::new_v4(),
session_id: conversation_id,
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
initial_messages: None,
};
let event = Event {
id: "1".to_string(),
@@ -322,4 +344,29 @@ mod tests {
});
assert_eq!(params.unwrap(), expected_params);
}
#[test]
fn verify_server_notification_serialization() {
let notification =
ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification {
login_id: Uuid::nil(),
success: true,
error: None,
});
let jsonrpc_notification: JSONRPCMessage =
OutgoingMessage::AppServerNotification(notification).into();
assert_eq!(
JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: "2.0".into(),
method: "loginChatGptComplete".into(),
params: Some(json!({
"loginId": Uuid::nil(),
"success": true,
})),
}),
jsonrpc_notification,
"ensure the strum macros serialize the method field correctly"
);
}
}

View File

@@ -9,20 +9,16 @@ path = "lib.rs"
[dependencies]
anyhow = "1"
assert_cmd = "2"
codex-core = { path = "../../../core" }
codex-mcp-server = { path = "../.." }
codex-protocol = { path = "../../../protocol" }
mcp-types = { path = "../../../mcp-types" }
pretty_assertions = "1.4.1"
serde = { version = "1" }
serde_json = "1"
shlex = "1.3.0"
tempfile = "3"
tokio = { version = "1", features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
] }
uuid = { version = "1", features = ["serde", "v4"] }
wiremock = "0.6"

View File

@@ -16,8 +16,10 @@ use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
@@ -240,9 +242,32 @@ impl McpProcess {
self.send_request("getAuthStatus", params).await
}
/// Send a `getConfigToml` JSON-RPC request.
pub async fn send_get_config_toml_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getConfigToml", None).await
/// Send a `getUserSavedConfig` JSON-RPC request.
pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserSavedConfig", None).await
}
/// Send a `getUserAgent` JSON-RPC request.
pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result<i64> {
self.send_request("getUserAgent", None).await
}
/// Send a `listConversations` JSON-RPC request.
pub async fn send_list_conversations_request(
&mut self,
params: ListConversationsParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("listConversations", params).await
}
/// Send a `resumeConversation` JSON-RPC request.
pub async fn send_resume_conversation_request(
&mut self,
params: ResumeConversationParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("resumeConversation", params).await
}
/// Send a `loginChatGpt` JSON-RPC request.

View File

@@ -2,10 +2,15 @@ use std::collections::HashMap;
use std::path::Path;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ConfigProfile;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::mcp_protocol::GetConfigTomlResponse;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
use codex_protocol::mcp_protocol::Profile;
use codex_protocol::mcp_protocol::SandboxSettings;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
@@ -21,22 +26,38 @@ fn create_config_toml(codex_home: &Path) -> std::io::Result<()> {
std::fs::write(
config_toml,
r#"
model = "gpt-5"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
model_reasoning_summary = "detailed"
model_reasoning_effort = "high"
model_verbosity = "medium"
profile = "test"
[sandbox_workspace_write]
writable_roots = ["/tmp"]
network_access = true
exclude_tmpdir_env_var = true
exclude_slash_tmp = true
[tools]
web_search = false
view_image = true
[profiles.test]
model = "gpt-4o"
approval_policy = "on-request"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
model_verbosity = "medium"
model_provider = "openai"
chatgpt_base_url = "https://api.chatgpt.com"
"#,
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_config_toml_returns_subset() {
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn get_config_toml_parses_all_fields() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).expect("write config.toml");
@@ -49,32 +70,94 @@ async fn get_config_toml_returns_subset() {
.expect("init failed");
let request_id = mcp
.send_get_config_toml_request()
.send_get_user_saved_config_request()
.await
.expect("send getConfigToml");
.expect("send getUserSavedConfig");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getConfigToml timeout")
.expect("getConfigToml response");
.expect("getUserSavedConfig timeout")
.expect("getUserSavedConfig response");
let config: GetConfigTomlResponse = to_response(resp).expect("deserialize config");
let expected = GetConfigTomlResponse {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
model_reasoning_effort: Some(ReasoningEffort::High),
profile: Some("test".to_string()),
profiles: Some(HashMap::from([(
"test".into(),
ConfigProfile {
model: Some("gpt-4o".into()),
approval_policy: Some(AskForApproval::OnRequest),
model_reasoning_effort: Some(ReasoningEffort::High),
},
)])),
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: Some(AskForApproval::OnRequest),
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
sandbox_settings: Some(SandboxSettings {
writable_roots: vec!["/tmp".into()],
network_access: Some(true),
exclude_tmpdir_env_var: Some(true),
exclude_slash_tmp: Some(true),
}),
model: Some("gpt-5".into()),
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_verbosity: Some(Verbosity::Medium),
tools: Some(Tools {
web_search: Some(false),
view_image: Some(true),
}),
profile: Some("test".to_string()),
profiles: HashMap::from([(
"test".into(),
Profile {
model: Some("gpt-4o".into()),
approval_policy: Some(AskForApproval::OnRequest),
model_reasoning_effort: Some(ReasoningEffort::High),
model_reasoning_summary: Some(ReasoningSummary::Detailed),
model_verbosity: Some(Verbosity::Medium),
model_provider: Some("openai".into()),
chatgpt_base_url: Some("https://api.chatgpt.com".into()),
},
)]),
},
};
assert_eq!(expected, config);
assert_eq!(config, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_config_toml_empty() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
let request_id = mcp
.send_get_user_saved_config_request()
.await
.expect("send getUserSavedConfig");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getUserSavedConfig timeout")
.expect("getUserSavedConfig response");
let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config");
let expected = GetUserSavedConfigResponse {
config: UserSavedConfig {
approval_policy: None,
sandbox_mode: None,
sandbox_settings: None,
model: None,
model_reasoning_effort: None,
model_reasoning_summary: None,
model_verbosity: None,
tools: None,
profile: None,
profiles: HashMap::new(),
},
};
assert_eq!(config, expected);
}

View File

@@ -0,0 +1,172 @@
use std::fs;
use std::path::Path;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::ResumeConversationResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_list_and_resume_conversations() {
// Prepare a temporary CODEX_HOME with a few fake rollout files.
let codex_home = TempDir::new().expect("create temp dir");
create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"Hello A",
);
create_fake_rollout(
codex_home.path(),
"2025-01-01T13-00-00",
"2025-01-01T13:00:00Z",
"Hello B",
);
create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello C",
);
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timeout")
.expect("init failed");
// Request first page with size 2
let req_id = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: None,
})
.await
.expect("send listConversations");
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await
.expect("listConversations timeout")
.expect("listConversations resp");
let ListConversationsResponse { items, next_cursor } =
to_response::<ListConversationsResponse>(resp).expect("deserialize response");
assert_eq!(items.len(), 2);
// Newest first; preview text should match
assert_eq!(items[0].preview, "Hello A");
assert_eq!(items[1].preview, "Hello B");
assert!(items[0].path.is_absolute());
assert!(next_cursor.is_some());
// Request the next page using the cursor
let req_id2 = mcp
.send_list_conversations_request(ListConversationsParams {
page_size: Some(2),
cursor: next_cursor,
})
.await
.expect("send listConversations page 2");
let resp2: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id2)),
)
.await
.expect("listConversations page 2 timeout")
.expect("listConversations page 2 resp");
let ListConversationsResponse {
items: items2,
next_cursor: next2,
..
} = to_response::<ListConversationsResponse>(resp2).expect("deserialize response");
assert_eq!(items2.len(), 1);
assert_eq!(items2[0].preview, "Hello C");
assert!(next2.is_some());
// Now resume one of the sessions and expect a SessionConfigured notification and response.
let resume_req_id = mcp
.send_resume_conversation_request(ResumeConversationParams {
path: items[0].path.clone(),
overrides: Some(NewConversationParams {
model: Some("o3".to_string()),
..Default::default()
}),
})
.await
.expect("send resumeConversation");
// Expect a codex/event notification with msg.type == session_configured
let notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event"),
)
.await
.expect("session_configured notification timeout")
.expect("session_configured notification");
// Basic shape assertion: ensure event type is session_configured
let msg_type = notification
.params
.as_ref()
.and_then(|p| p.get("msg"))
.and_then(|m| m.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("");
assert_eq!(msg_type, "session_configured");
// Then the response for resumeConversation
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_req_id)),
)
.await
.expect("resumeConversation timeout")
.expect("resumeConversation resp");
let ResumeConversationResponse {
conversation_id, ..
} = to_response::<ResumeConversationResponse>(resume_resp)
.expect("deserialize resumeConversation response");
// conversation id should be a valid UUID
let _: uuid::Uuid = conversation_id.into();
}
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) {
let uuid = Uuid::new_v4();
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
let year = &filename_ts[0..4];
let month = &filename_ts[5..7];
let day = &filename_ts[8..10];
let dir = codex_home.join("sessions").join(year).join(month).join(day);
fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("create sessions dir: {e}"));
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
let mut lines = Vec::new();
// Meta line with timestamp
lines.push(json!({"timestamp": meta_rfc3339, "id": uuid}).to_string());
// Minimal user message entry as a persisted response item
lines.push(
json!({
"type":"message",
"role":"user",
"content":[{"type":"input_text","text": preview}]
})
.to_string(),
);
fs::write(file_path, lines.join("\n") + "\n")
.unwrap_or_else(|e| panic!("write rollout file: {e}"));
}

View File

@@ -5,5 +5,7 @@ mod codex_tool;
mod config;
mod create_conversation;
mod interrupt;
mod list_resume;
mod login;
mod send_message;
mod user_agent;

View File

@@ -0,0 +1,45 @@
use codex_core::default_client::DEFAULT_ORIGINATOR;
use codex_core::default_client::get_codex_user_agent;
use codex_protocol::mcp_protocol::GetUserAgentResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_user_agent_returns_current_codex_user_agent() {
let codex_home = TempDir::new().unwrap_or_else(|err| panic!("create tempdir: {err}"));
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("initialize timeout")
.expect("initialize request");
let request_id = mcp
.send_get_user_agent_request()
.await
.expect("send getUserAgent");
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await
.expect("getUserAgent timeout")
.expect("getUserAgent response");
let received: GetUserAgentResponse =
to_response(response).expect("deserialize getUserAgent response");
let expected = GetUserAgentResponse {
user_agent: get_codex_user_agent(Some(DEFAULT_ORIGINATOR)),
};
assert_eq!(received, expected);
}

View File

@@ -9,4 +9,4 @@ workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ts-rs = { version = "11", features = ["serde-json-impl"] }
ts-rs = { version = "11", features = ["serde-json-impl", "no-serde-warnings"] }

View File

@@ -24,9 +24,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
toml = "0.9.5"
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
[dev-dependencies]
tempfile = "3"

View File

@@ -20,33 +20,25 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserMessageParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserTurnParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?;
generate_index_ts(out_dir)?;

View File

@@ -12,16 +12,23 @@ workspace = true
[dependencies]
base64 = "0.22.1"
icu_decimal = "2.0.0"
icu_locale_core = "2.0.0"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0.5"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
serde_with = { version = "3.14.0", features = ["macros", "base64"] }
strum = "0.27.2"
strum_macros = "0.27.2"
sys-locale = "0.3.2"
tracing = "0.1.41"
ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl"] }
ts-rs = { version = "11", features = ["uuid-impl", "serde-json-impl", "no-serde-warnings"] }
uuid = { version = "1", features = ["serde", "v4"] }
[dev-dependencies]
pretty_assertions = "1.4.1"
[package.metadata.cargo-shear]
# Required because the not imported as strum_macros in non-nightly builds.
ignored = ["strum"]

View File

@@ -4,8 +4,6 @@ use strum_macros::Display;
use strum_macros::EnumIter;
use ts_rs::TS;
use crate::protocol::AskForApproval;
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(
Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS, EnumIter,
@@ -35,6 +33,18 @@ pub enum ReasoningSummary {
None,
}
/// Controls output length/detail on GPT-5 models via the Responses API.
/// Serialized with lowercase values to match the OpenAI API.
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, TS)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verbosity {
Low,
#[default]
Medium,
High,
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
@@ -49,13 +59,3 @@ pub enum SandboxMode {
#[serde(rename = "danger-full-access")]
DangerFullAccess,
}
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`. Currently only a subset of the fields are supported.
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ConfigProfile {
pub model: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub model_reasoning_effort: Option<ReasoningEffort>,
}

View File

@@ -1,8 +1,9 @@
use serde::Deserialize;
use serde::Serialize;
use std::path::PathBuf;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
pub struct CustomPrompt {
pub name: String,
pub path: PathBuf,

View File

@@ -3,6 +3,7 @@ pub mod custom_prompts;
pub mod mcp_protocol;
pub mod message_history;
pub mod models;
pub mod num_format;
pub mod parse_command;
pub mod plan_tool;
pub mod protocol;

View File

@@ -2,11 +2,12 @@ use std::collections::HashMap;
use std::fmt::Display;
use std::path::PathBuf;
use crate::config_types::ConfigProfile;
use crate::config_types::ReasoningEffort;
use crate::config_types::ReasoningSummary;
use crate::config_types::SandboxMode;
use crate::config_types::Verbosity;
use crate::protocol::AskForApproval;
use crate::protocol::EventMsg;
use crate::protocol::FileChange;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
@@ -18,16 +19,40 @@ use strum_macros::Display;
use ts_rs::TS;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS, Hash)]
#[ts(type = "string")]
pub struct ConversationId(pub Uuid);
impl ConversationId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for ConversationId {
fn default() -> Self {
Self::new()
}
}
impl Display for ConversationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<Uuid> for ConversationId {
fn from(value: Uuid) -> Self {
Self(value)
}
}
impl From<ConversationId> for Uuid {
fn from(value: ConversationId) -> Self {
value.0
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
#[ts(type = "string")]
pub struct GitSha(pub String);
@@ -54,6 +79,18 @@ pub enum ClientRequest {
request_id: RequestId,
params: NewConversationParams,
},
/// List recorded Codex conversations (rollouts) with optional pagination and search.
ListConversations {
#[serde(rename = "id")]
request_id: RequestId,
params: ListConversationsParams,
},
/// Resume a recorded Codex conversation from a rollout file.
ResumeConversation {
#[serde(rename = "id")]
request_id: RequestId,
params: ResumeConversationParams,
},
SendUserMessage {
#[serde(rename = "id")]
request_id: RequestId,
@@ -102,10 +139,20 @@ pub enum ClientRequest {
request_id: RequestId,
params: GetAuthStatusParams,
},
GetConfigToml {
GetUserSavedConfig {
#[serde(rename = "id")]
request_id: RequestId,
},
GetUserAgent {
#[serde(rename = "id")]
request_id: RequestId,
},
/// Execute a command (argv vector) under the server's sandbox.
ExecOneOffCommand {
#[serde(rename = "id")]
request_id: RequestId,
params: ExecOneOffCommandParams,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
@@ -158,6 +205,57 @@ pub struct NewConversationResponse {
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResumeConversationResponse {
pub conversation_id: ConversationId,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<EventMsg>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsParams {
/// Optional page size; defaults to a reasonable server-side value.
#[serde(skip_serializing_if = "Option::is_none")]
pub page_size: Option<usize>,
/// Opaque pagination cursor returned by a previous call.
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ConversationSummary {
pub conversation_id: ConversationId,
pub path: PathBuf,
pub preview: String,
/// RFC3339 timestamp string for the session start, if available.
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ListConversationsResponse {
pub items: Vec<ConversationSummary>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// if None, there are no more items to return.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ResumeConversationParams {
/// Absolute path to the rollout JSONL file.
pub path: PathBuf,
/// Optional overrides to apply when spawning the resumed session.
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<NewConversationParams>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddConversationSubscriptionResponse {
@@ -218,6 +316,30 @@ pub struct GetAuthStatusParams {
pub refresh_token: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecOneOffCommandParams {
/// Command argv to execute.
pub command: Vec<String>,
/// Timeout of the command in milliseconds.
/// If not specified, a sensible default is used server-side.
pub timeout_ms: Option<u64>,
/// Optional working directory for the process. Defaults to server config cwd.
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
/// Optional explicit sandbox policy overriding the server default.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_policy: Option<SandboxPolicy>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecArbitraryCommandResponse {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusResponse {
@@ -230,22 +352,87 @@ pub struct GetAuthStatusResponse {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetConfigTomlResponse {
pub struct GetUserAgentResponse {
pub user_agent: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetUserSavedConfigResponse {
pub config: UserSavedConfig,
}
/// UserSavedConfig contains a subset of the config. It is meant to expose mcp
/// client-configurable settings that can be specified in the NewConversation
/// and SendUserTurn requests.
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct UserSavedConfig {
/// Approvals
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<AskForApproval>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_mode: Option<SandboxMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_settings: Option<SandboxSettings>,
/// Relevant model configuration
/// Model-specific configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_reasoning_effort: Option<ReasoningEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_reasoning_summary: Option<ReasoningSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_verbosity: Option<Verbosity>,
/// Tools
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Tools>,
/// Profiles
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default)]
pub profiles: HashMap<String, Profile>,
}
/// MCP representation of a [`codex_core::config_profile::ConfigProfile`].
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct Profile {
pub model: Option<String>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub chatgpt_base_url: Option<String>,
}
/// MCP representation of a [`codex_core::config::ToolsToml`].
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct Tools {
#[serde(skip_serializing_if = "Option::is_none")]
pub profiles: Option<HashMap<String, ConfigProfile>>,
pub web_search: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub view_image: Option<bool>,
}
/// MCP representation of a [`codex_core::config_types::SandboxWorkspaceWrite`].
#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct SandboxSettings {
#[serde(default)]
pub writable_roots: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub network_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_tmpdir_env_var: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude_slash_tmp: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
@@ -398,8 +585,8 @@ pub struct AuthStatusChangeNotification {
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ServerNotification {
/// Authentication status changed
AuthStatusChange(AuthStatusChangeNotification),
@@ -408,6 +595,15 @@ pub enum ServerNotification {
LoginChatGptComplete(LoginChatGptCompleteNotification),
}
impl ServerNotification {
pub fn to_params(self) -> Result<serde_json::Value, serde_json::Error> {
match self {
ServerNotification::AuthStatusChange(params) => serde_json::to_value(params),
ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -442,4 +638,10 @@ mod tests {
serde_json::to_value(&request).unwrap(),
);
}
#[test]
fn test_conversation_id_default_is_not_zeroes() {
let id = ConversationId::default();
assert_ne!(id.0, Uuid::nil());
}
}

View File

@@ -1,9 +1,10 @@
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
pub struct HistoryEntry {
pub session_id: String,
pub conversation_id: String,
pub ts: u64,
pub text: String,
}

View File

@@ -6,10 +6,11 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::ser::Serializer;
use ts_rs::TS;
use crate::protocol::InputItem;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseInputItem {
Message {
@@ -30,7 +31,7 @@ pub enum ResponseInputItem {
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentItem {
InputText { text: String },
@@ -38,15 +39,17 @@ pub enum ContentItem {
OutputText { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem {
Message {
#[serde(skip_serializing)]
id: Option<String>,
role: String,
content: Vec<ContentItem>,
},
Reasoning {
#[serde(default)]
id: String,
summary: Vec<ReasoningItemReasoningSummary>,
#[serde(default, skip_serializing_if = "should_serialize_reasoning_content")]
@@ -55,6 +58,7 @@ pub enum ResponseItem {
},
LocalShellCall {
/// Set when using the chat completions API.
#[serde(skip_serializing)]
id: Option<String>,
/// Set when using the Responses API.
call_id: Option<String>,
@@ -62,6 +66,7 @@ pub enum ResponseItem {
action: LocalShellAction,
},
FunctionCall {
#[serde(skip_serializing)]
id: Option<String>,
name: String,
// The Responses API returns the function call arguments as a *string* that contains
@@ -82,7 +87,7 @@ pub enum ResponseItem {
output: FunctionCallOutputPayload,
},
CustomToolCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
@@ -104,7 +109,7 @@ pub enum ResponseItem {
// "action": {"type":"search","query":"weather: San Francisco, CA"}
// }
WebSearchCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(skip_serializing)]
id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
@@ -155,7 +160,7 @@ impl From<ResponseInputItem> for ResponseItem {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum LocalShellStatus {
Completed,
@@ -163,13 +168,13 @@ pub enum LocalShellStatus {
Incomplete,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LocalShellAction {
Exec(LocalShellExecAction),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
pub struct LocalShellExecAction {
pub command: Vec<String>,
pub timeout_ms: Option<u64>,
@@ -178,7 +183,7 @@ pub struct LocalShellExecAction {
pub user: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WebSearchAction {
Search {
@@ -188,13 +193,13 @@ pub enum WebSearchAction {
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningItemReasoningSummary {
SummaryText { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningItemContent {
ReasoningText { text: String },
@@ -238,7 +243,7 @@ impl From<Vec<InputItem>> for ResponseInputItem {
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or shell`, the `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[derive(Deserialize, Debug, Clone, PartialEq, TS)]
pub struct ShellToolCallParams {
pub command: Vec<String>,
pub workdir: Option<String>,
@@ -252,7 +257,7 @@ pub struct ShellToolCallParams {
pub justification: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, TS)]
pub struct FunctionCallOutputPayload {
pub content: String,
pub success: Option<bool>,
@@ -309,6 +314,8 @@ impl std::ops::Deref for FunctionCallOutputPayload {
}
}
// (Moved event mapping logic into codex-core to avoid coupling protocol to UI-facing events.)
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,98 @@
use std::sync::OnceLock;
use icu_decimal::DecimalFormatter;
use icu_decimal::input::Decimal;
use icu_decimal::options::DecimalFormatterOptions;
use icu_locale_core::Locale;
fn make_local_formatter() -> Option<DecimalFormatter> {
let loc: Locale = sys_locale::get_locale()?.parse().ok()?;
DecimalFormatter::try_new(loc.into(), DecimalFormatterOptions::default()).ok()
}
fn make_en_us_formatter() -> DecimalFormatter {
#![allow(clippy::expect_used)]
let loc: Locale = "en-US".parse().expect("en-US wasn't a valid locale");
DecimalFormatter::try_new(loc.into(), DecimalFormatterOptions::default())
.expect("en-US wasn't a valid locale")
}
fn formatter() -> &'static DecimalFormatter {
static FORMATTER: OnceLock<DecimalFormatter> = OnceLock::new();
FORMATTER.get_or_init(|| make_local_formatter().unwrap_or_else(make_en_us_formatter))
}
/// Format a u64 with locale-aware digit separators (e.g. "12345" -> "12,345"
/// for en-US).
pub fn format_with_separators(n: u64) -> String {
formatter().format(&Decimal::from(n)).to_string()
}
fn format_si_suffix_with_formatter(n: u64, formatter: &DecimalFormatter) -> String {
if n < 1000 {
return formatter.format(&Decimal::from(n)).to_string();
}
// Format `n / scale` with the requested number of fractional digits.
let format_scaled = |n: u64, scale: u64, frac_digits: u32| -> String {
let value = n as f64 / scale as f64;
let scaled: u64 = (value * 10f64.powi(frac_digits as i32)).round() as u64;
let mut dec = Decimal::from(scaled);
dec.multiply_pow10(-(frac_digits as i16));
formatter.format(&dec).to_string()
};
const UNITS: [(u64, &str); 3] = [(1_000, "K"), (1_000_000, "M"), (1_000_000_000, "G")];
let f = n as f64;
for &(scale, suffix) in &UNITS {
if (100.0 * f / scale as f64).round() < 1000.0 {
return format!("{}{}", format_scaled(n, scale, 2), suffix);
} else if (10.0 * f / scale as f64).round() < 1000.0 {
return format!("{}{}", format_scaled(n, scale, 1), suffix);
} else if (f / scale as f64).round() < 1000.0 {
return format!("{}{}", format_scaled(n, scale, 0), suffix);
}
}
// Above 1000G, keep wholeG precision.
format!(
"{}G",
format_with_separators(((n as f64) / 1e9).round() as u64)
)
}
/// Format token counts to 3 significant figures, using base-10 SI suffixes.
///
/// Examples (en-US):
/// - 999 -> "999"
/// - 1200 -> "1.20K"
/// - 123456789 -> "123M"
pub fn format_si_suffix(n: u64) -> String {
format_si_suffix_with_formatter(n, formatter())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kmg() {
let formatter = make_en_us_formatter();
let fmt = |n: u64| format_si_suffix_with_formatter(n, &formatter);
assert_eq!(fmt(0), "0");
assert_eq!(fmt(999), "999");
assert_eq!(fmt(1_000), "1.00K");
assert_eq!(fmt(1_200), "1.20K");
assert_eq!(fmt(10_000), "10.0K");
assert_eq!(fmt(100_000), "100K");
assert_eq!(fmt(999_500), "1.00M");
assert_eq!(fmt(1_000_000), "1.00M");
assert_eq!(fmt(1_234_000), "1.23M");
assert_eq!(fmt(12_345_678), "12.3M");
assert_eq!(fmt(999_950_000), "1.00G");
assert_eq!(fmt(1_000_000_000), "1.00G");
assert_eq!(fmt(1_234_000_000), "1.23G");
// Above 1000G we keep wholeG precision (no higher unit supported here).
assert_eq!(fmt(1_234_000_000_000), "1,234G");
}
}

View File

@@ -1,7 +1,8 @@
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ParsedCommand {
Read {

View File

@@ -1,8 +1,9 @@
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
// Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
@@ -10,14 +11,14 @@ pub enum StepStatus {
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct PlanItemArg {
pub step: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct UpdatePlanArgs {
#[serde(default)]

View File

@@ -10,22 +10,30 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::custom_prompts::CustomPrompt;
use crate::mcp_protocol::ConversationId;
use crate::message_history::HistoryEntry;
use crate::models::ResponseItem;
use crate::num_format::format_with_separators;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
use mcp_types::CallToolResult;
use mcp_types::Tool as McpTool;
use serde::Deserialize;
use serde::Serialize;
use serde_bytes::ByteBuf;
use serde_with::serde_as;
use strum_macros::Display;
use ts_rs::TS;
use uuid::Uuid;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::models::ResponseItem;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
/// Open/close tags for special user-input blocks. Used across crates to avoid
/// duplicated hardcoded strings.
pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -397,7 +405,7 @@ pub struct Event {
}
/// Response event from the agent
#[derive(Debug, Clone, Deserialize, Serialize, Display)]
#[derive(Debug, Clone, Deserialize, Serialize, Display, TS)]
#[serde(tag = "type", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum EventMsg {
@@ -410,13 +418,16 @@ pub enum EventMsg {
/// Agent has completed all actions
TaskComplete(TaskCompleteEvent),
/// Token count event, sent periodically to report the number of tokens
/// used in the current session.
TokenCount(TokenUsage),
/// Usage update for the current session, including totals and last turn.
/// Optional means unknown — UIs should not display when `None`.
TokenCount(TokenCountEvent),
/// Agent text output message
AgentMessage(AgentMessageEvent),
/// User/system input message (what was sent to the model)
UserMessage(UserMessageEvent),
/// Agent text output delta message
AgentMessageDelta(AgentMessageDeltaEvent),
@@ -493,37 +504,82 @@ pub enum EventMsg {
// Individual event payload types matching each `EventMsg` variant.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ErrorEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct TaskCompleteEvent {
pub last_agent_message: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct TaskStartedEvent {
pub model_context_window: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[derive(Debug, Clone, Deserialize, Serialize, Default, TS)]
pub struct TokenUsage {
pub input_tokens: u64,
pub cached_input_tokens: Option<u64>,
pub cached_input_tokens: u64,
pub output_tokens: u64,
pub reasoning_output_tokens: Option<u64>,
pub reasoning_output_tokens: u64,
pub total_tokens: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct TokenUsageInfo {
pub total_token_usage: TokenUsage,
pub last_token_usage: TokenUsage,
pub model_context_window: Option<u64>,
}
impl TokenUsageInfo {
pub fn new_or_append(
info: &Option<TokenUsageInfo>,
last: &Option<TokenUsage>,
model_context_window: Option<u64>,
) -> Option<Self> {
if info.is_none() && last.is_none() {
return None;
}
let mut info = match info {
Some(info) => info.clone(),
None => Self {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
model_context_window,
},
};
if let Some(last) = last {
info.append_last_usage(last);
}
Some(info)
}
pub fn append_last_usage(&mut self, last: &TokenUsage) {
self.total_token_usage.add_assign(last);
self.last_token_usage = last.clone();
}
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct TokenCountEvent {
pub info: Option<TokenUsageInfo>,
}
// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: u64 = 12000;
impl TokenUsage {
pub fn is_zero(&self) -> bool {
self.total_tokens == 0
}
pub fn cached_input(&self) -> u64 {
self.cached_input_tokens.unwrap_or(0)
self.cached_input_tokens
}
pub fn non_cached_input(&self) -> u64 {
@@ -541,35 +597,40 @@ impl TokenUsage {
/// This will be off for the current turn and pending function calls.
pub fn tokens_in_context_window(&self) -> u64 {
self.total_tokens
.saturating_sub(self.reasoning_output_tokens.unwrap_or(0))
.saturating_sub(self.reasoning_output_tokens)
}
/// Estimate the remaining user-controllable percentage of the model's context window.
///
/// `context_window` is the total size of the model's context window.
/// `baseline_used_tokens` should capture tokens that are always present in
/// `BASELINE_TOKENS` should capture tokens that are always present in
/// the context (e.g., system prompt and fixed tool instructions) so that
/// the percentage reflects the portion the user can influence.
///
/// This normalizes both the numerator and denominator by subtracting the
/// baseline, so immediately after the first prompt the UI shows 100% left
/// and trends toward 0% as the user fills the effective window.
pub fn percent_of_context_window_remaining(
&self,
context_window: u64,
baseline_used_tokens: u64,
) -> u8 {
if context_window <= baseline_used_tokens {
pub fn percent_of_context_window_remaining(&self, context_window: u64) -> u8 {
if context_window <= BASELINE_TOKENS {
return 0;
}
let effective_window = context_window - baseline_used_tokens;
let effective_window = context_window - BASELINE_TOKENS;
let used = self
.tokens_in_context_window()
.saturating_sub(baseline_used_tokens);
.saturating_sub(BASELINE_TOKENS);
let remaining = effective_window.saturating_sub(used);
((remaining as f32 / effective_window as f32) * 100.0).clamp(0.0, 100.0) as u8
}
/// In-place element-wise sum of token counts.
pub fn add_assign(&mut self, other: &TokenUsage) {
self.input_tokens += other.input_tokens;
self.cached_input_tokens += other.cached_input_tokens;
self.output_tokens += other.output_tokens;
self.reasoning_output_tokens += other.reasoning_output_tokens;
self.total_tokens += other.total_tokens;
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -586,59 +647,108 @@ impl From<TokenUsage> for FinalOutput {
impl fmt::Display for FinalOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let token_usage = &self.token_usage;
write!(
f,
"Token usage: total={} input={}{} output={}{}",
token_usage.blended_total(),
token_usage.non_cached_input(),
format_with_separators(token_usage.blended_total()),
format_with_separators(token_usage.non_cached_input()),
if token_usage.cached_input() > 0 {
format!(" (+ {} cached)", token_usage.cached_input())
format!(
" (+ {} cached)",
format_with_separators(token_usage.cached_input())
)
} else {
String::new()
},
token_usage.output_tokens,
token_usage
.reasoning_output_tokens
.map(|r| format!(" (reasoning {r})"))
.unwrap_or_default()
format_with_separators(token_usage.output_tokens),
if token_usage.reasoning_output_tokens > 0 {
format!(
" (reasoning {})",
format_with_separators(token_usage.reasoning_output_tokens)
)
} else {
String::new()
}
)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentMessageEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
pub enum InputMessageKind {
/// Plain user text (default)
Plain,
/// XML-wrapped user instructions (<user_instructions>...)
UserInstructions,
/// XML-wrapped environment context (<environment_context>...)
EnvironmentContext,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct UserMessageEvent {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<InputMessageKind>,
}
impl<T, U> From<(T, U)> for InputMessageKind
where
T: AsRef<str>,
U: AsRef<str>,
{
fn from(value: (T, U)) -> Self {
let (_role, message) = value;
let message = message.as_ref();
let trimmed = message.trim();
if trimmed.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG)
&& trimmed.ends_with(ENVIRONMENT_CONTEXT_CLOSE_TAG)
{
InputMessageKind::EnvironmentContext
} else if trimmed.starts_with(USER_INSTRUCTIONS_OPEN_TAG)
&& trimmed.ends_with(USER_INSTRUCTIONS_CLOSE_TAG)
{
InputMessageKind::UserInstructions
} else {
InputMessageKind::Plain
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentMessageDeltaEvent {
pub delta: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentReasoningEvent {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentReasoningRawContentEvent {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentReasoningRawContentDeltaEvent {
pub delta: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentReasoningSectionBreakEvent {}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct AgentReasoningDeltaEvent {
pub delta: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct McpInvocation {
/// Name of the MCP server as defined in the config.
pub server: String,
@@ -648,18 +758,19 @@ pub struct McpInvocation {
pub arguments: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct McpToolCallBeginEvent {
/// Identifier so this can be paired with the McpToolCallEnd event.
pub call_id: String,
pub invocation: McpInvocation,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct McpToolCallEndEvent {
/// Identifier for the corresponding McpToolCallBegin that finished.
pub call_id: String,
pub invocation: McpInvocation,
#[ts(type = "string")]
pub duration: Duration,
/// Result of the tool call. Note this could be an error.
pub result: Result<CallToolResult, String>,
@@ -674,12 +785,12 @@ impl McpToolCallEndEvent {
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct WebSearchBeginEvent {
pub call_id: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct WebSearchEndEvent {
pub call_id: String,
pub query: String,
@@ -687,13 +798,13 @@ pub struct WebSearchEndEvent {
/// Response payload for `Op::GetHistory` containing the current session's
/// in-memory transcript.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ConversationHistoryResponseEvent {
pub conversation_id: Uuid,
pub conversation_id: ConversationId,
pub entries: Vec<ResponseItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ExecCommandBeginEvent {
/// Identifier so this can be paired with the ExecCommandEnd event.
pub call_id: String,
@@ -704,7 +815,7 @@ pub struct ExecCommandBeginEvent {
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ExecCommandEndEvent {
/// Identifier for the ExecCommandBegin that finished.
pub call_id: String,
@@ -718,30 +829,33 @@ pub struct ExecCommandEndEvent {
/// The command's exit code.
pub exit_code: i32,
/// The duration of the command execution.
#[ts(type = "string")]
pub duration: Duration,
/// Formatted output from the command, as seen by the model.
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde_as]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
pub struct ExecCommandOutputDeltaEvent {
/// Identifier for the ExecCommandBegin that produced this chunk.
pub call_id: String,
/// Which stream produced this chunk.
pub stream: ExecOutputStream,
/// Raw bytes from the stream (may not be valid UTF-8).
#[serde(with = "serde_bytes")]
pub chunk: ByteBuf,
#[serde_as(as = "serde_with::base64::Base64")]
#[ts(type = "string")]
pub chunk: Vec<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ExecApprovalRequestEvent {
/// Identifier for the associated exec call, if available.
pub call_id: String,
@@ -754,7 +868,7 @@ pub struct ExecApprovalRequestEvent {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ApplyPatchApprovalRequestEvent {
/// Responses API call id for the associated patch apply call, if available.
pub call_id: String,
@@ -767,17 +881,17 @@ pub struct ApplyPatchApprovalRequestEvent {
pub grant_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct BackgroundEventEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct StreamErrorEvent {
pub message: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct PatchApplyBeginEvent {
/// Identifier so this can be paired with the PatchApplyEnd event.
pub call_id: String,
@@ -787,7 +901,7 @@ pub struct PatchApplyBeginEvent {
pub changes: HashMap<PathBuf, FileChange>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct PatchApplyEndEvent {
/// Identifier for the PatchApplyBegin that finished.
pub call_id: String,
@@ -799,12 +913,12 @@ pub struct PatchApplyEndEvent {
pub success: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct TurnDiffEvent {
pub unified_diff: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct GetHistoryEntryResponseEvent {
pub offset: usize,
pub log_id: u64,
@@ -814,22 +928,22 @@ pub struct GetHistoryEntryResponseEvent {
}
/// Response payload for `Op::ListMcpTools`.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct McpListToolsResponseEvent {
/// Fully qualified tool name -> tool definition.
pub tools: std::collections::HashMap<String, McpTool>,
}
/// Response payload for `Op::ListCustomPrompts`.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ListCustomPromptsResponseEvent {
pub custom_prompts: Vec<CustomPrompt>,
}
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[derive(Debug, Default, Clone, Deserialize, Serialize, TS)]
pub struct SessionConfiguredEvent {
/// Unique id for this session.
pub session_id: Uuid,
/// Name left as session_id instead of conversation_id for backwards compatibility.
pub session_id: ConversationId,
/// Tell the client what model is being queried.
pub model: String,
@@ -839,6 +953,11 @@ pub struct SessionConfiguredEvent {
/// Current number of entries in the history log.
pub history_entry_count: usize,
/// Optional initial messages (as events) for resumed sessions.
/// When present, UIs can use these to seed the history.
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_messages: Option<Vec<EventMsg>>,
}
/// User's decision in response to an ExecApprovalRequest.
@@ -878,7 +997,7 @@ pub enum FileChange {
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct Chunk {
/// 1-based line index of the first line in the original file
pub orig_index: u32,
@@ -886,7 +1005,7 @@ pub struct Chunk {
pub inserted_lines: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct TurnAbortedEvent {
pub reason: TurnAbortReason,
}
@@ -906,14 +1025,15 @@ mod tests {
/// amount of nesting.
#[test]
fn serialize_event() {
let session_id: Uuid = uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8");
let conversation_id = ConversationId(uuid::uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"));
let event = Event {
id: "1234".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id,
session_id: conversation_id,
model: "codex-mini-latest".to_string(),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
}),
};
let serialized = serde_json::to_string(&event).unwrap();
@@ -922,4 +1042,21 @@ mod tests {
r#"{"id":"1234","msg":{"type":"session_configured","session_id":"67e55044-10b1-426f-9247-bb680e5fe0c8","model":"codex-mini-latest","history_log_id":0,"history_entry_count":0}}"#
);
}
#[test]
fn vec_u8_as_base64_serialization_and_deserialization() {
let event = ExecCommandOutputDeltaEvent {
call_id: "call21".to_string(),
stream: ExecOutputStream::Stdout,
chunk: vec![1, 2, 3, 4, 5],
};
let serialized = serde_json::to_string(&event).unwrap();
assert_eq!(
r#"{"call_id":"call21","stream":"stdout","chunk":"AQIDBAU="}"#,
serialized,
);
let deserialized: ExecCommandOutputDeltaEvent = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, event);
}
}

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import re
import subprocess
import sys
REPO = "openai/codex"
BRANCH_REF = "heads/main"
CARGO_TOML_PATH = "codex-rs/Cargo.toml"
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Publish a tagged Codex release.")
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
help="Print the version that would be used and exit before making changes.",
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--publish-alpha",
action="store_true",
help="Publish the next alpha release for the upcoming minor version.",
)
group.add_argument(
"--publish-release",
action="store_true",
help="Publish the next stable release by bumping the minor version.",
)
return parser.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
version = determine_version(args)
print(f"Publishing version {version}")
if args.dry_run:
return 0
print("Fetching branch head...")
base_commit = get_branch_head()
print(f"Base commit: {base_commit}")
print("Fetching commit tree...")
base_tree = get_commit_tree(base_commit)
print(f"Base tree: {base_tree}")
print("Fetching Cargo.toml...")
current_contents = fetch_file_contents(base_commit)
print("Updating version...")
updated_contents = replace_version(current_contents, version)
print("Creating blob...")
blob_sha = create_blob(updated_contents)
print(f"Blob SHA: {blob_sha}")
print("Creating tree...")
tree_sha = create_tree(base_tree, blob_sha)
print(f"Tree SHA: {tree_sha}")
print("Creating commit...")
commit_sha = create_commit(version, tree_sha, base_commit)
print(f"Commit SHA: {commit_sha}")
print("Creating tag...")
tag_sha = create_tag(version, commit_sha)
print(f"Tag SHA: {tag_sha}")
print("Creating tag ref...")
create_tag_ref(version, tag_sha)
print("Done.")
except ReleaseError as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
return 0
class ReleaseError(RuntimeError):
pass
def run_gh_api(endpoint: str, *, method: str = "GET", payload: dict | None = None) -> dict:
print(f"Running gh api {method} {endpoint}")
command = [
"gh",
"api",
endpoint,
"--method",
method,
"-H",
"Accept: application/vnd.github+json",
]
json_payload = None
if payload is not None:
json_payload = json.dumps(payload)
print(f"Payload: {json_payload}")
command.extend(["-H", "Content-Type: application/json", "--input", "-"])
result = subprocess.run(command, text=True, capture_output=True, input=json_payload)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "gh api call failed"
raise ReleaseError(message)
try:
return json.loads(result.stdout or "{}")
except json.JSONDecodeError as error:
raise ReleaseError("Failed to parse response from gh api.") from error
def get_branch_head() -> str:
response = run_gh_api(f"/repos/{REPO}/git/refs/{BRANCH_REF}")
try:
return response["object"]["sha"]
except KeyError as error:
raise ReleaseError("Unable to determine branch head.") from error
def get_commit_tree(commit_sha: str) -> str:
response = run_gh_api(f"/repos/{REPO}/git/commits/{commit_sha}")
try:
return response["tree"]["sha"]
except KeyError as error:
raise ReleaseError("Commit response missing tree SHA.") from error
def fetch_file_contents(ref_sha: str) -> str:
response = run_gh_api(f"/repos/{REPO}/contents/{CARGO_TOML_PATH}?ref={ref_sha}")
try:
encoded_content = response["content"].replace("\n", "")
encoding = response.get("encoding", "")
except KeyError as error:
raise ReleaseError("Failed to fetch Cargo.toml contents.") from error
if encoding != "base64":
raise ReleaseError(f"Unexpected Cargo.toml encoding: {encoding}")
try:
return base64.b64decode(encoded_content).decode("utf-8")
except (ValueError, UnicodeDecodeError) as error:
raise ReleaseError("Failed to decode Cargo.toml contents.") from error
def replace_version(contents: str, version: str) -> str:
updated, matches = re.subn(
r'^version = "[^"]+"', f'version = "{version}"', contents, count=1, flags=re.MULTILINE
)
if matches != 1:
raise ReleaseError("Unable to update version in Cargo.toml.")
return updated
def create_blob(content: str) -> str:
response = run_gh_api(
f"/repos/{REPO}/git/blobs",
method="POST",
payload={"content": content, "encoding": "utf-8"},
)
try:
return response["sha"]
except KeyError as error:
raise ReleaseError("Blob creation response missing SHA.") from error
def create_tree(base_tree_sha: str, blob_sha: str) -> str:
response = run_gh_api(
f"/repos/{REPO}/git/trees",
method="POST",
payload={
"base_tree": base_tree_sha,
"tree": [
{
"path": CARGO_TOML_PATH,
"mode": "100644",
"type": "blob",
"sha": blob_sha,
}
],
},
)
try:
return response["sha"]
except KeyError as error:
raise ReleaseError("Tree creation response missing SHA.") from error
def create_commit(version: str, tree_sha: str, parent_sha: str) -> str:
response = run_gh_api(
f"/repos/{REPO}/git/commits",
method="POST",
payload={
"message": f"Release {version}",
"tree": tree_sha,
"parents": [parent_sha],
},
)
try:
return response["sha"]
except KeyError as error:
raise ReleaseError("Commit creation response missing SHA.") from error
def create_tag(version: str, commit_sha: str) -> str:
tag_name = f"rust-v{version}"
response = run_gh_api(
f"/repos/{REPO}/git/tags",
method="POST",
payload={
"tag": tag_name,
"message": f"Release {version}",
"object": commit_sha,
"type": "commit",
},
)
try:
return response["sha"]
except KeyError as error:
raise ReleaseError("Tag creation response missing SHA.") from error
def create_tag_ref(version: str, tag_sha: str) -> None:
tag_ref = f"refs/tags/rust-v{version}"
run_gh_api(
f"/repos/{REPO}/git/refs",
method="POST",
payload={"ref": tag_ref, "sha": tag_sha},
)
def determine_version(args: argparse.Namespace) -> str:
latest_version = get_latest_release_version()
major, minor, patch = parse_semver(latest_version)
next_minor_version = format_version(major, minor + 1, patch)
if args.publish_release:
return next_minor_version
alpha_prefix = f"{next_minor_version}-alpha."
releases = list_releases()
highest_alpha = 0
found_alpha = False
for release in releases:
tag = release.get("tag_name", "")
candidate = strip_tag_prefix(tag)
if candidate and candidate.startswith(alpha_prefix):
suffix = candidate[len(alpha_prefix) :]
try:
alpha_number = int(suffix)
except ValueError:
continue
highest_alpha = max(highest_alpha, alpha_number)
found_alpha = True
if found_alpha:
return f"{alpha_prefix}{highest_alpha + 1}"
return f"{alpha_prefix}1"
def get_latest_release_version() -> str:
response = run_gh_api(f"/repos/{REPO}/releases/latest")
tag = response.get("tag_name")
version = strip_tag_prefix(tag)
if not version:
raise ReleaseError("Latest release tag has unexpected format.")
return version
def list_releases() -> list[dict]:
response = run_gh_api(f"/repos/{REPO}/releases?per_page=100")
if not isinstance(response, list):
raise ReleaseError("Unexpected response when listing releases.")
return response
def strip_tag_prefix(tag: str | None) -> str | None:
if not tag:
return None
prefix = "rust-v"
if not tag.startswith(prefix):
return None
return tag[len(prefix) :]
def parse_semver(version: str) -> tuple[int, int, int]:
parts = version.split(".")
if len(parts) != 3:
raise ReleaseError(f"Unexpected version format: {version}")
try:
return int(parts[0]), int(parts[1]), int(parts[2])
except ValueError as error:
raise ReleaseError(f"Version components must be integers: {version}") from error
def format_version(major: int, minor: int, patch: int) -> str:
return f"{major}.{minor}.{patch}"
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@@ -1,64 +0,0 @@
#!/bin/bash
set -euo pipefail
# By default, this script uses a version based on the current date and time.
# If you want to specify a version, pass it as the first argument. Example:
#
# ./scripts/create_github_release.sh 0.1.0-alpha.4
#
# The value will be used to update the `version` field in `Cargo.toml`.
# Change to the root of the Cargo workspace.
cd "$(dirname "${BASH_SOURCE[0]}")/.."
# Cancel if there are uncommitted changes.
if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo "ERROR: You have uncommitted or untracked changes." >&2
exit 1
fi
# Fail if in a detached HEAD state.
CURRENT_BRANCH=$(git symbolic-ref --short -q HEAD 2>/dev/null || true)
if [ -z "${CURRENT_BRANCH:-}" ]; then
echo "ERROR: Could not determine the current branch (detached HEAD?)." >&2
echo " Please run this script from a checked-out branch." >&2
exit 1
fi
# Ensure we are on the 'main' branch before proceeding.
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "ERROR: Releases must be created from the 'main' branch (current: '${CURRENT_BRANCH}')." >&2
echo " Please switch to 'main' and try again." >&2
exit 1
fi
# Ensure the current local commit on 'main' is present on 'origin/main'.
# This guarantees we only create releases from commits that are already on
# the canonical repository (https://github.com/openai/codex).
if ! git fetch --quiet origin main; then
echo "ERROR: Failed to fetch 'origin/main'. Ensure the 'origin' remote is configured and reachable." >&2
exit 1
fi
if ! git merge-base --is-ancestor HEAD origin/main; then
echo "ERROR: Your local 'main' HEAD commit is not present on 'origin/main'." >&2
echo " Please push your commits first (git push origin main) or check out a commit on 'origin/main'." >&2
exit 1
fi
# Create a new branch for the release and make a commit with the new version.
if [ $# -ge 1 ]; then
VERSION="$1"
else
VERSION=$(printf '0.0.%d' "$(date +%y%m%d%H%M)")
fi
TAG="rust-v$VERSION"
git checkout -b "$TAG"
perl -i -pe "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml
git add Cargo.toml
git commit -m "Release $VERSION"
git tag -a "$TAG" -m "Release $VERSION"
git push origin "refs/tags/$TAG"
git checkout "$CURRENT_BRANCH"

View File

@@ -44,7 +44,7 @@ crossterm = { version = "0.28.1", features = [
"event-stream",
] }
diffy = "0.4.2"
image = { version = "^0.25.6", default-features = false, features = [
image = { version = "^0.25.8", default-features = false, features = [
"jpeg",
"png",
] }
@@ -59,9 +59,7 @@ ratatui = { version = "0.29.0", features = [
"unstable-rendered-line-info",
"unstable-widget-ref",
] }
ratatui-image = "8.0.0"
regex-lite = "0.1"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0"
@@ -81,12 +79,10 @@ tokio-stream = "0.1.17"
tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tui-input = "0.14.0"
tui-markdown = "0.3.3"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
url = "2"
uuid = "1"
pathdiff = "0.2"
[target.'cfg(unix)'.dependencies]
@@ -100,7 +96,7 @@ arboard = "3"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
insta = "1.43.2"
pretty_assertions = "1"
rand = "0.9"
vt100 = "0.16.2"

View File

@@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::pager_overlay::Overlay;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
use codex_ansi_escape::ansi_escape_line;
@@ -12,6 +13,7 @@ use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::protocol::TokenUsage;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -61,6 +63,7 @@ impl App {
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
resume_selection: ResumeSelection,
) -> Result<TokenUsage> {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
@@ -70,15 +73,44 @@ impl App {
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
let chat_widget = ChatWidget::new(
config.clone(),
conversation_manager.clone(),
tui.frame_requester(),
app_event_tx.clone(),
initial_prompt,
initial_images,
enhanced_keys_supported,
);
let chat_widget = match resume_selection {
ResumeSelection::StartFresh | ResumeSelection::Exit => {
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_prompt: initial_prompt.clone(),
initial_images: initial_images.clone(),
enhanced_keys_supported,
};
ChatWidget::new(init, conversation_manager.clone())
}
ResumeSelection::Resume(path) => {
let resumed = conversation_manager
.resume_conversation_from_rollout(
config.clone(),
path.clone(),
auth_manager.clone(),
)
.await
.wrap_err_with(|| {
format!("Failed to resume session from {}", path.display())
})?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: app_event_tx.clone(),
initial_prompt: initial_prompt.clone(),
initial_images: initial_images.clone(),
enhanced_keys_supported,
};
ChatWidget::new_from_existing(
init,
resumed.conversation,
resumed.session_configured,
)
}
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
@@ -151,15 +183,6 @@ impl App {
},
)?;
}
TuiEvent::AttachImage {
path,
width,
height,
format_label,
} => {
self.chat_widget
.attach_image(path, width, height, format_label);
}
}
}
Ok(true)
@@ -168,15 +191,15 @@ impl App {
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
match event {
AppEvent::NewSession => {
self.chat_widget = ChatWidget::new(
self.config.clone(),
self.server.clone(),
tui.frame_requester(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
);
let init = crate::chatwidget::ChatWidgetInit {
config: self.config.clone(),
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
tui.frame_requester().schedule_frame();
}
AppEvent::InsertHistoryCell(cell) => {

View File

@@ -4,23 +4,25 @@ use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
use codex_core::protocol::ConversationHistoryResponseEvent;
use codex_protocol::mcp_protocol::ConversationId;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
/// Aggregates all backtrack-related state used by the App.
#[derive(Default)]
pub(crate) struct BacktrackState {
/// True when Esc has primed backtrack mode in the main view.
pub(crate) primed: bool,
/// Session id of the base conversation to fork from.
pub(crate) base_id: Option<uuid::Uuid>,
pub(crate) base_id: Option<ConversationId>,
/// Current step count (Nth last user message).
pub(crate) count: usize,
/// True when the transcript overlay is showing a backtrack preview.
pub(crate) overlay_preview_active: bool,
/// Pending fork request: (base_id, drop_count, prefill).
pub(crate) pending: Option<(uuid::Uuid, usize, String)>,
pub(crate) pending: Option<(ConversationId, usize, String)>,
}
impl App {
@@ -91,7 +93,7 @@ impl App {
pub(crate) fn request_backtrack(
&mut self,
prefill: String,
base_id: uuid::Uuid,
base_id: ConversationId,
drop_last_messages: usize,
) {
self.backtrack.pending = Some((base_id, drop_last_messages, prefill));
@@ -135,7 +137,7 @@ impl App {
fn prime_backtrack(&mut self) {
self.backtrack.primed = true;
self.backtrack.count = 0;
self.backtrack.base_id = self.chat_widget.session_id();
self.backtrack.base_id = self.chat_widget.conversation_id();
self.chat_widget.show_esc_backtrack_hint();
}
@@ -151,7 +153,7 @@ impl App {
/// When overlay is already open, begin preview mode and select latest user message.
fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.session_id();
self.backtrack.base_id = self.chat_widget.conversation_id();
self.backtrack.overlay_preview_active = true;
let sel = self.compute_backtrack_selection(tui, 1);
self.apply_backtrack_selection(sel);
@@ -319,14 +321,16 @@ impl App {
) {
let conv = new_conv.conversation;
let session_configured = new_conv.session_configured;
self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(
cfg,
conv,
session_configured,
tui.frame_requester(),
self.app_event_tx.clone(),
self.enhanced_keys_supported,
);
let init = crate::chatwidget::ChatWidgetInit {
config: cfg,
frame_requester: tui.frame_requester(),
app_event_tx: self.app_event_tx.clone(),
initial_prompt: None,
initial_images: Vec::new(),
enhanced_keys_supported: self.enhanced_keys_supported,
};
self.chat_widget =
crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured);
// Trim transcript up to the selected user message and re-render it.
self.trim_transcript_for_backtrack(drop_count);
self.render_transcript_once(tui);

View File

@@ -12,7 +12,7 @@ pub(crate) fn highlight_range_for_nth_last_user(
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
let before = &lines[0..header_idx];
crate::insert_history::word_wrap_lines(before, width).len()
crate::wrapping::word_wrap_lines(before, width as usize).len()
}
/// Find the header index for the Nth last user message in the transcript.

View File

@@ -19,7 +19,7 @@ pub(crate) trait BottomPaneView {
/// Handle Ctrl-C while this view is active.
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
CancellationEvent::Ignored
CancellationEvent::NotHandled
}
/// Return the desired height of the view.

View File

@@ -1,4 +1,5 @@
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_protocol::num_format::format_si_suffix;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -11,7 +12,6 @@ use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -37,6 +37,7 @@ use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
use crate::clipboard_paste::pasted_image_format;
use crate::key_hint;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::collections::HashMap;
@@ -63,21 +64,6 @@ struct AttachedImage {
path: PathBuf,
}
struct TokenUsageInfo {
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
/// Baseline token count present in the context before the user's first
/// message content is considered. This is used to normalize the
/// "context left" percentage so it reflects the portion the user can
/// influence rather than fixed prompt overhead (system prompt, tool
/// instructions, etc.).
///
/// Preferred source is `cached_input_tokens` from the first turn (when
/// available), otherwise we fall back to 0.
initial_prompt_tokens: u64,
}
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
@@ -94,6 +80,7 @@ pub(crate) struct ChatComposer {
has_focus: bool,
attached_images: Vec<AttachedImage>,
placeholder_text: String,
is_task_running: bool,
// Non-bracketed paste burst tracker.
paste_burst: PasteBurst,
// When true, disables paste-burst logic and inserts characters immediately.
@@ -134,6 +121,7 @@ impl ChatComposer {
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
is_task_running: false,
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
@@ -175,24 +163,8 @@ impl ChatComposer {
/// Update the cached *context-left* percentage and refresh the placeholder
/// text. The UI relies on the placeholder to convey the remaining
/// context when the composer is empty.
pub(crate) fn set_token_usage(
&mut self,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
let initial_prompt_tokens = self
.token_usage_info
.as_ref()
.map(|info| info.initial_prompt_tokens)
.unwrap_or_else(|| last_token_usage.cached_input_tokens.unwrap_or(0));
self.token_usage_info = Some(TokenUsageInfo {
total_token_usage,
last_token_usage,
model_context_window,
initial_prompt_tokens,
});
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.token_usage_info = token_info;
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
@@ -1236,6 +1208,10 @@ impl ChatComposer {
self.has_focus = has_focus;
}
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
}
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
self.esc_backtrack_hint = show;
}
@@ -1259,35 +1235,40 @@ impl WidgetRef for ChatComposer {
}
ActivePopup::None => {
let bottom_line_rect = popup_rect;
let key_hint_style = Style::default().fg(Color::Cyan);
let mut hint = if self.ctrl_c_quit_hint {
vec![
" ".into(),
"Ctrl+C again".set_style(key_hint_style),
" to quit".into(),
]
} else {
let newline_hint_key = if self.use_shift_enter_hint {
"Shift+⏎"
let mut hint: Vec<Span<'static>> = if self.ctrl_c_quit_hint {
let ctrl_c_followup = if self.is_task_running {
" to interrupt"
} else {
"Ctrl+J"
" to quit"
};
vec![
" ".into(),
"".set_style(key_hint_style),
key_hint::ctrl('C'),
" again".into(),
ctrl_c_followup.into(),
]
} else {
let newline_hint_key = if self.use_shift_enter_hint {
key_hint::shift('⏎')
} else {
key_hint::ctrl('J')
};
vec![
" ".into(),
key_hint::plain('⏎'),
" send ".into(),
newline_hint_key.set_style(key_hint_style),
newline_hint_key,
" newline ".into(),
"Ctrl+T".set_style(key_hint_style),
key_hint::ctrl('T'),
" transcript ".into(),
"Ctrl+C".set_style(key_hint_style),
key_hint::ctrl('C'),
" quit".into(),
]
};
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
hint.push(" ".into());
hint.push("Esc".set_style(key_hint_style));
hint.push(key_hint::plain("Esc"));
hint.push(" edit prev".into());
}
@@ -1296,24 +1277,29 @@ impl WidgetRef for ChatComposer {
let token_usage = &token_usage_info.total_token_usage;
hint.push(" ".into());
hint.push(
Span::from(format!("{} tokens used", token_usage.blended_total()))
.style(Style::default().add_modifier(Modifier::DIM)),
Span::from(format!(
"{} tokens used",
format_si_suffix(token_usage.blended_total())
))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
last_token_usage.percent_of_context_window_remaining(
context_window,
token_usage_info.initial_prompt_tokens,
)
last_token_usage.percent_of_context_window_remaining(context_window)
} else {
100
};
let context_style = if percent_remaining < 20 {
Style::default().fg(Color::Yellow)
} else {
Style::default().add_modifier(Modifier::DIM)
};
hint.push(" ".into());
hint.push(
Span::from(format!("{percent_remaining}% context left"))
.style(Style::default().add_modifier(Modifier::DIM)),
);
hint.push(Span::styled(
format!("{percent_remaining}% context left"),
context_style,
));
}
}
@@ -1635,7 +1621,6 @@ mod tests {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -1687,13 +1672,12 @@ mod tests {
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
assert_snapshot!(name, terminal.backend());
insta::assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
@@ -1720,7 +1704,7 @@ mod tests {
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.
assert_snapshot!("slash_popup_mo", terminal.backend());
insta::assert_snapshot!("slash_popup_mo", terminal.backend());
}
#[test]

View File

@@ -5,7 +5,7 @@ use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
@@ -30,8 +30,8 @@ mod textarea;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Ignored,
Handled,
NotHandled,
}
pub(crate) use chat_composer::ChatComposer;
@@ -162,6 +162,8 @@ impl BottomPane {
view.handle_key_event(self, key_event);
if !view.is_complete() {
self.active_view = Some(view);
} else {
self.on_active_view_complete();
}
self.request_redraw();
InputResult::None
@@ -193,7 +195,15 @@ impl BottomPane {
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
let mut view = match self.active_view.take() {
Some(view) => view,
None => return CancellationEvent::Ignored,
None => {
return if self.composer_is_empty() {
CancellationEvent::NotHandled
} else {
self.set_composer_text(String::new());
self.show_ctrl_c_quit_hint();
CancellationEvent::Handled
};
}
};
let event = view.on_ctrl_c(self);
@@ -201,10 +211,12 @@ impl BottomPane {
CancellationEvent::Handled => {
if !view.is_complete() {
self.active_view = Some(view);
} else {
self.on_active_view_complete();
}
self.show_ctrl_c_quit_hint();
}
CancellationEvent::Ignored => {
CancellationEvent::NotHandled => {
self.active_view = Some(view);
}
}
@@ -263,6 +275,7 @@ impl BottomPane {
}
}
#[cfg(test)]
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
self.ctrl_c_quit_hint
}
@@ -285,6 +298,7 @@ impl BottomPane {
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
self.composer.set_task_running(running);
if running {
if self.status.is_none() {
@@ -354,14 +368,8 @@ impl BottomPane {
/// Update the *context-window remaining* indicator in the composer. This
/// is forwarded directly to the underlying `ChatComposer`.
pub(crate) fn set_token_usage(
&mut self,
total_token_usage: TokenUsage,
last_token_usage: TokenUsage,
model_context_window: Option<u64>,
) {
self.composer
.set_token_usage(total_token_usage, last_token_usage, model_context_window);
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.composer.set_token_usage(token_info);
self.request_redraw();
}
@@ -381,10 +389,27 @@ impl BottomPane {
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.active_view = Some(Box::new(modal));
self.request_redraw()
}
fn on_active_view_complete(&mut self) {
self.resume_status_timer_after_modal();
}
fn pause_status_timer_for_modal(&mut self) {
if let Some(status) = self.status.as_mut() {
status.pause_timer();
}
}
fn resume_status_timer_after_modal(&mut self) {
if let Some(status) = self.status.as_mut() {
status.resume_timer();
}
}
/// Height (terminal rows) required by the current bottom pane.
pub(crate) fn request_redraw(&self) {
self.frame_requester.schedule_frame();
@@ -489,7 +514,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
@@ -498,7 +523,7 @@ mod tests {
pane.push_approval_request(exec_request());
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert!(pane.ctrl_c_quit_hint_visible());
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
}
// live ring removed; related tests deleted.
@@ -509,7 +534,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
@@ -540,7 +565,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx.clone(),
frame_requester: crate::tui::FrameRequester::test_dummy(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
@@ -568,7 +593,7 @@ mod tests {
// Render and ensure the top row includes the Working header and a composer line below.
// Give the animation thread a moment to tick.
std::thread::sleep(std::time::Duration::from_millis(120));
std::thread::sleep(Duration::from_millis(120));
let area = Rect::new(0, 0, 40, 6);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
@@ -608,7 +633,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
@@ -639,7 +664,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
@@ -690,7 +715,7 @@ mod tests {
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: crate::tui::FrameRequester::test_dummy(),
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

View File

@@ -11,4 +11,4 @@ expression: terminal.backend()
"▌ "
"▌ "
"▌ "
" ⏎ send Ctrl+J newline Ctrl+T transcript Ctrl+C quit "
" ⏎ send J newline T transcript C quit "

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