Compare commits

...

41 Commits

Author SHA1 Message Date
Michael Bolin
c577e94b67 chore: introduce codex-common crate (#843)
I started this PR because I wanted to share the `format_duration()`
utility function in `codex-rs/exec/src/event_processor.rs` with the TUI.
The question was: where to put it?

`core` should have as few dependencies as possible, so moving it there
would introduce a dependency on `chrono`, which seemed undesirable.
`core` already had this `cli` feature to deal with a similar situation
around sharing common utility functions, so I decided to:

* make `core` feature-free
* introduce `common`
* `common` can have as many "special interest" features as it needs,
each of which can declare their own deps
* the first two features of common are `cli` and `elapsed`

In practice, this meant updating a number of `Cargo.toml` files,
replacing this line:

```toml
codex-core = { path = "../core", features = ["cli"] }
```

with these:

```toml
codex-core = { path = "../core" }
codex-common = { path = "../common", features = ["cli"] }
```

Moving `format_duration()` into its own file gave it some "breathing
room" to add a unit test, so I had Codex generate some tests and new
support for durations over 1 minute.
2025-05-06 17:38:56 -07:00
Michael Bolin
7d8b38b37b feat: show MCP tool calls in codex exec subcommand (#841)
This is analogous to the change for the TUI in
https://github.com/openai/codex/pull/836, but for `codex exec`.

To test, I ran:

```
cargo run --bin codex-exec -- 'what is the weather in wellesley ma tomorrow'
```

and saw:


![image](https://github.com/user-attachments/assets/5714e07f-88c7-4dd9-aa0d-be54c1670533)
2025-05-06 16:52:43 -07:00
Michael Bolin
6f87f4c69f feat: drop support for q in the Rust TUI since we already support ctrl+d (#799)
Out of the box, we will make `/` the only official "escape sequence" for
commands in the Rust TUI. We will look to support `q` (or any string you
want to use as a "macro") via a plugin, but not make it part of the
default experience.

Existing `q` users will have to get by with `ctrl+d` for now.
2025-05-06 16:34:17 -07:00
Michael Bolin
aa36a15f9f fix: make all fields of Session struct private again (#840)
https://github.com/openai/codex/pull/829 noted it introduced a circular
dep between `codex.rs` and `mcp_tool_call.rs`. This attempts to clean
things up: the circular dep still exists, but at least all the fields of
`Session` are private again.
2025-05-06 16:21:35 -07:00
Michael Bolin
88e7ca5f2b feat: show MCP tool calls in TUI (#836)
Adds logic for the `McpToolCallBegin` and `McpToolCallEnd` events in
`codex-rs/tui/src/chatwidget.rs` so they get entries in the conversation
history in the TUI.

Building on top of https://github.com/openai/codex/pull/829, here is the
result of running:

```
cargo run --bin codex -- 'what is the weather in san francisco tomorrow'
```


![image](https://github.com/user-attachments/assets/db4a79bb-4988-46cb-acb2-446d5ba9e058)
2025-05-06 16:12:15 -07:00
Michael Bolin
147a940449 feat: support mcp_servers in config.toml (#829)
This adds initial support for MCP servers in the style of Claude Desktop
and Cursor. Note this PR is the bare minimum to get things working end
to end: all configured MCP servers are launched every time Codex is run,
there is no recovery for MCP servers that crash, etc.

(Also, I took some shortcuts to change some fields of `Session` to be
`pub(crate)`, which also means there are circular deps between
`codex.rs` and `mcp_tool_call.rs`, but I will clean that up in a
subsequent PR.)

`codex-rs/README.md` is updated as part of this PR to explain how to use
this feature. There is a bit of plumbing to route the new settings from
`Config` to the business logic in `codex.rs`. The most significant
chunks for new code are in `mcp_connection_manager.rs` (which defines
the `McpConnectionManager` struct) and `mcp_tool_call.rs`, which is
responsible for tool calls.

This PR also introduces new `McpToolCallBegin` and `McpToolCallEnd`
event types to the protocol, but does not add any handlers for them.
(See https://github.com/openai/codex/pull/836 for initial usage.)

To test, I added the following to my `~/.codex/config.toml`:

```toml
# Local build of https://github.com/hideya/mcp-server-weather-js
[mcp_servers.weather]
command = "/Users/mbolin/code/mcp-server-weather-js/dist/index.js"
args = []
```

And then I ran the following:

```
codex-rs$ cargo run --bin codex exec 'what is the weather in san francisco'
[2025-05-06T22:40:05] Task started: 1
[2025-05-06T22:40:18] Agent message: Here’s the latest National Weather Service forecast for San Francisco (downtown, near 37.77° N, 122.42° W):

This Afternoon (Tue):
• Sunny, high near 69 °F
• West-southwest wind around 12 mph

Tonight:
• Partly cloudy, low around 52 °F
• SW wind 7–10 mph
...
```

Note that Codex itself is not able to make network calls, so it would
not normally be able to get live weather information like this. However,
the weather MCP is [currently] not run under the Codex sandbox, so it is
able to hit `api.weather.gov` and fetch current weather information.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/829).
* #836
* __->__ #829
2025-05-06 15:47:59 -07:00
Michael Bolin
49d040215a fix: build all crates individually as part of CI (#833)
I discovered that `cargo build` worked for the entire workspace, but not
for the `mcp-client` or `core` crates.

* `mcp-client` failed to build because it underspecified the set of
features it needed from `tokio`.
* `core` failed to build because it was using a "feature" of its own
crate in the default, no-feature version.
 
This PR fixes the builds and adds a check in CI to defend against this
sort of thing going forward.
2025-05-06 12:02:49 -07:00
Michael Bolin
5f1b8f707c feat: update McpClient::new_stdio_client() to accept an env (#831)
Cleans up the signature for `new_stdio_client()` to more closely mirror
how MCP servers are declared in config files (`command`, `args`, `env`).
Also takes a cue from Claude Code where the MCP server is launched with
a restricted `env` so that it only includes "safe" things like `USER`
and `PATH` (see the `create_env_for_mcp_server()` function introduced in
this PR for details) by default, as it is common for developers to have
sensitive API keys present in their environment that should only be
forwarded to the MCP server when the user has explicitly configured it
to do so.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/831).
* #829
* __->__ #831
2025-05-06 11:14:47 -07:00
Michael Bolin
2cf7aeeeb6 feat: initial McpClient for Rust (#822)
This PR introduces an initial `McpClient` that we will use to give Codex
itself programmatic access to foreign MCPs. This does not wire it up in
Codex itself yet, but the new `mcp-client` crate includes a `main.rs`
for basic testing for now.

Manually tested by sending a `tools/list` request to Codex's own MCP
server:

```
codex-rs$ cargo build
codex-rs$ cargo run --bin codex-mcp-client ./target/debug/codex-mcp-server
{
  "tools": [
    {
      "description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
      "inputSchema": {
        "properties": {
          "approval-policy": {
            "description": "Execution approval policy expressed as the kebab-case variant name (`unless-allow-listed`, `auto-edit`, `on-failure`, `never`).",
            "enum": [
              "auto-edit",
              "unless-allow-listed",
              "on-failure",
              "never"
            ],
            "type": "string"
          },
          "cwd": {
            "description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.",
            "type": "string"
          },
          "disable-response-storage": {
            "description": "Disable server-side response storage.",
            "type": "boolean"
          },
          "model": {
            "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\")",
            "type": "string"
          },
          "prompt": {
            "description": "The *initial user prompt* to start the Codex conversation.",
            "type": "string"
          },
          "sandbox-permissions": {
            "description": "Sandbox permissions using the same string values accepted by the CLI (e.g. \"disk-write-cwd\", \"network-full-access\").",
            "items": {
              "enum": [
                "disk-full-read-access",
                "disk-write-cwd",
                "disk-write-platform-user-temp-folder",
                "disk-write-platform-global-temp-folder",
                "disk-full-write-access",
                "network-full-access"
              ],
              "type": "string"
            },
            "type": "array"
          }
        },
        "required": [
          "prompt"
        ],
        "type": "object"
      },
      "name": "codex"
    }
  ]
}
```
2025-05-05 12:52:55 -07:00
Anil Karaka
76a979007e fix: increase output limits for truncating collector (#575)
This Pull Request addresses an issue where the output of commands
executed in the raw-exec utility was being truncated due to restrictive
limits on the number of lines and bytes collected. The truncation caused
the message [Output truncated: too many lines or bytes] to appear when
processing large outputs, which could hinder the functionality of the
CLI.

Changes Made

Increased the maximum output limits in the
[createTruncatingCollector](https://github.com/openai/codex/pull/575)
utility:
Bytes: Increased from 10 KB to 100 KB.
Lines: Increased from 256 lines to 1024 lines.
Installed the @types/node package to resolve missing type definitions
for [NodeJS](https://github.com/openai/codex/pull/575) and
[Buffer](https://github.com/openai/codex/pull/575).
Verified and fixed any related errors in the
[createTruncatingCollector](https://github.com/openai/codex/pull/575)
implementation.

Issue Solved: 

This PR ensures that larger outputs can be processed without truncation,
improving the usability of the CLI for commands that generate extensive
output. https://github.com/openai/codex/issues/509

---------

Co-authored-by: Michael Bolin <bolinfest@gmail.com>
2025-05-05 10:26:55 -07:00
Andrey Mishchenko
7e97980cb4 Use "Title case" for ToC (#812) 2025-05-05 08:49:42 -07:00
Michael Bolin
2b72d05c5e feat: make Codex available as a tool when running it as an MCP server (#811)
This PR replaces the placeholder `"echo"` tool call in the MCP server
with a `"codex"` tool that calls Codex. Events such as
`ExecApprovalRequest` and `ApplyPatchApprovalRequest` are not handled
properly yet, but I have `approval_policy = "never"` set in my
`~/.codex/config.toml` such that those codepaths are not exercised.

The schema for this MPC tool is defined by a new `CodexToolCallParam`
struct introduced in this PR. It is fairly similar to `ConfigOverrides`,
as the param is used to help create the `Config` used to start the Codex
session, though it also includes the `prompt` used to kick off the
session.

This PR also introduces the use of the third-party `schemars` crate to
generate the JSON schema, which is verified in the
`verify_codex_tool_json_schema()` unit test.

Events that are dispatched during the Codex session are sent back to the
MCP client as MCP notifications. This gives the client a way to monitor
progress as the tool call itself may take minutes to complete depending
on the complexity of the task requested by the user.

In the video below, I launched the server via:

```shell
mcp-server$ RUST_LOG=debug npx @modelcontextprotocol/inspector cargo run --
```

In the video, you can see the flow of:

* requesting the list of tools
* choosing the **codex** tool
* entering a value for **prompt** and then making the tool call

Note that I left the other fields blank because when unspecified, the
values in my `~/.codex/config.toml` were used:


https://github.com/user-attachments/assets/1975058c-b004-43ef-8c8d-800a953b8192

Note that while using the inspector, I did run into
https://github.com/modelcontextprotocol/inspector/issues/293, though the
tip about ensuring I had only one instance of the **MCP Inspector** tab
open in my browser seemed to fix things.
2025-05-05 07:16:19 -07:00
Michael Bolin
5d924d44cf fix: ensure apply_patch resolves relative paths against workdir or project cwd (#810)
https://github.com/openai/codex/pull/800 kicked off some work to be more
disciplined about honoring the `cwd` param passed in rather than
assuming `std::env::current_dir()` as the `cwd`. As part of this, we
need to ensure `apply_patch` calls honor the appropriate `cwd` as well,
which is significant if the paths in the `apply_patch` arg are not
absolute paths themselves. Failing that:

- The `apply_patch` function call can contain an optional`workdir`
param, so:
- If specified and is an absolute path, it should be used to resolve
relative paths
- If specified and is a relative path, should be resolved against
`Config.cwd` and then any relative paths will be resolved against the
result
- If `workdir` is not specified on the function call, relative paths
should be resolved against `Config.cwd`

Note that we had a similar issue in the TypeScript CLI that was fixed in
https://github.com/openai/codex/pull/556.

As part of the fix, this PR introduces `ApplyPatchAction` so clients can
deal with that instead of the raw `HashMap<PathBuf,
ApplyPatchFileChange>`. This enables us to enforce, by construction,
that all paths contained in the `ApplyPatchAction` are absolute paths.
2025-05-04 12:32:51 -07:00
Michael Bolin
a134bdde49 fix: is_inside_git_repo should take the directory as a param (#809)
https://github.com/openai/codex/pull/800 made `cwd` a property of
`Config` and made it so the `cwd` is not necessarily
`std::env::current_dir()`. As such, `is_inside_git_repo()` should check
`Config.cwd` rather than `std::env::current_dir()`.

This PR updates `is_inside_git_repo()` to take `Config` instead of an
arbitrary `PathBuf` to force the check to operate on a `Config` where
`cwd` has been resolved to what the user specified.
2025-05-04 11:39:10 -07:00
Michael Bolin
cd12f0c24a fix: TUI should use cwd from Config (#808)
https://github.com/openai/codex/pull/800 made `cwd` a property of
`Config`, so the TUI should use this instead of running
`std::env::current_dir()`.
2025-05-04 11:12:40 -07:00
Michael Bolin
421e159888 feat: make cwd a required field of Config so we stop assuming std::env::current_dir() in a session (#800)
In order to expose Codex via an MCP server, I realized that we should be
taking `cwd` as a parameter rather than assuming
`std::env::current_dir()` as the `cwd`. Specifically, the user may want
to start a session in a directory other than the one where the MCP
server has been started.

This PR makes `cwd: PathBuf` a required field of `Session` and threads
it all the way through, though I think there is still an issue with not
honoring `workdir` for `apply_patch`, which is something we also had to
fix in the TypeScript version: https://github.com/openai/codex/pull/556.

This also adds `-C`/`--cd` to change the cwd via the command line.

To test, I ran:

```
cargo run --bin codex -- exec -C /tmp 'show the output of ls'
```

and verified it showed the contents of my `/tmp` folder instead of
`$PWD`.
2025-05-04 10:57:12 -07:00
Andrey Mishchenko
4b61fb8bab use "Title case" in README.md (#798) 2025-05-03 10:17:44 -07:00
Michael Bolin
0442458309 doc: update the config.toml documentation for the Rust CLI in codex-rs/README.md (#795)
https://github.com/openai/codex/pull/793 had important information on
the `notify` config option that seemed worth memorializing, so this PR
updates the documentation about all of the configurable options in
`~/.codex/config.toml`.
2025-05-02 20:32:24 -07:00
Michael Bolin
a180ed44e8 feat: configurable notifications in the Rust CLI (#793)
With this change, you can specify a program that will be executed to get
notified about events generated by Codex. The notification info will be
packaged as a JSON object. The supported notification types are defined
by the `UserNotification` enum introduced in this PR. Initially, it
contains only one variant, `AgentTurnComplete`:

```rust
pub(crate) enum UserNotification {
    #[serde(rename_all = "kebab-case")]
    AgentTurnComplete {
        turn_id: String,

        /// Messages that the user sent to the agent to initiate the turn.
        input_messages: Vec<String>,

        /// The last message sent by the assistant in the turn.
        last_assistant_message: Option<String>,
    },
}
```

This is intended to support the common case when a "turn" ends, which
often means it is now your chance to give Codex further instructions.

For example, I have the following in my `~/.codex/config.toml`:

```toml
notify = ["python3", "/Users/mbolin/.codex/notify.py"]
```

I created my own custom notifier script that calls out to
[terminal-notifier](https://github.com/julienXX/terminal-notifier) to
show a desktop push notification on macOS. Contents of `notify.py`:

```python
#!/usr/bin/env python3

import json
import subprocess
import sys


def main() -> int:
    if len(sys.argv) != 2:
        print("Usage: notify.py <NOTIFICATION_JSON>")
        return 1

    try:
        notification = json.loads(sys.argv[1])
    except json.JSONDecodeError:
        return 1

    match notification_type := notification.get("type"):
        case "agent-turn-complete":
            assistant_message = notification.get("last-assistant-message")
            if assistant_message:
                title = f"Codex: {assistant_message}"
            else:
                title = "Codex: Turn Complete!"
            input_messages = notification.get("input_messages", [])
            message = " ".join(input_messages)
            title += message
        case _:
            print(f"not sending a push notification for: {notification_type}")
            return 0

    subprocess.check_output(
        [
            "terminal-notifier",
            "-title",
            title,
            "-message",
            message,
            "-group",
            "codex",
            "-ignoreDnD",
            "-activate",
            "com.googlecode.iterm2",
        ]
    )

    return 0


if __name__ == "__main__":
    sys.exit(main())
```

For reference, here are related PRs that tried to add this functionality
to the TypeScript version of the Codex CLI:

* https://github.com/openai/codex/pull/160
* https://github.com/openai/codex/pull/498
2025-05-02 19:48:13 -07:00
Michael Bolin
21cd953dbd feat: introduce mcp-server crate (#792)
This introduces the `mcp-server` crate, which contains a barebones MCP
server that provides an `echo` tool that echoes the user's request back
to them.

To test it out, I launched
[modelcontextprotocol/inspector](https://github.com/modelcontextprotocol/inspector)
like so:

```
mcp-server$ npx @modelcontextprotocol/inspector cargo run --
```

and opened up `http://127.0.0.1:6274` in my browser:


![image](https://github.com/user-attachments/assets/83fc55d4-25c2-4497-80cd-e9702283ff93)

I also had to make a small fix to `mcp-types`, adding
`#[serde(untagged)]` to a number of `enum`s.
2025-05-02 17:25:58 -07:00
Michael Bolin
865e518771 fix: mcp-types serialization wasn't quite working (#791)
While creating a basic MCP server in
https://github.com/openai/codex/pull/792, I discovered a number of bugs
with the initial `mcp-types` crate that I needed to fix in order to
implement the server.

For example, I discovered that when serializing a message, `"jsonrpc":
"2.0"` was not being included.

I changed the codegen so that the field is added as:

```rust
    #[serde(rename = "jsonrpc", default = "default_jsonrpc")]
    pub jsonrpc: String,
```

This ensures that the field is serialized as `"2.0"`, though the field
still has to be assigned, which is tedious. I may experiment with
`Default` or something else in the future. (I also considered creating a
custom serializer, but I'm not sure it's worth the trouble.)

While here, I also added `MCP_SCHEMA_VERSION` and `JSONRPC_VERSION` as
`pub const`s for the crate.

I also discovered that MCP rejects sending `null` for optional fields,
so I had to add `#[serde(skip_serializing_if = "Option::is_none")]` on
`Option` fields.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/791).
* #792
* __->__ #791
2025-05-02 16:38:05 -07:00
Michael Bolin
83961e0299 feat: introduce mcp-types crate (#787)
This adds our own `mcp-types` crate to our Cargo workspace. We vendor in
the
[`2025-03-26/schema.json`](05f2045136/schema/2025-03-26/schema.json)
from the MCP repo and introduce a `generate_mcp_types.py` script to
codegen the `lib.rs` from the JSON schema.

Test coverage is currently light, but I plan to refine things as we
start making use of this crate.

And yes, I am aware that
https://github.com/modelcontextprotocol/rust-sdk exists, though the
published https://crates.io/crates/rmcp appears to be a competing
effort. While things are up in the air, it seems better for us to
control our own version of this code.

Incidentally, Codex did a lot of the work for this PR. I told it to
never edit `lib.rs` directly and instead to update
`generate_mcp_types.py` and then re-run it to update `lib.rs`. It
followed these instructions and once things were working end-to-end, I
iteratively asked for changes to the tests until the API looked
reasonable (and the code worked). Codex was responsible for figuring out
what to do to `generate_mcp_types.py` to achieve the requested test/API
changes.
2025-05-02 13:33:14 -07:00
anup-openai
f6b1ce2e3a Configure HTTPS agent for proxies (#775)
- Some workflows require you to route openAI API traffic through a proxy
- See
https://github.com/openai/openai-node/tree/v4?tab=readme-ov-file#configuring-an-https-agent-eg-for-proxies
for more details

---------

Co-authored-by: Thibault Sottiaux <tibo@openai.com>
Co-authored-by: Fouad Matin <fouad@openai.com>
2025-05-02 12:08:13 -07:00
Fouad Matin
b864cc3810 update: vite version (#766) 2025-05-02 09:30:08 -07:00
Michael Bolin
a4b51f6b67 feat: use Landlock for sandboxing on Linux in TypeScript CLI (#763)
Building on top of https://github.com/openai/codex/pull/757, this PR
updates Codex to use the Landlock executor binary for sandboxing in the
Node.js CLI. Note that Codex has to be invoked with either `--full-auto`
or `--auto-edit` to activate sandboxing. (Using `--suggest` or
`--dangerously-auto-approve-everything` ensures the sandboxing codepath
will not be exercised.)

When I tested this on a Linux host (specifically, `Ubuntu 24.04.1 LTS`),
things worked as expected: I ran Codex CLI with `--full-auto` and then
asked it to do `echo 'hello mbolin' into hello_world.txt` and it
succeeded without prompting me.

However, in my testing, I discovered that the sandboxing did *not* work
when using `--full-auto` in a Linux Docker container from a macOS host.
I updated the code to throw a detailed error message when this happens:


![image](https://github.com/user-attachments/assets/e5b99def-f00e-4ade-a0c5-2394d30df52e)
2025-05-01 12:34:56 -07:00
Michael Bolin
3f5975ad5a chore: make build process a single script to run (#757)
This introduces `./codex-cli/scripts/stage_release.sh`, which is a shell
script that stages a release for the Node.js module in a temp directory.
It updates the release to include these native binaries:

```
bin/codex-linux-sandbox-arm64
bin/codex-linux-sandbox-x64
```

though this PR does not update Codex CLI to use them yet.

When doing local development, run
`./codex-cli/scripts/install_native_deps.sh` to install these in your
own `bin/` folder.

This PR also updates `README.md` to document the new workflow.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/757).
* #763
* __->__ #757
2025-05-01 08:36:07 -07:00
Fouad Matin
463a230991 bump(version): 0.1.2504301751 (#768)
## `0.1.2504301751`

### 🚀 Features

- User config api key (#569)
- `@mention` files in codex (#701)
- Add `--reasoning` CLI flag (#314)
- Lower default retry wait time and increase number of tries (#720)
- Add common package registries domains to allowed-domains list (#414)

### 🪲 Bug Fixes

- Insufficient quota message (#758)
- Input keyboard shortcut opt+delete (#685)
- `/diff` should include untracked files (#686)
- Only allow running without sandbox if explicitly marked in safe
container (#699)
- Tighten up check for /usr/bin/sandbox-exec (#710)
- Check if sandbox-exec is available (#696)
- Duplicate messages in quiet mode (#680)
2025-04-30 17:55:34 -07:00
Fouad Matin
985fd44ec0 fix: input keyboard shortcut opt+delete (#685) 2025-04-30 17:17:13 -07:00
moppywhip
bc4e6db749 feat: @mention files in codex (#701)
Solves #700

## State of the World Before

Prior to this PR, when users wanted to share file contents with Codex,
they had two options:
- Manually copy and paste file contents into the chat
- Wait for the assistant to use the shell tool to view the file

The second approach required the assistant to:
1. Recognize the need to view a file
2. Execute a shell tool call
3. Wait for the tool call to complete
4. Process the file contents

This consumed extra tokens and reduced user control over which files
were shared with the model.

## State of the World After

With this PR, users can now:
- Reference files directly in their chat input using the `@path` syntax
- Have file contents automatically expanded into XML blocks before being
sent to the LLM

For example, users can type `@src/utils/config.js` in their message, and
the file contents will be included in context. Within the terminal chat
history, these file blocks will be collapsed back to `@path` format in
the UI for clean presentation.

Tag File suggestions:
<img width="857" alt="file-suggestions"
src="https://github.com/user-attachments/assets/397669dc-ad83-492d-b5f0-164fab2ff4ba"
/>

Tagging files in action:
<img width="858" alt="tagging-files"
src="https://github.com/user-attachments/assets/0de9d559-7b7f-4916-aeff-87ae9b16550a"
/>

Demo video of file tagging:
[![Demo video of file
tagging](https://img.youtube.com/vi/vL4LqtBnqt8/0.jpg)](https://www.youtube.com/watch?v=vL4LqtBnqt8)

## Implementation Details

This PR consists of 2 main components:

1. **File Tag Utilities**:
- New `file-tag-utils.ts` utility module that handles both expansion and
collapsing of file tags
- `expandFileTags()` identifies `@path` tokens and replaces them with
XML blocks containing file contents
- `collapseXmlBlocks()` reverses the process, converting XML blocks back
to `@path` format for UI display
- Tokens are only expanded if they point to valid files (directories are
ignored)
   - Expansion happens just before sending input to the model

2. **Terminal Chat Integration**:
- Leveraged the existing file system completion system for tabbing to
support the `@path` syntax
   - Added `updateFsSuggestions` helper to manage filesystem suggestions
- Added `replaceFileSystemSuggestion` to replace input with filesystem
suggestions
- Applied `collapseXmlBlocks` in the chat response rendering so that
tagged files are shown as simple `@path` tags

The PR also includes test coverage for both the UI and the file tag
utilities.

## Next Steps

Some ideas I'd like to implement if this feature gets merged:

- Line selection: `@path[50:80]` to grab specific sections of files
- Method selection: `@path#methodName` to grab just one function/class
- Visual improvements: highlight file tags in the UI to make them more
noticeable
2025-04-30 16:19:55 -07:00
Kevin Alwell
bd82101859 fix: insufficient quota message (#758)
This pull request includes a change to improve the error message
displayed when there is insufficient quota in the `AgentLoop` class. The
updated message provides more detailed information and a link for
managing or purchasing credits.

Error message improvement:

*
[`codex-cli/src/utils/agent/agent-loop.ts`](diffhunk://#diff-b15957eac2720c3f1f55aa32f172cdd0ac6969caf4e7be87983df747a9f97083L1140-R1140):
Updated the error message in the `AgentLoop` class to include the
specific error message (if available) and a link to manage or purchase
credits.


Fixes #751
2025-04-30 16:00:50 -07:00
Michael Bolin
033d379eca fix: remove unused _writableRoots arg to exec() function (#762)
I suspect this was done originally so that `execForSandbox()` had a
consistent signature for both the `SandboxType.NONE` and
`SandboxType.MACOS_SEATBELT` cases, but that is not really necessary and
turns out to make the upcoming Landlock support a bit more complicated
to implement, so I had Codex remove it and clean up the call sites.
2025-04-30 14:08:27 -07:00
Michael Bolin
e6fe8d6fa1 chore: mark Rust releases as "prerelease" (#761)
Apparently the URLs for draft releases cannot be downloaded using
unauthenticated `curl`, which means the DotSlash file only works for
users who are authenticated with `gh`. According to chat, prereleases
_can_ be fetched with unauthenticated `curl`, so let's try that.
2025-04-30 13:25:53 -07:00
Michael Bolin
b571249867 chore: script to create a Rust release (#759)
For now, keep things simple such that we never update the `version` in
the `Cargo.toml` for the workspace root on the `main` branch. Instead,
create a new branch for a release, push one commit that updates the
`version`, and then tag that branch to kick off a release.

To test, I ran this script and created this release job:

https://github.com/openai/codex/actions/runs/14762580641
2025-04-30 12:39:03 -07:00
Michael Bolin
24278347b7 fix: remove codex-repl from GitHub workflows (#760)
I missed this when doing https://github.com/openai/codex/pull/754.
2025-04-30 12:10:24 -07:00
Michael Bolin
8f7a54501c chore: Rust release, set prerelease:false and version=0.0.2504301132 (#755)
The generated DotSlash file has URLs that refer to
`https://github.com/openai/codex/releases/`, so let's set
`prerelease:false` (but keep `draft:true` for now) so those URLs should
work.

Also updated `version` in Cargo workspace so I will kick off a build
once this lands.
2025-04-30 11:53:03 -07:00
Michael Bolin
2f1d96e77d fix: remove errant eslint-disable so pnpm run lint passes again (#756)
My bad: introduced in https://github.com/openai/codex/pull/753.
2025-04-30 11:37:11 -07:00
Michael Bolin
84aaefa102 fix: read version from package.json instead of modifying session.ts (#753)
I am working to simplify the build process. As a first step, update
`session.ts` so it reads the `version` from `package.json` at runtime so
we no longer have to modify it during the build process. I want to get
to a place where the build looks like:

```
cd codex-cli
pnpm i
pnpm build
RELEASE_DIR=$(mktemp -d)
cp -r bin "$RELEASE_DIR/bin"
cp -r dist "$RELEASE_DIR/dist"
cp -r src "$RELEASE_DIR/src" # important if we want sourcemaps to continue to work
cp ../README.md "$RELEASE_DIR"
VERSION=$(printf '0.1.%d' $(date +%y%m%d%H%M))
jq --arg version "$VERSION" '.version = $version' package.json > "$RELEASE_DIR/package.json"
```

Then the contents of `$RELEASE_DIR` should be good to `npm publish`, no?
2025-04-30 11:03:10 -07:00
Michael Bolin
c432d9ef81 chore: remove the REPL crate/subcommand (#754)
@oai-ragona and I discussed it, and we feel the REPL crate has served
its purpose, so we're going to delete the code and future archaeologists
can find it in Git history.
2025-04-30 10:15:50 -07:00
Michael Bolin
4746ee900f fix: remove expected dot after v in rust-v tag name (#742)
I think this extra dot was not intentional, but I'm not sure. Certainly
this comment suggests it should not be there:


85999d7277/.github/workflows/rust-release.yml (L4)
2025-04-30 10:05:47 -07:00
Michael Bolin
f2ed46ceca fix: include x86_64-unknown-linux-gnu in the list of arch to build codex-linux-sandbox (#748) 2025-04-29 21:19:14 -07:00
Michael Bolin
e42dacbdc8 fix: add another place where $dest was missing in rust-release.yml (#747)
I thought https://github.com/openai/codex/pull/745 was the last fix I
needed, but apparently not.
2025-04-29 20:23:54 -07:00
100 changed files with 8970 additions and 1162 deletions

View File

@@ -1,14 +1,5 @@
{
"outputs": {
"codex-repl": {
"platforms": {
"macos-aarch64": { "regex": "^codex-repl-aarch64-apple-darwin\\.zst$", "path": "codex-repl" },
"macos-x86_64": { "regex": "^codex-repl-x86_64-apple-darwin\\.zst$", "path": "codex-repl" },
"linux-x86_64": { "regex": "^codex-repl-x86_64-unknown-linux-musl\\.zst$", "path": "codex-repl" },
"linux-aarch64": { "regex": "^codex-repl-aarch64-unknown-linux-gnu\\.zst$", "path": "codex-repl" }
}
},
"codex-exec": {
"platforms": {
"macos-aarch64": { "regex": "^codex-exec-aarch64-apple-darwin\\.zst$", "path": "codex-exec" },

View File

@@ -68,6 +68,12 @@ jobs:
- name: Build
run: pnpm run build
- name: Ensure staging a release works.
working-directory: codex-cli
env:
GH_TOKEN: ${{ github.token }}
run: pnpm stage-release
- name: Ensure README.md contains only ASCII and certain Unicode code points
run: ./scripts/asciicheck.py README.md
- name: Check README ToC

View File

@@ -83,8 +83,17 @@ jobs:
- name: cargo clippy
run: cargo clippy --target ${{ matrix.target }} --all-features -- -D warnings || echo "FAILED=${FAILED:+$FAILED, }cargo clippy" >> $GITHUB_ENV
# Running `cargo build` from the workspace root builds the workspace using
# the union of all features from third-party crates. This can mask errors
# where individual crates have underspecified features. To avoid this, we
# run `cargo build` for each crate individually, though because this is
# slower, we only do this for the x86_64-unknown-linux-gnu target.
- name: cargo build individual crates
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}
run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build' || echo "FAILED=${FAILED:+$FAILED, }cargo build individual crates" >> $GITHUB_ENV
- name: cargo test
run: cargo test --target ${{ matrix.target }} || echo "FAILED=${FAILED:+$FAILED, }cargo test" >> $GITHUB_ENV
run: cargo test --all-features --target ${{ matrix.target }} || echo "FAILED=${FAILED:+$FAILED, }cargo test" >> $GITHUB_ENV
- name: Fail if any step failed
if: env.FAILED != ''

View File

@@ -9,14 +9,14 @@ name: rust-release
on:
push:
tags:
- "rust-v.*.*.*"
- "rust-v*.*.*"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env:
TAG_REGEX: '^rust-v\.[0-9]+\.[0-9]+\.[0-9]+$'
TAG_REGEX: '^rust-v[0-9]+\.[0-9]+\.[0-9]+$'
jobs:
tag-check:
@@ -37,7 +37,7 @@ jobs:
|| { echo "❌ Tag '${GITHUB_REF_NAME}' != ${TAG_REGEX}"; exit 1; }
# 2. Extract versions
tag_ver="${GITHUB_REF_NAME#rust-v.}"
tag_ver="${GITHUB_REF_NAME#rust-v}"
cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \
| sed -E 's/version *= *"([^"]+)".*/\1/')"
@@ -102,11 +102,10 @@ jobs:
dest="dist/${{ matrix.target }}"
mkdir -p "$dest"
cp target/${{ matrix.target }}/release/codex-repl "$dest/codex-repl-${{ matrix.target }}"
cp target/${{ matrix.target }}/release/codex-exec "$dest/codex-exec-${{ matrix.target }}"
cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-gnu' }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'x86_64-unknown-linux-gnu' || matrix.target == 'aarch64-unknown-linux-gnu' }}
name: Stage Linux-only artifacts
shell: bash
run: |
@@ -116,6 +115,7 @@ jobs:
- name: Compress artifacts
shell: bash
run: |
dest="dist/${{ matrix.target }}"
zstd -T0 -19 --rm "$dest"/*
- uses: actions/upload-artifact@v4
@@ -142,11 +142,9 @@ jobs:
with:
tag_name: ${{ env.RELEASE_TAG }}
files: dist/**
# TODO(ragona): I'm going to leave these as prerelease/draft for now.
# It gives us 1) clarity that these are not yet a stable version, and
# 2) allows a human step to review the release before publishing the draft.
# For now, tag releases as "prerelease" because we are not claiming
# the Rust CLI is stable yet.
prerelease: true
draft: true
- uses: facebook/dotslash-publish-release@v2
env:

View File

@@ -2,6 +2,26 @@
You can install any of these versions: `npm install -g codex@version`
## `0.1.2504301751`
### 🚀 Features
- User config api key (#569)
- `@mention` files in codex (#701)
- Add `--reasoning` CLI flag (#314)
- Lower default retry wait time and increase number of tries (#720)
- Add common package registries domains to allowed-domains list (#414)
### 🪲 Bug Fixes
- Insufficient quota message (#758)
- Input keyboard shortcut opt+delete (#685)
- `/diff` should include untracked files (#686)
- Only allow running without sandbox if explicitly marked in safe container (#699)
- Tighten up check for /usr/bin/sandbox-exec (#710)
- Check if sandbox-exec is available (#696)
- Duplicate messages in quiet mode (#680)
## `0.1.2504251709`
### 🚀 Features

118
README.md
View File

@@ -8,48 +8,48 @@
---
<details>
<summary><strong>Table&nbsp;of&nbsp;Contents</strong></summary>
<summary><strong>Table of contents</strong></summary>
<!-- Begin ToC -->
- [Experimental Technology Disclaimer](#experimental-technology-disclaimer)
- [Experimental technology disclaimer](#experimental-technology-disclaimer)
- [Quickstart](#quickstart)
- [Why Codex?](#why-codex)
- [Security Model & Permissions](#security-model--permissions)
- [Security model & permissions](#security-model--permissions)
- [Platform sandboxing details](#platform-sandboxing-details)
- [System Requirements](#system-requirements)
- [CLI Reference](#cli-reference)
- [Memory & Project Docs](#memory--project-docs)
- [System requirements](#system-requirements)
- [CLI reference](#cli-reference)
- [Memory & project docs](#memory--project-docs)
- [Non-interactive / CI mode](#non-interactive--ci-mode)
- [Tracing / Verbose Logging](#tracing--verbose-logging)
- [Tracing / verbose logging](#tracing--verbose-logging)
- [Recipes](#recipes)
- [Installation](#installation)
- [Configuration Guide](#configuration-guide)
- [Basic Configuration Parameters](#basic-configuration-parameters)
- [Custom AI Provider Configuration](#custom-ai-provider-configuration)
- [History Configuration](#history-configuration)
- [Configuration Examples](#configuration-examples)
- [Full Configuration Example](#full-configuration-example)
- [Custom Instructions](#custom-instructions)
- [Environment Variables Setup](#environment-variables-setup)
- [Configuration guide](#configuration-guide)
- [Basic configuration parameters](#basic-configuration-parameters)
- [Custom AI provider configuration](#custom-ai-provider-configuration)
- [History configuration](#history-configuration)
- [Configuration examples](#configuration-examples)
- [Full configuration example](#full-configuration-example)
- [Custom instructions](#custom-instructions)
- [Environment variables setup](#environment-variables-setup)
- [FAQ](#faq)
- [Zero Data Retention (ZDR) Usage](#zero-data-retention-zdr-usage)
- [Codex Open Source Fund](#codex-open-source-fund)
- [Zero data retention (ZDR) usage](#zero-data-retention-zdr-usage)
- [Codex open source fund](#codex-open-source-fund)
- [Contributing](#contributing)
- [Development workflow](#development-workflow)
- [Git Hooks with Husky](#git-hooks-with-husky)
- [Git hooks with Husky](#git-hooks-with-husky)
- [Debugging](#debugging)
- [Writing high-impact code changes](#writing-high-impact-code-changes)
- [Opening a pull request](#opening-a-pull-request)
- [Review process](#review-process)
- [Community values](#community-values)
- [Getting help](#getting-help)
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
- [Contributor license agreement (CLA)](#contributor-license-agreement-cla)
- [Quick fixes](#quick-fixes)
- [Releasing `codex`](#releasing-codex)
- [Alternative Build Options](#alternative-build-options)
- [Nix Flake Development](#nix-flake-development)
- [Security & Responsible AI](#security--responsible-ai)
- [Alternative build options](#alternative-build-options)
- [Nix flake development](#nix-flake-development)
- [Security & responsible AI](#security--responsible-ai)
- [License](#license)
<!-- End ToC -->
@@ -58,7 +58,7 @@
---
## Experimental Technology Disclaimer
## Experimental technology disclaimer
Codex CLI is an experimental project under active development. It is not yet stable, may contain bugs, incomplete features, or undergo breaking changes. We're building it in the open with the community and welcome:
@@ -158,7 +158,7 @@ And it's **fully open-source** so you can see and contribute to how it develops!
---
## Security Model & Permissions
## Security model & permissions
Codex lets you decide _how much autonomy_ the agent receives and auto-approval policy via the
`--approval-mode` flag (or the interactive onboarding prompt):
@@ -198,7 +198,7 @@ The hardening mechanism Codex uses depends on your OS:
---
## System Requirements
## System requirements
| Requirement | Details |
| --------------------------- | --------------------------------------------------------------- |
@@ -211,7 +211,7 @@ The hardening mechanism Codex uses depends on your OS:
---
## CLI Reference
## CLI reference
| Command | Purpose | Example |
| ------------------------------------ | ----------------------------------- | ------------------------------------ |
@@ -224,7 +224,7 @@ Key flags: `--model/-m`, `--approval-mode/-a`, `--quiet/-q`, and `--notify`.
---
## Memory & Project Docs
## Memory & project docs
Codex merges Markdown instructions in this order:
@@ -250,7 +250,7 @@ Run Codex head-less in pipelines. Example GitHub Action step:
Set `CODEX_QUIET_MODE=1` to silence interactive UI noise.
## Tracing / Verbose Logging
## Tracing / verbose logging
Setting the environment variable `DEBUG=true` prints full API request and response details:
@@ -308,6 +308,9 @@ corepack enable
pnpm install
pnpm build
# Linux-only: download prebuilt sandboxing binaries (requires gh and zstd).
./scripts/install_native_deps.sh
# Get the usage and the options
node ./dist/cli.js --help
@@ -322,11 +325,11 @@ pnpm link
---
## Configuration Guide
## Configuration guide
Codex configuration files can be placed in the `~/.codex/` directory, supporting both YAML and JSON formats.
### Basic Configuration Parameters
### Basic configuration parameters
| Parameter | Type | Default | Description | Available Options |
| ------------------- | ------- | ---------- | -------------------------------- | ---------------------------------------------------------------------------------------------- |
@@ -335,7 +338,7 @@ Codex configuration files can be placed in the `~/.codex/` directory, supporting
| `fullAutoErrorMode` | string | `ask-user` | Error handling in full-auto mode | `ask-user` (prompt for user input)<br>`ignore-and-continue` (ignore and proceed) |
| `notify` | boolean | `true` | Enable desktop notifications | `true`/`false` |
### Custom AI Provider Configuration
### Custom AI provider configuration
In the `providers` object, you can configure multiple AI service providers. Each provider requires the following parameters:
@@ -345,7 +348,7 @@ In the `providers` object, you can configure multiple AI service providers. Each
| `baseURL` | string | API service URL | `"https://api.openai.com/v1"` |
| `envKey` | string | Environment variable name (for API key) | `"OPENAI_API_KEY"` |
### History Configuration
### History configuration
In the `history` object, you can configure conversation history settings:
@@ -355,7 +358,7 @@ In the `history` object, you can configure conversation history settings:
| `saveHistory` | boolean | Whether to save history | `true` |
| `sensitivePatterns` | array | Patterns of sensitive information to filter in history | `[]` |
### Configuration Examples
### Configuration examples
1. YAML format (save as `~/.codex/config.yaml`):
@@ -377,7 +380,7 @@ notify: true
}
```
### Full Configuration Example
### Full configuration example
Below is a comprehensive example of `config.json` with multiple custom providers:
@@ -435,7 +438,7 @@ Below is a comprehensive example of `config.json` with multiple custom providers
}
```
### Custom Instructions
### Custom instructions
You can create a `~/.codex/instructions.md` file to define custom instructions:
@@ -444,7 +447,7 @@ You can create a `~/.codex/instructions.md` file to define custom instructions:
- Only use git commands when explicitly requested
```
### Environment Variables Setup
### Environment variables setup
For each AI provider, you need to set the corresponding API key in your environment variables. For example:
@@ -497,7 +500,7 @@ Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.mic
---
## Zero Data Retention (ZDR) Usage
## Zero data retention (ZDR) usage
Codex CLI **does** support OpenAI organizations with [Zero Data Retention (ZDR)](https://platform.openai.com/docs/guides/your-data#zero-data-retention) enabled. If your OpenAI organization has Zero Data Retention enabled and you still encounter errors such as:
@@ -509,7 +512,7 @@ You may need to upgrade to a more recent version with: `npm i -g @openai/codex@l
---
## Codex Open Source Fund
## Codex open source fund
We're excited to launch a **$1 million initiative** supporting open source projects that use Codex CLI and other OpenAI models.
@@ -534,7 +537,7 @@ More broadly we welcome contributions - whether you are opening your very first
- We use **Vitest** for unit tests, **ESLint** + **Prettier** for style, and **TypeScript** for type-checking.
- Before pushing, run the full test/type/lint suite:
### Git Hooks with Husky
### Git hooks with Husky
This project uses [Husky](https://typicode.github.io/husky/) to enforce code quality checks:
@@ -608,7 +611,7 @@ If you run into problems setting up the project, would like feedback on an idea,
Together we can make Codex CLI an incredible tool. **Happy hacking!** :rocket:
### Contributor License Agreement (CLA)
### Contributor license agreement (CLA)
All contributors **must** accept the CLA. The process is lightweight:
@@ -633,22 +636,29 @@ The **DCO check** blocks merges until every commit in the PR carries the footer
### Releasing `codex`
To publish a new version of the CLI, run the release scripts defined in `codex-cli/package.json`:
To publish a new version of the CLI, run the following in the `codex-cli` folder to stage the release in a temporary directory:
1. Open the `codex-cli` directory
2. Make sure you're on a branch like `git checkout -b bump-version`
3. Bump the version and `CLI_VERSION` to current datetime: `pnpm release:version`
4. Commit the version bump (with DCO sign-off):
```bash
git add codex-cli/src/utils/session.ts codex-cli/package.json
git commit -s -m "chore(release): codex-cli v$(node -p \"require('./codex-cli/package.json').version\")"
```
5. Copy README, build, and publish to npm: `pnpm release`
6. Push to branch: `git push origin HEAD`
```
pnpm stage-release
```
### Alternative Build Options
Note you can specify the folder for the staged release:
#### Nix Flake Development
```
RELEASE_DIR=$(mktemp -d)
pnpm stage-release "$RELEASE_DIR"
```
Go to the folder where the release is staged and verify that it works as intended. If so, run the following from the temp folder:
```
cd "$RELEASE_DIR"
npm publish
```
### Alternative build options
#### Nix flake development
Prerequisite: Nix >= 2.4 with flakes enabled (`experimental-features = nix-command flakes` in `~/.config/nix/nix.conf`).
@@ -675,7 +685,7 @@ nix run .#codex
---
## Security & Responsible AI
## Security & responsible AI
Have you discovered a vulnerability or have concerns about model output? Please e-mail **security@openai.com** and we will respond promptly.

3
codex-cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Added by ./scripts/install_native_deps.sh
/bin/codex-linux-sandbox-arm64
/bin/codex-linux-sandbox-x64

View File

@@ -1,6 +1,6 @@
{
"name": "@openai/codex",
"version": "0.1.2504251709",
"version": "0.1.2504301751",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -20,10 +20,7 @@
"typecheck": "tsc --noEmit",
"build": "node build.mjs",
"build:dev": "NODE_ENV=development node build.mjs --dev && NODE_OPTIONS=--enable-source-maps node dist/cli-dev.js",
"release:readme": "cp ../README.md ./README.md",
"release:version": "TS=$(date +%y%m%d%H%M) && sed -E -i'' -e \"s/\\\"0\\.1\\.[0-9]{10}\\\"/\\\"0.1.${TS}\\\"/g\" package.json src/utils/session.ts",
"release:build-and-publish": "pnpm run build && npm publish",
"release": "pnpm run release:readme && pnpm run release:version && pnpm install && pnpm run release:build-and-publish"
"stage-release": "./scripts/stage_release.sh"
},
"files": [
"dist"
@@ -37,6 +34,7 @@
"fast-npm-meta": "^0.4.2",
"figures": "^6.1.0",
"file-type": "^20.1.0",
"https-proxy-agent": "^7.0.6",
"ink": "^5.2.0",
"js-yaml": "^4.1.0",
"marked": "^15.0.7",
@@ -76,7 +74,8 @@
"semver": "^7.7.1",
"ts-node": "^10.9.1",
"typescript": "^5.0.3",
"vitest": "^3.0.9",
"vite": "^6.3.4",
"vitest": "^3.1.2",
"whatwg-url": "^14.2.0",
"which": "^5.0.0"
},

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# Copy the Linux sandbox native binaries into the bin/ subfolder of codex-cli/.
#
# Usage:
# ./scripts/install_native_deps.sh [CODEX_CLI_ROOT]
#
# Arguments
# [CODEX_CLI_ROOT] Optional. If supplied, it should be the codex-cli
# folder that contains the package.json for @openai/codex.
#
# When no argument is given we assume the script is being run directly from a
# development checkout. In that case we install the binaries into the
# repositorys own `bin/` directory so that the CLI can run locally.
set -euo pipefail
# ----------------------------------------------------------------------------
# Determine where the binaries should be installed.
# ----------------------------------------------------------------------------
if [[ $# -gt 0 ]]; then
# The caller supplied a release root directory.
CODEX_CLI_ROOT="$1"
BIN_DIR="$CODEX_CLI_ROOT/bin"
else
# No argument; fall back to the repos own bin directory.
# Resolve the path of this script, then walk up to the repo root.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BIN_DIR="$CODEX_CLI_ROOT/bin"
fi
# Make sure the destination directory exists.
mkdir -p "$BIN_DIR"
# ----------------------------------------------------------------------------
# Download and decompress the artifacts from the GitHub Actions workflow.
# ----------------------------------------------------------------------------
# Until we start publishing stable GitHub releases, we have to grab the binaries
# from the GitHub Action that created them. Update the URL below to point to the
# appropriate workflow run:
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/14763725716"
WORKFLOW_ID="${WORKFLOW_URL##*/}"
ARTIFACTS_DIR="$(mktemp -d)"
trap 'rm -rf "$ARTIFACTS_DIR"' EXIT
# NB: The GitHub CLI `gh` must be installed and authenticated.
gh run download --dir "$ARTIFACTS_DIR" --repo openai/codex "$WORKFLOW_ID"
# Decompress the two target architectures.
zstd -d "$ARTIFACTS_DIR/x86_64-unknown-linux-musl/codex-linux-sandbox-x86_64-unknown-linux-musl.zst" \
-o "$BIN_DIR/codex-linux-sandbox-x64"
zstd -d "$ARTIFACTS_DIR/aarch64-unknown-linux-gnu/codex-linux-sandbox-aarch64-unknown-linux-gnu.zst" \
-o "$BIN_DIR/codex-linux-sandbox-arm64"
echo "Installed native dependencies into $BIN_DIR"

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
# Change to the codex-cli directory.
cd "$(dirname "${BASH_SOURCE[0]}")/.."
# First argument is where to stage the release. Creates a temporary directory
# if not provided.
RELEASE_DIR="${1:-$(mktemp -d)}"
[ -n "${1-}" ] && shift
# Compile the JavaScript.
pnpm install
pnpm build
mkdir "$RELEASE_DIR/bin"
cp -r bin/codex.js "$RELEASE_DIR/bin/codex.js"
cp -r dist "$RELEASE_DIR/dist"
cp -r src "$RELEASE_DIR/src" # important if we want sourcemaps to continue to work
cp ../README.md "$RELEASE_DIR"
# TODO: Derive version from Git tag.
VERSION=$(printf '0.1.%d' "$(date +%y%m%d%H%M)")
jq --arg version "$VERSION" '.version = $version' package.json > "$RELEASE_DIR/package.json"
# Copy the native dependencies.
./scripts/install_native_deps.sh "$RELEASE_DIR"
echo "Staged version $VERSION for release in $RELEASE_DIR"

View File

@@ -137,6 +137,9 @@ export interface MultilineTextEditorProps {
// Called when the internal text buffer updates.
readonly onChange?: (text: string) => void;
// Optional initial cursor position (character offset)
readonly initialCursorOffset?: number;
}
// Expose a minimal imperative API so parent components (e.g. TerminalChatInput)
@@ -169,6 +172,7 @@ const MultilineTextEditorInner = (
onSubmit,
focus = true,
onChange,
initialCursorOffset,
}: MultilineTextEditorProps,
ref: React.Ref<MultilineTextEditorHandle | null>,
): React.ReactElement => {
@@ -176,7 +180,7 @@ const MultilineTextEditorInner = (
// Editor State
// ---------------------------------------------------------------------------
const buffer = useRef(new TextBuffer(initialText));
const buffer = useRef(new TextBuffer(initialText, initialCursorOffset));
const [version, setVersion] = useState(0);
// Keep track of the current terminal size so that the editor grows/shrinks

View File

@@ -1,5 +1,6 @@
import type { MultilineTextEditorHandle } from "./multiline-editor";
import type { ReviewDecision } from "../../utils/agent/review.js";
import type { FileSystemSuggestion } from "../../utils/file-system-suggestions.js";
import type { HistoryEntry } from "../../utils/storage/command-history.js";
import type {
ResponseInputItem,
@@ -11,6 +12,7 @@ import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
import TextCompletions from "./terminal-chat-completions.js";
import { loadConfig } from "../../utils/config.js";
import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js";
import { expandFileTags } from "../../utils/file-tag-utils";
import { createInputItem } from "../../utils/input-utils.js";
import { log } from "../../utils/logger/log.js";
import { setSessionId } from "../../utils/session.js";
@@ -92,16 +94,120 @@ export default function TerminalChatInput({
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [draftInput, setDraftInput] = useState<string>("");
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(false);
const [fsSuggestions, setFsSuggestions] = useState<Array<string>>([]);
const [fsSuggestions, setFsSuggestions] = useState<
Array<FileSystemSuggestion>
>([]);
const [selectedCompletion, setSelectedCompletion] = useState<number>(-1);
// Multiline text editor key to force remount after submission
const [editorKey, setEditorKey] = useState(0);
const [editorState, setEditorState] = useState<{
key: number;
initialCursorOffset?: number;
}>({ key: 0 });
// Imperative handle from the multiline editor so we can query caret position
const editorRef = useRef<MultilineTextEditorHandle | null>(null);
// Track the caret row across keystrokes
const prevCursorRow = useRef<number | null>(null);
const prevCursorWasAtLastRow = useRef<boolean>(false);
// --- Helper for updating input, remounting editor, and moving cursor to end ---
const applyFsSuggestion = useCallback((newInputText: string) => {
setInput(newInputText);
setEditorState((s) => ({
key: s.key + 1,
initialCursorOffset: newInputText.length,
}));
}, []);
// --- Helper for updating file system suggestions ---
function updateFsSuggestions(
txt: string,
alwaysUpdateSelection: boolean = false,
) {
// Clear file system completions if a space is typed
if (txt.endsWith(" ")) {
setFsSuggestions([]);
setSelectedCompletion(-1);
} else {
// Determine the current token (last whitespace-separated word)
const words = txt.trim().split(/\s+/);
const lastWord = words[words.length - 1] ?? "";
const shouldUpdateSelection =
lastWord.startsWith("@") || alwaysUpdateSelection;
// Strip optional leading '@' for the path prefix
let pathPrefix: string;
if (lastWord.startsWith("@")) {
pathPrefix = lastWord.slice(1);
// If only '@' is typed, list everything in the current directory
pathPrefix = pathPrefix.length === 0 ? "./" : pathPrefix;
} else {
pathPrefix = lastWord;
}
if (shouldUpdateSelection) {
const completions = getFileSystemSuggestions(pathPrefix);
setFsSuggestions(completions);
if (completions.length > 0) {
setSelectedCompletion((prev) =>
prev < 0 || prev >= completions.length ? 0 : prev,
);
} else {
setSelectedCompletion(-1);
}
} else if (fsSuggestions.length > 0) {
// Token cleared → clear menu
setFsSuggestions([]);
setSelectedCompletion(-1);
}
}
}
/**
* Result of replacing text with a file system suggestion
*/
interface ReplacementResult {
/** The new text with the suggestion applied */
text: string;
/** The selected suggestion if a replacement was made */
suggestion: FileSystemSuggestion | null;
/** Whether a replacement was actually made */
wasReplaced: boolean;
}
// --- Helper for replacing input with file system suggestion ---
function getFileSystemSuggestion(
txt: string,
requireAtPrefix: boolean = false,
): ReplacementResult {
if (fsSuggestions.length === 0 || selectedCompletion < 0) {
return { text: txt, suggestion: null, wasReplaced: false };
}
const words = txt.trim().split(/\s+/);
const lastWord = words[words.length - 1] ?? "";
// Check if @ prefix is required and the last word doesn't have it
if (requireAtPrefix && !lastWord.startsWith("@")) {
return { text: txt, suggestion: null, wasReplaced: false };
}
const selected = fsSuggestions[selectedCompletion];
if (!selected) {
return { text: txt, suggestion: null, wasReplaced: false };
}
const replacement = lastWord.startsWith("@")
? `@${selected.path}`
: selected.path;
words[words.length - 1] = replacement;
return {
text: words.join(" "),
suggestion: selected,
wasReplaced: true,
};
}
// Load command history on component mount
useEffect(() => {
async function loadHistory() {
@@ -223,21 +329,12 @@ export default function TerminalChatInput({
}
if (_key.tab && selectedCompletion >= 0) {
const words = input.trim().split(/\s+/);
const selected = fsSuggestions[selectedCompletion];
if (words.length > 0 && selected) {
words[words.length - 1] = selected;
const newText = words.join(" ");
setInput(newText);
// Force remount of the editor with the new text
setEditorKey((k) => k + 1);
// We need to move the cursor to the end after editor remounts
setTimeout(() => {
editorRef.current?.moveCursorToEnd?.();
}, 0);
const { text: newText, wasReplaced } =
getFileSystemSuggestion(input);
// Only proceed if the text was actually changed
if (wasReplaced) {
applyFsSuggestion(newText);
setFsSuggestions([]);
setSelectedCompletion(-1);
}
@@ -277,7 +374,7 @@ export default function TerminalChatInput({
setInput(history[newIndex]?.command ?? "");
// Re-mount the editor so it picks up the new initialText
setEditorKey((k) => k + 1);
setEditorState((s) => ({ key: s.key + 1 }));
return; // handled
}
@@ -296,28 +393,23 @@ export default function TerminalChatInput({
if (newIndex >= history.length) {
setHistoryIndex(null);
setInput(draftInput);
setEditorKey((k) => k + 1);
setEditorState((s) => ({ key: s.key + 1 }));
} else {
setHistoryIndex(newIndex);
setInput(history[newIndex]?.command ?? "");
setEditorKey((k) => k + 1);
setEditorState((s) => ({ key: s.key + 1 }));
}
return; // handled
}
// Otherwise let it propagate
}
if (_key.tab) {
const words = input.split(/\s+/);
const mostRecentWord = words[words.length - 1];
if (mostRecentWord === undefined || mostRecentWord === "") {
return;
}
const completions = getFileSystemSuggestions(mostRecentWord);
setFsSuggestions(completions);
if (completions.length > 0) {
setSelectedCompletion(0);
}
// Defer filesystem suggestion logic to onSubmit if enter key is pressed
if (!_key.return) {
// Pressing tab should trigger the file system suggestions
const shouldUpdateSelection = _key.tab;
const targetInput = _key.delete ? input.slice(0, -1) : input + _input;
updateFsSuggestions(targetInput, shouldUpdateSelection);
}
}
@@ -599,7 +691,10 @@ export default function TerminalChatInput({
);
text = text.trim();
const inputItem = await createInputItem(text, images);
// Expand @file tokens into XML blocks for the model
const expandedText = await expandFileTags(text);
const inputItem = await createInputItem(expandedText, images);
submitInput([inputItem]);
// Get config for history persistence.
@@ -673,28 +768,30 @@ export default function TerminalChatInput({
setHistoryIndex(null);
}
setInput(txt);
// Clear tab completions if a space is typed
if (txt.endsWith(" ")) {
setFsSuggestions([]);
setSelectedCompletion(-1);
} else if (fsSuggestions.length > 0) {
// Update file suggestions as user types
const words = txt.trim().split(/\s+/);
const mostRecentWord =
words.length > 0 ? words[words.length - 1] : "";
if (mostRecentWord !== undefined) {
setFsSuggestions(getFileSystemSuggestions(mostRecentWord));
}
}
}}
key={editorKey}
key={editorState.key}
initialCursorOffset={editorState.initialCursorOffset}
initialText={input}
height={6}
focus={active}
onSubmit={(txt) => {
onSubmit(txt);
setEditorKey((k) => k + 1);
// If final token is an @path, replace with filesystem suggestion if available
const {
text: replacedText,
suggestion,
wasReplaced,
} = getFileSystemSuggestion(txt, true);
// If we replaced @path token with a directory, don't submit
if (wasReplaced && suggestion?.isDirectory) {
applyFsSuggestion(replacedText);
// Update suggestions for the new directory
updateFsSuggestions(replacedText, true);
return;
}
onSubmit(replacedText);
setEditorState((s) => ({ key: s.key + 1 }));
setInput("");
setHistoryIndex(null);
setDraftInput("");
@@ -741,7 +838,7 @@ export default function TerminalChatInput({
</Text>
) : fsSuggestions.length > 0 ? (
<TextCompletions
completions={fsSuggestions}
completions={fsSuggestions.map((suggestion) => suggestion.path)}
selectedCompletion={selectedCompletion}
displayLimit={5}
/>

View File

@@ -10,6 +10,7 @@ import type {
} from "openai/resources/responses/responses";
import { useTerminalSize } from "../../hooks/use-terminal-size";
import { collapseXmlBlocks } from "../../utils/file-tag-utils";
import { parseToolCall, parseToolCallOutput } from "../../utils/parsers";
import chalk, { type ForegroundColorName } from "chalk";
import { Box, Text } from "ink";
@@ -137,7 +138,7 @@ function TerminalChatResponseMessage({
: c.type === "refusal"
? c.refusal
: c.type === "input_text"
? c.text
? collapseXmlBlocks(c.text)
: c.type === "input_image"
? "<Image>"
: c.type === "input_file"

View File

@@ -100,11 +100,14 @@ export default class TextBuffer {
private clipboard: string | null = null;
constructor(text = "") {
constructor(text = "", initialCursorIdx = 0) {
this.lines = text.split("\n");
if (this.lines.length === 0) {
this.lines = [""];
}
// No need to reset cursor on failure - class already default cursor position to 0,0
this.setCursorIdx(initialCursorIdx);
}
/* =======================================================================
@@ -122,6 +125,39 @@ export default class TextBuffer {
this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow));
}
/**
* Sets the cursor position based on a character offset from the start of the document.
* @param idx The character offset to move to (0-based)
* @returns true if successful, false if the index was invalid
*/
private setCursorIdx(idx: number): boolean {
// Reset preferred column since this is an explicit horizontal movement
this.preferredCol = null;
let remainingChars = idx;
let row = 0;
// Count characters line by line until we find the right position
while (row < this.lines.length) {
const lineLength = this.lineLen(row);
// Add 1 for the newline character (except for the last line)
const totalChars = lineLength + (row < this.lines.length - 1 ? 1 : 0);
if (remainingChars <= lineLength) {
this.cursorRow = row;
this.cursorCol = remainingChars;
return true;
}
// Move to next line, subtract this line's characters plus newline
remainingChars -= totalChars;
row++;
}
// If we get here, the index was too large
return false;
}
/* =====================================================================
* History helpers
* =================================================================== */
@@ -489,6 +525,22 @@ export default class TextBuffer {
end++;
}
/*
* After consuming the actual word we also want to swallow any immediate
* separator run that *follows* it so that a forward word-delete mirrors
* the behaviour of common shells/editors (and matches the expectations
* encoded in our test-suite).
*
* Example given the text "foo bar baz" and the caret placed at the
* beginning of "bar" (index 4) we want Alt+Delete to turn the string
* into "foo␠baz" (single space). Without this extra loop we would stop
* right before the separating space, producing "foo␠␠baz".
*/
while (end < arr.length && !isWordChar(arr[end])) {
end++;
}
this.lines[this.cursorRow] =
cpSlice(line, 0, this.cursorCol) + cpSlice(line, end);
// caret stays in place
@@ -823,12 +875,42 @@ export default class TextBuffer {
// no `key.backspace` flag set. Treat that byte exactly like an ordinary
// Backspace for parity with textarea.rs and to make interactive tests
// feedable through the simpler `(ch, {}, vp)` path.
// ------------------------------------------------------------------
// Word-wise deletions
//
// macOS (and many terminals on Linux/BSD) map the physical “Delete” key
// to a *backspace* operation emitting either the raw DEL (0x7f) byte
// or setting `key.backspace = true` in Inks parsed event. Holding the
// Option/Alt modifier therefore *also* sends backspace semantics even
// though users colloquially refer to the shortcut as “⌥+Delete”.
//
// Historically we treated **modifier + Delete** as a *forward* word
// deletion. This behaviour, however, diverges from the default found
// in shells (zsh, bash, fish, etc.) and native macOS text fields where
// ⌥+Delete removes the word *to the left* of the caret. Update the
// mapping so that both
//
// • ⌥/Alt/Meta + Backspace and
// • ⌥/Alt/Meta + Delete
//
// perform a **backward** word deletion. We keep the ability to delete
// the *next* word by requiring an additional Shift modifier a common
// binding on full-size keyboards that expose a dedicated Forward Delete
// key.
// ------------------------------------------------------------------
else if (
// ⌥/Alt/Meta + (Backspace|Delete|DEL byte) → backward word delete
(key["meta"] || key["ctrl"] || key["alt"]) &&
(key["backspace"] || input === "\x7f")
!key["shift"] &&
(key["backspace"] || input === "\x7f" || key["delete"])
) {
this.deleteWordLeft();
} else if ((key["meta"] || key["ctrl"] || key["alt"]) && key["delete"]) {
} else if (
// ⇧+⌥/Alt/Meta + (Backspace|Delete|DEL byte) → forward word delete
(key["meta"] || key["ctrl"] || key["alt"]) &&
key["shift"] &&
(key["backspace"] || input === "\x7f" || key["delete"])
) {
this.deleteWordRight();
} else if (
key["backspace"] ||

View File

@@ -29,6 +29,7 @@ import {
setSessionId,
} from "../session.js";
import { handleExecCommand } from "./handle-exec-command.js";
import { HttpsProxyAgent } from "https-proxy-agent";
import { randomUUID } from "node:crypto";
import OpenAI, { APIConnectionTimeoutError } from "openai";
@@ -38,6 +39,9 @@ const RATE_LIMIT_RETRY_WAIT_MS = parseInt(
10,
);
// See https://github.com/openai/openai-node/tree/v4?tab=readme-ov-file#configuring-an-https-agent-eg-for-proxies
const PROXY_URL = process.env["HTTPS_PROXY"];
export type CommandConfirmation = {
review: ReviewDecision;
applyPatch?: ApplyPatchCommand | undefined;
@@ -314,6 +318,7 @@ export class AgentLoop {
: {}),
...(OPENAI_PROJECT ? { "OpenAI-Project": OPENAI_PROJECT } : {}),
},
httpAgent: PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined,
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
});
@@ -1137,7 +1142,7 @@ export class AgentLoop {
content: [
{
type: "input_text",
text: "⚠️ Insufficient quota. Please check your billing details and retry.",
text: `\u26a0 Insufficient quota: ${err instanceof Error && err.message ? err.message.trim() : "No remaining quota."} Manage or purchase credits at https://platform.openai.com/account/billing.`,
},
],
});

View File

@@ -1,9 +1,11 @@
import type { AppConfig } from "../config.js";
import type { ExecInput, ExecResult } from "./sandbox/interface.js";
import type { SpawnOptions } from "child_process";
import type { ParseEntry } from "shell-quote";
import { process_patch } from "./apply-patch.js";
import { SandboxType } from "./sandbox/interface.js";
import { execWithLandlock } from "./sandbox/landlock.js";
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
import { exec as rawExec } from "./sandbox/raw-exec.js";
import { formatCommandForDisplay } from "../../format-command.js";
@@ -40,26 +42,39 @@ export function exec(
additionalWritableRoots,
}: ExecInput & { additionalWritableRoots: ReadonlyArray<string> },
sandbox: SandboxType,
config: AppConfig,
abortSignal?: AbortSignal,
): Promise<ExecResult> {
// This is a temporary measure to understand what are the common base commands
// until we start persisting and uploading rollouts
const execForSandbox =
sandbox === SandboxType.MACOS_SEATBELT ? execWithSeatbelt : rawExec;
const opts: SpawnOptions = {
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
...(requiresShell(cmd) ? { shell: true } : {}),
...(workdir ? { cwd: workdir } : {}),
};
// Merge default writable roots with any user-specified ones.
const writableRoots = [
process.cwd(),
os.tmpdir(),
...additionalWritableRoots,
];
return execForSandbox(cmd, opts, writableRoots, abortSignal);
switch (sandbox) {
case SandboxType.NONE: {
// SandboxType.NONE uses the raw exec implementation.
return rawExec(cmd, opts, config, abortSignal);
}
case SandboxType.MACOS_SEATBELT: {
// Merge default writable roots with any user-specified ones.
const writableRoots = [
process.cwd(),
os.tmpdir(),
...additionalWritableRoots,
];
return execWithSeatbelt(cmd, opts, writableRoots, config, abortSignal);
}
case SandboxType.LINUX_LANDLOCK: {
return execWithLandlock(
cmd,
opts,
additionalWritableRoots,
config,
abortSignal,
);
}
}
}
export function execApplyPatch(

View File

@@ -94,6 +94,7 @@ export async function handleExecCommand(
/* applyPatch */ undefined,
/* runInSandbox */ false,
additionalWritableRoots,
config,
abortSignal,
).then(convertSummaryToResult);
}
@@ -142,6 +143,7 @@ export async function handleExecCommand(
applyPatch,
runInSandbox,
additionalWritableRoots,
config,
abortSignal,
);
// If the operation was aborted in the meantime, propagate the cancellation
@@ -179,6 +181,7 @@ export async function handleExecCommand(
applyPatch,
false,
additionalWritableRoots,
config,
abortSignal,
);
return convertSummaryToResult(summary);
@@ -213,6 +216,7 @@ async function execCommand(
applyPatchCommand: ApplyPatchCommand | undefined,
runInSandbox: boolean,
additionalWritableRoots: ReadonlyArray<string>,
config: AppConfig,
abortSignal?: AbortSignal,
): Promise<ExecCommandSummary> {
let { workdir } = execInput;
@@ -252,6 +256,7 @@ async function execCommand(
: await exec(
{ ...execInput, additionalWritableRoots },
await getSandbox(runInSandbox),
config,
abortSignal,
);
const duration = Date.now() - start;
@@ -303,6 +308,11 @@ async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
);
}
} else if (process.platform === "linux") {
// TODO: Need to verify that the Landlock sandbox is working. For example,
// using Landlock in a Linux Docker container from a macOS host may not
// work.
return SandboxType.LINUX_LANDLOCK;
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
// Allow running without a sandbox if the user has explicitly marked the
// environment as already being sufficiently locked-down.

View File

@@ -1,7 +1,6 @@
// Maximum output cap: either MAX_OUTPUT_LINES lines or MAX_OUTPUT_BYTES bytes,
// whichever limit is reached first.
const MAX_OUTPUT_BYTES = 1024 * 10; // 10 KB
const MAX_OUTPUT_LINES = 256;
import { DEFAULT_SHELL_MAX_BYTES, DEFAULT_SHELL_MAX_LINES } from "../../config";
/**
* Creates a collector that accumulates data Buffers from a stream up to
@@ -10,8 +9,8 @@ const MAX_OUTPUT_LINES = 256;
*/
export function createTruncatingCollector(
stream: NodeJS.ReadableStream,
byteLimit: number = MAX_OUTPUT_BYTES,
lineLimit: number = MAX_OUTPUT_LINES,
byteLimit: number = DEFAULT_SHELL_MAX_BYTES,
lineLimit: number = DEFAULT_SHELL_MAX_LINES,
): {
getString: () => string;
hit: boolean;

View File

@@ -0,0 +1,175 @@
import type { ExecResult } from "./interface.js";
import type { AppConfig } from "../../config.js";
import type { SpawnOptions } from "child_process";
import { exec } from "./raw-exec.js";
import { execFile } from "child_process";
import fs from "fs";
import path from "path";
import { log } from "src/utils/logger/log.js";
import { fileURLToPath } from "url";
/**
* Runs Landlock with the following permissions:
* - can read any file on disk
* - can write to process.cwd()
* - can write to the platform user temp folder
* - can write to any user-provided writable root
*/
export async function execWithLandlock(
cmd: Array<string>,
opts: SpawnOptions,
userProvidedWritableRoots: ReadonlyArray<string>,
config: AppConfig,
abortSignal?: AbortSignal,
): Promise<ExecResult> {
const sandboxExecutable = await getSandboxExecutable();
const extraSandboxPermissions = userProvidedWritableRoots.flatMap(
(root: string) => ["--sandbox-permission", `disk-write-folder=${root}`],
);
const fullCommand = [
sandboxExecutable,
"--sandbox-permission",
"disk-full-read-access",
"--sandbox-permission",
"disk-write-cwd",
"--sandbox-permission",
"disk-write-platform-user-temp-folder",
...extraSandboxPermissions,
"--",
...cmd,
];
return exec(fullCommand, opts, config, abortSignal);
}
/**
* Lazily initialized promise that resolves to the absolute path of the
* architecture-specific Landlock helper binary.
*/
let sandboxExecutablePromise: Promise<string> | null = null;
async function detectSandboxExecutable(): Promise<string> {
// Find the executable relative to the package.json file.
const __filename = fileURLToPath(import.meta.url);
let dir: string = path.dirname(__filename);
// Ascend until package.json is found or we reach the filesystem root.
// eslint-disable-next-line no-constant-condition
while (true) {
try {
// eslint-disable-next-line no-await-in-loop
await fs.promises.access(
path.join(dir, "package.json"),
fs.constants.F_OK,
);
break; // Found the package.json ⇒ dir is our project root.
} catch {
// keep searching
}
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error("Unable to locate package.json");
}
dir = parent;
}
const sandboxExecutable = getLinuxSandboxExecutableForCurrentArchitecture();
const candidate = path.join(dir, "bin", sandboxExecutable);
try {
await fs.promises.access(candidate, fs.constants.X_OK);
} catch {
throw new Error(`${candidate} not found or not executable`);
}
// Will throw if the executable is not working in this environment.
await verifySandboxExecutable(candidate);
return candidate;
}
const ERROR_WHEN_LANDLOCK_NOT_SUPPORTED = `\
The combination of seccomp/landlock that Codex uses for sandboxing is not
supported in this environment.
If you are running in a Docker container, you may want to try adding
restrictions to your Docker container such that it provides your desired
sandboxing guarantees and then run Codex with the
--dangerously-auto-approve-everything option inside the container.
If you are running on an older Linux kernel that does not support newer
features of seccomp/landlock, you will have to update your kernel to a newer
version.
`;
/**
* Now that we have the path to the executable, make sure that it works in
* this environment. For example, when running a Linux Docker container from
* macOS like so:
*
* docker run -it alpine:latest /bin/sh
*
* Running `codex-linux-sandbox-x64 -- true` in the container fails with:
*
* ```
* Error: sandbox error: seccomp setup error
*
* Caused by:
* 0: seccomp setup error
* 1: Error calling `seccomp`: Invalid argument (os error 22)
* 2: Invalid argument (os error 22)
* ```
*/
function verifySandboxExecutable(sandboxExecutable: string): Promise<void> {
// Note we are running `true` rather than `bash -lc true` because we want to
// ensure we run an executable, not a shell built-in. Note that `true` should
// always be available in a POSIX environment.
return new Promise((resolve, reject) => {
const args = ["--", "true"];
execFile(sandboxExecutable, args, (error, stdout, stderr) => {
if (error) {
log(
`Sandbox check failed for ${sandboxExecutable} ${args.join(" ")}: ${error}`,
);
log(`stdout: ${stdout}`);
log(`stderr: ${stderr}`);
reject(new Error(ERROR_WHEN_LANDLOCK_NOT_SUPPORTED));
} else {
resolve();
}
});
});
}
/**
* Returns the absolute path to the architecture-specific Landlock helper
* binary. (Could be a rejected promise if not found.)
*/
function getSandboxExecutable(): Promise<string> {
if (!sandboxExecutablePromise) {
sandboxExecutablePromise = detectSandboxExecutable();
}
return sandboxExecutablePromise;
}
/** @return name of the native executable to use for Linux sandboxing. */
function getLinuxSandboxExecutableForCurrentArchitecture(): string {
switch (process.arch) {
case "arm64":
return "codex-linux-sandbox-arm64";
case "x64":
return "codex-linux-sandbox-x64";
// Fall back to the x86_64 build for anything else it will obviously
// fail on incompatible systems but gives a sane error message rather
// than crashing earlier.
default:
return "codex-linux-sandbox-x64";
}
}

View File

@@ -1,4 +1,5 @@
import type { ExecResult } from "./interface.js";
import type { AppConfig } from "../../config.js";
import type { SpawnOptions } from "child_process";
import { exec } from "./raw-exec.js";
@@ -24,6 +25,7 @@ export function execWithSeatbelt(
cmd: Array<string>,
opts: SpawnOptions,
writableRoots: ReadonlyArray<string>,
config: AppConfig,
abortSignal?: AbortSignal,
): Promise<ExecResult> {
let scopedWritePolicy: string;
@@ -72,7 +74,7 @@ export function execWithSeatbelt(
"--",
...cmd,
];
return exec(fullCommand, opts, writableRoots, abortSignal);
return exec(fullCommand, opts, config, abortSignal);
}
const READ_ONLY_SEATBELT_POLICY = `

View File

@@ -1,4 +1,5 @@
import type { ExecResult } from "./interface";
import type { AppConfig } from "../../config";
import type {
ChildProcess,
SpawnOptions,
@@ -20,7 +21,7 @@ import * as os from "os";
export function exec(
command: Array<string>,
options: SpawnOptions,
_writableRoots: ReadonlyArray<string>,
config: AppConfig,
abortSignal?: AbortSignal,
): Promise<ExecResult> {
// Adapt command for the current platform (e.g., convert 'ls' to 'dir' on Windows)
@@ -143,9 +144,21 @@ export function exec(
// ExecResult object so the rest of the agent loop can carry on gracefully.
return new Promise<ExecResult>((resolve) => {
// Get shell output limits from config if available
const maxBytes = config?.tools?.shell?.maxBytes;
const maxLines = config?.tools?.shell?.maxLines;
// Collect stdout and stderr up to configured limits.
const stdoutCollector = createTruncatingCollector(child.stdout!);
const stderrCollector = createTruncatingCollector(child.stderr!);
const stdoutCollector = createTruncatingCollector(
child.stdout!,
maxBytes,
maxLines,
);
const stderrCollector = createTruncatingCollector(
child.stderr!,
maxBytes,
maxLines,
);
child.on("exit", (code, signal) => {
const stdout = stdoutCollector.getString();

View File

@@ -48,6 +48,10 @@ export const DEFAULT_FULL_CONTEXT_MODEL = "gpt-4.1";
export const DEFAULT_APPROVAL_MODE = AutoApprovalMode.SUGGEST;
export const DEFAULT_INSTRUCTIONS = "";
// Default shell output limits
export const DEFAULT_SHELL_MAX_BYTES = 1024 * 10; // 10 KB
export const DEFAULT_SHELL_MAX_LINES = 256;
export const CONFIG_DIR = join(homedir(), ".codex");
export const CONFIG_JSON_FILEPATH = join(CONFIG_DIR, "config.json");
export const CONFIG_YAML_FILEPATH = join(CONFIG_DIR, "config.yaml");
@@ -145,6 +149,12 @@ export type StoredConfig = {
saveHistory?: boolean;
sensitivePatterns?: Array<string>;
};
tools?: {
shell?: {
maxBytes?: number;
maxLines?: number;
};
};
/** User-defined safe commands */
safeCommands?: Array<string>;
reasoningEffort?: ReasoningEffort;
@@ -186,6 +196,12 @@ export type AppConfig = {
saveHistory: boolean;
sensitivePatterns: Array<string>;
};
tools?: {
shell?: {
maxBytes: number;
maxLines: number;
};
};
};
// Formatting (quiet mode-only).
@@ -388,6 +404,14 @@ export const loadConfig = (
instructions: combinedInstructions,
notify: storedConfig.notify === true,
approvalMode: storedConfig.approvalMode,
tools: {
shell: {
maxBytes:
storedConfig.tools?.shell?.maxBytes ?? DEFAULT_SHELL_MAX_BYTES,
maxLines:
storedConfig.tools?.shell?.maxLines ?? DEFAULT_SHELL_MAX_LINES,
},
},
disableResponseStorage: storedConfig.disableResponseStorage === true,
reasoningEffort: storedConfig.reasoningEffort,
};
@@ -517,6 +541,18 @@ export const saveConfig = (
};
}
// Add tools settings if they exist
if (config.tools) {
configToSave.tools = {
shell: config.tools.shell
? {
maxBytes: config.tools.shell.maxBytes,
maxLines: config.tools.shell.maxLines,
}
: undefined,
};
}
if (ext === ".yaml" || ext === ".yml") {
writeFileSync(targetPath, dumpYaml(configToSave), "utf-8");
} else {

View File

@@ -2,7 +2,24 @@ import fs from "fs";
import os from "os";
import path from "path";
export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
/**
* Represents a file system suggestion with path and directory information
*/
export interface FileSystemSuggestion {
/** The full path of the suggestion */
path: string;
/** Whether the suggestion is a directory */
isDirectory: boolean;
}
/**
* Gets file system suggestions based on a path prefix
* @param pathPrefix The path prefix to search for
* @returns Array of file system suggestions
*/
export function getFileSystemSuggestions(
pathPrefix: string,
): Array<FileSystemSuggestion> {
if (!pathPrefix) {
return [];
}
@@ -31,10 +48,10 @@ export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
.map((item) => {
const fullPath = path.join(readDir, item);
const isDirectory = fs.statSync(fullPath).isDirectory();
if (isDirectory) {
return path.join(fullPath, sep);
}
return fullPath;
return {
path: isDirectory ? path.join(fullPath, sep) : fullPath,
isDirectory,
};
});
} catch {
return [];

View File

@@ -0,0 +1,62 @@
import fs from "fs";
import path from "path";
/**
* Replaces @path tokens in the input string with <path>file contents</path> XML blocks for LLM context.
* Only replaces if the path points to a file; directories are ignored.
*/
export async function expandFileTags(raw: string): Promise<string> {
const re = /@([\w./~-]+)/g;
let out = raw;
type MatchInfo = { index: number; length: number; path: string };
const matches: Array<MatchInfo> = [];
for (const m of raw.matchAll(re) as IterableIterator<RegExpMatchArray>) {
const idx = m.index;
const captured = m[1];
if (idx !== undefined && captured) {
matches.push({ index: idx, length: m[0].length, path: captured });
}
}
// Process in reverse to avoid index shifting.
for (let i = matches.length - 1; i >= 0; i--) {
const { index, length, path: p } = matches[i]!;
const resolved = path.resolve(process.cwd(), p);
try {
const st = fs.statSync(resolved);
if (st.isFile()) {
const content = fs.readFileSync(resolved, "utf-8");
const rel = path.relative(process.cwd(), resolved);
const xml = `<${rel}>\n${content}\n</${rel}>`;
out = out.slice(0, index) + xml + out.slice(index + length);
}
} catch {
// If path invalid, leave token as is
}
}
return out;
}
/**
* Collapses <path>content</path> XML blocks back to @path format.
* This is the reverse operation of expandFileTags.
* Only collapses blocks where the path points to a valid file; invalid paths remain unchanged.
*/
export function collapseXmlBlocks(text: string): string {
return text.replace(
/<([^\n>]+)>([\s\S]*?)<\/\1>/g,
(match, path1: string) => {
const filePath = path.normalize(path1.trim());
try {
// Only convert to @path format if it's a valid file
return fs.statSync(path.resolve(process.cwd(), filePath)).isFile()
? "@" + filePath
: match;
} catch {
return match; // Keep XML block if path is invalid
}
},
);
}

View File

@@ -1,4 +1,9 @@
export const CLI_VERSION = "0.1.2504251709"; // Must be in sync with package.json.
// Node ESM supports JSON imports behind an assertion. TypeScript's
// `resolveJsonModule` takes care of the typings.
import pkg from "../../package.json" assert { type: "json" };
// Read the version directly from package.json.
export const CLI_VERSION: string = (pkg as { version: string }).version;
export const ORIGIN = "codex_cli_ts";
export type TerminalChatSession = {

View File

@@ -1,5 +1,6 @@
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
import { describe, it, expect } from "vitest";
import type { AppConfig } from "src/utils/config.js";
// Import the lowlevel exec implementation so we can verify that AbortSignal
// correctly terminates a spawned process. We bypass the higherlevel wrappers
@@ -12,9 +13,13 @@ describe("exec cancellation", () => {
// Spawn a node process that would normally run for 5 seconds before
// printing anything. We should abort long before that happens.
const cmd = ["node", "-e", "setTimeout(() => console.log('late'), 5000);"];
const config: AppConfig = {
model: "test-model",
instructions: "test-instructions",
};
const start = Date.now();
const promise = rawExec(cmd, {}, [], abortController.signal);
const promise = rawExec(cmd, {}, config, abortController.signal);
// Abort almost immediately.
abortController.abort();
@@ -36,9 +41,14 @@ describe("exec cancellation", () => {
it("allows the process to finish when not aborted", async () => {
const abortController = new AbortController();
const config: AppConfig = {
model: "test-model",
instructions: "test-instructions",
};
const cmd = ["node", "-e", "console.log('finished')"];
const result = await rawExec(cmd, {}, [], abortController.signal);
const result = await rawExec(cmd, {}, config, abortController.signal);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe("finished");

View File

@@ -1,6 +1,11 @@
import type * as fsType from "fs";
import { loadConfig, saveConfig } from "../src/utils/config.js"; // parent import first
import {
loadConfig,
saveConfig,
DEFAULT_SHELL_MAX_BYTES,
DEFAULT_SHELL_MAX_LINES,
} from "../src/utils/config.js";
import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
import { tmpdir } from "os";
import { join } from "path";
@@ -275,3 +280,84 @@ test("handles empty user instructions when saving with project doc separator", (
});
expect(loadedConfig.instructions).toBe("");
});
test("loads default shell config when not specified", () => {
// Setup config without shell settings
memfs[testConfigPath] = JSON.stringify(
{
model: "mymodel",
},
null,
2,
);
memfs[testInstructionsPath] = "test instructions";
// Load config and verify default shell settings
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
// Check shell settings were loaded with defaults
expect(loadedConfig.tools).toBeDefined();
expect(loadedConfig.tools?.shell).toBeDefined();
expect(loadedConfig.tools?.shell?.maxBytes).toBe(DEFAULT_SHELL_MAX_BYTES);
expect(loadedConfig.tools?.shell?.maxLines).toBe(DEFAULT_SHELL_MAX_LINES);
});
test("loads and saves custom shell config", () => {
// Setup config with custom shell settings
const customMaxBytes = 12_410;
const customMaxLines = 500;
memfs[testConfigPath] = JSON.stringify(
{
model: "mymodel",
tools: {
shell: {
maxBytes: customMaxBytes,
maxLines: customMaxLines,
},
},
},
null,
2,
);
memfs[testInstructionsPath] = "test instructions";
// Load config and verify custom shell settings
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
// Check shell settings were loaded correctly
expect(loadedConfig.tools?.shell?.maxBytes).toBe(customMaxBytes);
expect(loadedConfig.tools?.shell?.maxLines).toBe(customMaxLines);
// Modify shell settings and save
const updatedMaxBytes = 20_000;
const updatedMaxLines = 1_000;
const updatedConfig = {
...loadedConfig,
tools: {
shell: {
maxBytes: updatedMaxBytes,
maxLines: updatedMaxLines,
},
},
};
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
// Verify saved config contains updated shell settings
expect(memfs[testConfigPath]).toContain(`"maxBytes": ${updatedMaxBytes}`);
expect(memfs[testConfigPath]).toContain(`"maxLines": ${updatedMaxLines}`);
// Load again and verify updated values
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
disableProjectDoc: true,
});
expect(reloadedConfig.tools?.shell?.maxBytes).toBe(updatedMaxBytes);
expect(reloadedConfig.tools?.shell?.maxLines).toBe(updatedMaxLines);
});

View File

@@ -36,8 +36,14 @@ describe("getFileSystemSuggestions", () => {
expect(mockFs.readdirSync).toHaveBeenCalledWith("/home/testuser");
expect(result).toEqual([
path.join("/home/testuser", "file1.txt"),
path.join("/home/testuser", "docs" + path.sep),
{
path: path.join("/home/testuser", "file1.txt"),
isDirectory: false,
},
{
path: path.join("/home/testuser", "docs" + path.sep),
isDirectory: true,
},
]);
});
@@ -48,7 +54,16 @@ describe("getFileSystemSuggestions", () => {
}));
const result = getFileSystemSuggestions("a");
expect(result).toEqual(["abc.txt", "abd.txt/"]);
expect(result).toEqual([
{
path: "abc.txt",
isDirectory: false,
},
{
path: "abd.txt/",
isDirectory: true,
},
]);
});
it("handles errors gracefully", () => {
@@ -67,7 +82,11 @@ describe("getFileSystemSuggestions", () => {
}));
const result = getFileSystemSuggestions("./");
expect(result).toContain("foo/");
expect(result).toContain("bar/");
const paths = result.map((item) => item.path);
const allDirectories = result.every((item) => item.isDirectory === true);
expect(paths).toContain("foo/");
expect(paths).toContain("bar/");
expect(allDirectories).toBe(true);
});
});

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import fs from "fs";
import path from "path";
import os from "os";
import {
expandFileTags,
collapseXmlBlocks,
} from "../src/utils/file-tag-utils.js";
/**
* Unit-tests for file tag utility functions:
* - expandFileTags(): Replaces tokens like `@relative/path` with XML blocks containing file contents
* - collapseXmlBlocks(): Reverses the expansion, converting XML blocks back to @path format
*/
describe("expandFileTags", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-test-"));
const originalCwd = process.cwd();
beforeAll(() => {
// Run the test from within the temporary directory so that the helper
// generates relative paths that are predictable and isolated.
process.chdir(tmpDir);
});
afterAll(() => {
process.chdir(originalCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("replaces @file token with XML wrapped contents", async () => {
const filename = "hello.txt";
const fileContent = "Hello, world!";
fs.writeFileSync(path.join(tmpDir, filename), fileContent);
const input = `Please read @${filename}`;
const output = await expandFileTags(input);
expect(output).toContain(`<${filename}>`);
expect(output).toContain(fileContent);
expect(output).toContain(`</${filename}>`);
});
it("leaves token unchanged when file does not exist", async () => {
const input = "This refers to @nonexistent.file";
const output = await expandFileTags(input);
expect(output).toEqual(input);
});
it("handles multiple @file tokens in one string", async () => {
const fileA = "a.txt";
const fileB = "b.txt";
fs.writeFileSync(path.join(tmpDir, fileA), "A content");
fs.writeFileSync(path.join(tmpDir, fileB), "B content");
const input = `@${fileA} and @${fileB}`;
const output = await expandFileTags(input);
expect(output).toContain("A content");
expect(output).toContain("B content");
expect(output).toContain(`<${fileA}>`);
expect(output).toContain(`<${fileB}>`);
});
it("does not replace @dir if it's a directory", async () => {
const dirName = "somedir";
fs.mkdirSync(path.join(tmpDir, dirName));
const input = `Check @${dirName}`;
const output = await expandFileTags(input);
expect(output).toContain(`@${dirName}`);
});
it("handles @file with special characters in name", async () => {
const fileName = "weird-._~name.txt";
fs.writeFileSync(path.join(tmpDir, fileName), "special chars");
const input = `@${fileName}`;
const output = await expandFileTags(input);
expect(output).toContain("special chars");
expect(output).toContain(`<${fileName}>`);
});
it("handles repeated @file tokens", async () => {
const fileName = "repeat.txt";
fs.writeFileSync(path.join(tmpDir, fileName), "repeat content");
const input = `@${fileName} @${fileName}`;
const output = await expandFileTags(input);
// Both tags should be replaced
expect(output.match(new RegExp(`<${fileName}>`, "g"))?.length).toBe(2);
});
it("handles empty file", async () => {
const fileName = "empty.txt";
fs.writeFileSync(path.join(tmpDir, fileName), "");
const input = `@${fileName}`;
const output = await expandFileTags(input);
expect(output).toContain(`<${fileName}>\n\n</${fileName}>`);
});
it("handles string with no @file tokens", async () => {
const input = "No tags here.";
const output = await expandFileTags(input);
expect(output).toBe(input);
});
});
describe("collapseXmlBlocks", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-collapse-test-"));
const originalCwd = process.cwd();
beforeAll(() => {
// Run the test from within the temporary directory so that the helper
// generates relative paths that are predictable and isolated.
process.chdir(tmpDir);
});
afterAll(() => {
process.chdir(originalCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("collapses XML block to @path format for valid file", () => {
// Create a real file
const fileName = "valid-file.txt";
fs.writeFileSync(path.join(tmpDir, fileName), "file content");
const input = `<${fileName}>\nHello, world!\n</${fileName}>`;
const output = collapseXmlBlocks(input);
expect(output).toBe(`@${fileName}`);
});
it("does not collapse XML block for unrelated xml block", () => {
const xmlBlockName = "non-file-block";
const input = `<${xmlBlockName}>\nContent here\n</${xmlBlockName}>`;
const output = collapseXmlBlocks(input);
// Should remain unchanged
expect(output).toBe(input);
});
it("does not collapse XML block for a directory", () => {
// Create a directory
const dirName = "test-dir";
fs.mkdirSync(path.join(tmpDir, dirName), { recursive: true });
const input = `<${dirName}>\nThis is a directory\n</${dirName}>`;
const output = collapseXmlBlocks(input);
// Should remain unchanged
expect(output).toBe(input);
});
it("collapses multiple valid file XML blocks in one string", () => {
// Create real files
const fileA = "a.txt";
const fileB = "b.txt";
fs.writeFileSync(path.join(tmpDir, fileA), "A content");
fs.writeFileSync(path.join(tmpDir, fileB), "B content");
const input = `<${fileA}>\nA content\n</${fileA}> and <${fileB}>\nB content\n</${fileB}>`;
const output = collapseXmlBlocks(input);
expect(output).toBe(`@${fileA} and @${fileB}`);
});
it("only collapses valid file paths in mixed content", () => {
// Create a real file
const validFile = "valid.txt";
fs.writeFileSync(path.join(tmpDir, validFile), "valid content");
const invalidFile = "invalid.txt";
const input = `<${validFile}>\nvalid content\n</${validFile}> and <${invalidFile}>\ninvalid content\n</${invalidFile}>`;
const output = collapseXmlBlocks(input);
expect(output).toBe(
`@${validFile} and <${invalidFile}>\ninvalid content\n</${invalidFile}>`,
);
});
it("handles paths with subdirectories for valid files", () => {
// Create a nested file
const nestedDir = "nested/path";
const nestedFile = "nested/path/file.txt";
fs.mkdirSync(path.join(tmpDir, nestedDir), { recursive: true });
fs.writeFileSync(path.join(tmpDir, nestedFile), "nested content");
const relPath = "nested/path/file.txt";
const input = `<${relPath}>\nContent here\n</${relPath}>`;
const output = collapseXmlBlocks(input);
const expectedPath = path.normalize(relPath);
expect(output).toBe(`@${expectedPath}`);
});
it("handles XML blocks with special characters in path for valid files", () => {
// Create a file with special characters
const specialFileName = "weird-._~name.txt";
fs.writeFileSync(path.join(tmpDir, specialFileName), "special chars");
const input = `<${specialFileName}>\nspecial chars\n</${specialFileName}>`;
const output = collapseXmlBlocks(input);
expect(output).toBe(`@${specialFileName}`);
});
it("handles XML blocks with empty content for valid files", () => {
// Create an empty file
const emptyFileName = "empty.txt";
fs.writeFileSync(path.join(tmpDir, emptyFileName), "");
const input = `<${emptyFileName}>\n\n</${emptyFileName}>`;
const output = collapseXmlBlocks(input);
expect(output).toBe(`@${emptyFileName}`);
});
it("handles string with no XML blocks", () => {
const input = "No tags here.";
const output = collapseXmlBlocks(input);
expect(output).toBe(input);
});
it("handles adjacent XML blocks for valid files", () => {
// Create real files
const adjFile1 = "adj1.txt";
const adjFile2 = "adj2.txt";
fs.writeFileSync(path.join(tmpDir, adjFile1), "adj1");
fs.writeFileSync(path.join(tmpDir, adjFile2), "adj2");
const input = `<${adjFile1}>\nadj1\n</${adjFile1}><${adjFile2}>\nadj2\n</${adjFile2}>`;
const output = collapseXmlBlocks(input);
expect(output).toBe(`@${adjFile1}@${adjFile2}`);
});
it("ignores malformed XML blocks", () => {
const input = "<incomplete>content without closing tag";
const output = collapseXmlBlocks(input);
expect(output).toBe(input);
});
it("handles mixed content with valid file XML blocks and regular text", () => {
// Create a real file
const mixedFile = "mixed-file.txt";
fs.writeFileSync(path.join(tmpDir, mixedFile), "file content");
const input = `This is <${mixedFile}>\nfile content\n</${mixedFile}> and some more text.`;
const output = collapseXmlBlocks(input);
expect(output).toBe(`This is @${mixedFile} and some more text.`);
});
});

View File

@@ -5,12 +5,12 @@ import { describe, it, expect, vi } from "vitest";
// ---------------------------------------------------------------------------
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
import type { AppConfig } from "../src/utils/config.js";
describe("rawExec invalid command handling", () => {
it("resolves with nonzero exit code when executable is missing", async () => {
const cmd = ["definitely-not-a-command-1234567890"];
const result = await rawExec(cmd, {}, []);
const config = { model: "any", instructions: "" } as AppConfig;
const result = await rawExec(cmd, {}, config);
expect(result.exitCode).not.toBe(0);
expect(result.stderr.length).toBeGreaterThan(0);

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
import type { AppConfig } from "src/utils/config.js";
// Regression test: When cancelling an inflight `rawExec()` the implementation
// must terminate *all* processes that belong to the spawned command not just
@@ -27,13 +28,17 @@ describe("rawExec abort kills entire process group", () => {
// Bash script: spawn `sleep 30` in background, print its PID, then wait.
const script = "sleep 30 & pid=$!; echo $pid; wait $pid";
const cmd = ["bash", "-c", script];
const config: AppConfig = {
model: "test-model",
instructions: "test-instructions",
};
// Start a bash shell that:
// - spawns a background `sleep 30`
// - prints the PID of the `sleep`
// - waits for `sleep` to exit
const { stdout, exitCode } = await (async () => {
const p = rawExec(cmd, {}, [], abortController.signal);
const p = rawExec(cmd, {}, config, abortController.signal);
// Give Bash a tiny bit of time to start and print the PID.
await new Promise((r) => setTimeout(r, 100));

View File

@@ -0,0 +1,206 @@
import React from "react";
import type { ComponentProps } from "react";
import { renderTui } from "./ui-test-helpers.js";
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
// Helper function for typing and flushing
async function type(
stdin: NodeJS.WritableStream,
text: string,
flush: () => Promise<void>,
) {
stdin.write(text);
await flush();
}
/**
* Helper to reliably trigger file system suggestions in tests.
*
* This function simulates typing '@' followed by Tab to ensure suggestions appear.
*
* In real usage, simply typing '@' does trigger suggestions correctly.
*/
async function typeFileTag(
stdin: NodeJS.WritableStream,
flush: () => Promise<void>,
) {
// Type @ character
stdin.write("@");
await flush();
stdin.write("\t");
await flush();
}
// Mock the file system suggestions utility
vi.mock("../src/utils/file-system-suggestions.js", () => ({
FileSystemSuggestion: class {}, // Mock the interface
getFileSystemSuggestions: vi.fn((pathPrefix: string) => {
const normalizedPrefix = pathPrefix.startsWith("./")
? pathPrefix.slice(2)
: pathPrefix;
const allItems = [
{ path: "file1.txt", isDirectory: false },
{ path: "file2.js", isDirectory: false },
{ path: "directory1/", isDirectory: true },
{ path: "directory2/", isDirectory: true },
];
return allItems.filter((item) => item.path.startsWith(normalizedPrefix));
}),
}));
// Mock the createInputItem function to avoid filesystem operations
vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: vi.fn(async (text: string) => ({
role: "user",
type: "message",
content: [{ type: "input_text", text }],
})),
}));
describe("TerminalChatInput file tag suggestions", () => {
// Standard props for all tests
const baseProps: ComponentProps<typeof TerminalChatInput> = {
isNew: false,
loading: false,
submitInput: vi.fn().mockImplementation(() => {}),
confirmationPrompt: null,
explanation: undefined,
submitConfirmation: vi.fn(),
setLastResponseId: vi.fn(),
setItems: vi.fn(),
contextLeftPercent: 50,
openOverlay: vi.fn(),
openDiffOverlay: vi.fn(),
openModelOverlay: vi.fn(),
openApprovalOverlay: vi.fn(),
openHelpOverlay: vi.fn(),
onCompact: vi.fn(),
interruptAgent: vi.fn(),
active: true,
thinkingSeconds: 0,
};
beforeEach(() => {
vi.clearAllMocks();
});
it("shows file system suggestions when typing @ alone", async () => {
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
<TerminalChatInput {...baseProps} />,
);
// Type @ and activate suggestions
await typeFileTag(stdin, flush);
// Check that current directory suggestions are shown
const frame = lastFrameStripped();
expect(frame).toContain("file1.txt");
cleanup();
});
it("completes the selected file system suggestion with Tab", async () => {
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
<TerminalChatInput {...baseProps} />,
);
// Type @ and activate suggestions
await typeFileTag(stdin, flush);
// Press Tab to select the first suggestion
await type(stdin, "\t", flush);
// Check that the input has been completed with the selected suggestion
const frameAfterTab = lastFrameStripped();
expect(frameAfterTab).toContain("@file1.txt");
// Check that the rest of the suggestions have collapsed
expect(frameAfterTab).not.toContain("file2.txt");
expect(frameAfterTab).not.toContain("directory2/");
expect(frameAfterTab).not.toContain("directory1/");
cleanup();
});
it("clears file system suggestions when typing a space", async () => {
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
<TerminalChatInput {...baseProps} />,
);
// Type @ and activate suggestions
await typeFileTag(stdin, flush);
// Check that suggestions are shown
let frame = lastFrameStripped();
expect(frame).toContain("file1.txt");
// Type a space to clear suggestions
await type(stdin, " ", flush);
// Check that suggestions are cleared
frame = lastFrameStripped();
expect(frame).not.toContain("file1.txt");
cleanup();
});
it("selects and retains directory when pressing Enter on directory suggestion", async () => {
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
<TerminalChatInput {...baseProps} />,
);
// Type @ and activate suggestions
await typeFileTag(stdin, flush);
// Navigate to directory suggestion (we need two down keys to get to the first directory)
await type(stdin, "\u001B[B", flush); // Down arrow key - move to file2.js
await type(stdin, "\u001B[B", flush); // Down arrow key - move to directory1/
// Check that the directory suggestion is selected
let frame = lastFrameStripped();
expect(frame).toContain("directory1/");
// Press Enter to select the directory
await type(stdin, "\r", flush);
// Check that the input now contains the directory path
frame = lastFrameStripped();
expect(frame).toContain("@directory1/");
// Check that submitInput was NOT called (since we're only navigating, not submitting)
expect(baseProps.submitInput).not.toHaveBeenCalled();
cleanup();
});
it("submits when pressing Enter on file suggestion", async () => {
const { stdin, flush, cleanup } = renderTui(
<TerminalChatInput {...baseProps} />,
);
// Type @ and activate suggestions
await typeFileTag(stdin, flush);
// Press Enter to select first suggestion (file1.txt)
await type(stdin, "\r", flush);
// Check that submitInput was called
expect(baseProps.submitInput).toHaveBeenCalled();
// Get the arguments passed to submitInput
const submitArgs = (baseProps.submitInput as any).mock.calls[0][0];
// Verify the first argument is an array with at least one item
expect(Array.isArray(submitArgs)).toBe(true);
expect(submitArgs.length).toBeGreaterThan(0);
// Check that the content includes the file path
const content = submitArgs[0].content;
expect(Array.isArray(content)).toBe(true);
expect(content.length).toBeGreaterThan(0);
expect(content[0].text).toContain("@file1.txt");
cleanup();
});
});

View File

@@ -43,18 +43,17 @@ describe("TextBuffer wordwise navigation & deletion", () => {
expect(tb.getText()).toBe("foo bar ");
});
test("Option/Alt+Delete deletes next word", () => {
test("Option/Alt+Delete deletes previous word (matches shells)", () => {
const tb = new TextBuffer("foo bar baz");
const vp = { height: 10, width: 80 } as const;
// Move caret between first and second word (after space)
tb.move("wordRight"); // after foo
tb.move("right"); // skip space -> start of bar
// Place caret at end so we can test backward deletion.
tb.move("end");
// Option+Delete
// Simulate Option+Delete (parsed as alt-modified Delete on some terminals)
tb.handleInput(undefined, { delete: true, alt: true }, vp);
expect(tb.getText()).toBe("foo baz"); // note double space removed later maybe
expect(tb.getText()).toBe("foo bar ");
});
test("wordLeft eventually reaches column 0", () => {
@@ -121,4 +120,18 @@ describe("TextBuffer wordwise navigation & deletion", () => {
const [, col] = tb.getCursor();
expect(col).toBe(6);
});
test("Shift+Option/Alt+Delete deletes next word", () => {
const tb = new TextBuffer("foo bar baz");
const vp = { height: 10, width: 80 } as const;
// Move caret between first and second word (after space)
tb.move("wordRight"); // after foo
tb.move("right"); // skip space -> start of bar
// Shift+Option+Delete should now remove "bar "
tb.handleInput(undefined, { delete: true, alt: true, shift: true }, vp);
expect(tb.getText()).toBe("foo baz");
});
});

View File

@@ -136,6 +136,33 @@ describe("TextBuffer basic editing parity with Rust suite", () => {
});
});
describe("cursor initialization", () => {
it("initializes cursor to (0,0) by default", () => {
const buf = new TextBuffer("hello\nworld");
expect(buf.getCursor()).toEqual([0, 0]);
});
it("sets cursor to valid position within line", () => {
const buf = new TextBuffer("hello", 2);
expect(buf.getCursor()).toEqual([0, 2]); // cursor at 'l'
});
it("sets cursor to end of line", () => {
const buf = new TextBuffer("hello", 5);
expect(buf.getCursor()).toEqual([0, 5]); // cursor after 'o'
});
it("sets cursor across multiple lines", () => {
const buf = new TextBuffer("hello\nworld", 7);
expect(buf.getCursor()).toEqual([1, 1]); // cursor at 'o' in 'world'
});
it("defaults to position 0 for invalid index", () => {
const buf = new TextBuffer("hello", 999);
expect(buf.getCursor()).toEqual([0, 0]);
});
});
/* ------------------------------------------------------------------ */
/* Vertical cursor movement we should preserve the preferred column */
/* ------------------------------------------------------------------ */

97
codex-rs/Cargo.lock generated
View File

@@ -469,13 +469,13 @@ dependencies = [
[[package]]
name = "codex-cli"
version = "0.0.2504292006"
version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-common",
"codex-core",
"codex-exec",
"codex-repl",
"codex-tui",
"serde_json",
"tokio",
@@ -483,6 +483,15 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "codex-common"
version = "0.1.0"
dependencies = [
"chrono",
"clap",
"codex-core",
]
[[package]]
name = "codex-core"
version = "0.1.0"
@@ -494,6 +503,7 @@ dependencies = [
"bytes",
"clap",
"codex-apply-patch",
"codex-mcp-client",
"dirs",
"env-flags",
"eventsource-stream",
@@ -501,6 +511,7 @@ dependencies = [
"futures",
"landlock",
"libc",
"mcp-types",
"mime_guess",
"openssl-sys",
"patch",
@@ -524,13 +535,16 @@ dependencies = [
[[package]]
name = "codex-exec"
version = "0.0.2504292006"
version = "0.0.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"codex-common",
"codex-core",
"mcp-types",
"owo-colors 4.2.0",
"serde_json",
"shlex",
"tokio",
"tracing",
@@ -558,14 +572,29 @@ dependencies = [
]
[[package]]
name = "codex-repl"
version = "0.0.2504292006"
name = "codex-mcp-client"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"mcp-types",
"pretty_assertions",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "codex-mcp-server"
version = "0.1.0"
dependencies = [
"codex-core",
"owo-colors 4.2.0",
"rand",
"mcp-types",
"pretty_assertions",
"schemars",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
@@ -578,10 +607,13 @@ dependencies = [
"anyhow",
"clap",
"codex-ansi-escape",
"codex-common",
"codex-core",
"color-eyre",
"crossterm",
"mcp-types",
"ratatui",
"serde_json",
"shlex",
"tokio",
"tracing",
@@ -936,6 +968,12 @@ dependencies = [
"syn 2.0.100",
]
[[package]]
name = "dyn-clone"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]]
name = "either"
version = "1.15.0"
@@ -1955,6 +1993,14 @@ dependencies = [
"regex-automata 0.1.10",
]
[[package]]
name = "mcp-types"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "memchr"
version = "2.7.4"
@@ -2818,6 +2864,30 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "schemars"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.100",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2876,6 +2946,17 @@ dependencies = [
"syn 2.0.100",
]
[[package]]
name = "serde_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "serde_json"
version = "1.0.140"

View File

@@ -4,15 +4,18 @@ members = [
"ansi-escape",
"apply-patch",
"cli",
"common",
"core",
"exec",
"execpolicy",
"repl",
"mcp-client",
"mcp-server",
"mcp-types",
"tui",
]
[workspace.package]
version = "0.0.2504292006"
version = "0.0.0"
[profile.release]
lto = "fat"

View File

@@ -19,5 +19,179 @@ This folder is the root of a Cargo workspace. It contains quite a bit of experim
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this to be a library crate that is generally useful for building other Rust/native applications that use Codex.
- [`exec/`](./exec) "headless" CLI for use in automation.
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
- [`repl/`](./repl) CLI that launches a lightweight REPL similar to the Python or Node.js REPL.
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
## Config
The CLI can be configured via `~/.codex/config.toml`. It supports the following options:
### model
The model that Codex should use.
```toml
model = "o3" # overrides the default of "o4-mini"
```
### approval_policy
Determines when the user should be prompted to approve whether Codex can execute a command:
```toml
# This is analogous to --suggest in the TypeScript Codex CLI
approval_policy = "unless-allow-listed"
```
```toml
# If the command fails when run in the sandbox, Codex asks for permission to
# retry the command outside the sandbox.
approval_policy = "on-failure"
```
```toml
# User is never prompted: if the command fails, Codex will automatically try
# something out. Note the `exec` subcommand always uses this mode.
approval_policy = "never"
```
### sandbox_permissions
List of permissions to grant to the sandbox that Codex uses to execute untrusted commands:
```toml
# This is comparable to --full-auto in the TypeScript Codex CLI, though
# specifying `disk-write-platform-global-temp-folder` adds /tmp as a writable
# folder in addition to $TMPDIR.
sandbox_permissions = [
"disk-full-read-access",
"disk-write-platform-user-temp-folder",
"disk-write-platform-global-temp-folder",
"disk-write-cwd",
]
```
To add additional writable folders, use `disk-write-folder`, which takes a parameter (this can be specified multiple times):
```toml
sandbox_permissions = [
# ...
"disk-write-folder=/Users/mbolin/.pyenv/shims",
]
```
### mcp_servers
Defines the list of MCP servers that Codex can consult for tool use. Currently, only servers that are launched by executing a program that communicate over stdio are supported. For servers that use the SSE transport, consider an adapter like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy).
**Note:** Codex may cache the list of tools and resources from an MCP server so that Codex can include this information in context at startup without spawning all the servers. This is designed to save resources by loading MCP servers lazily.
This config option is comparable to how Claude and Cursor define `mcpServers` in their respective JSON config files, though because Codex uses TOML for its config language, the format is slightly different. For example, the following config in JSON:
```json
{
"mcpServers": {
"server-name": {
"command": "npx",
"args": ["-y", "mcp-server"],
"env": {
"API_KEY": "value"
}
}
}
}
```
Should be represented as follows in `~/.codex/config.toml`:
```toml
# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`.
[mcp_servers.server-name]
command = "npx"
args = ["-y", "mcp-server"]
env = { "API_KEY" = "value" }
```
### disable_response_storage
Currently, customers whose accounts are set to use Zero Data Retention (ZDR) must set `disable_response_storage` to `true` so that Codex uses an alternative to the Responses API that works with ZDR:
```toml
disable_response_storage = true
```
### notify
Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.:
```json
{
"type": "agent-turn-complete",
"turn-id": "12345",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds."
}
```
The `"type"` property will always be set. Currently, `"agent-turn-complete"` is the only notification type that is supported.
As an example, here is a Python script that parses the JSON and decides whether to show a desktop push notification using [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS:
```python
#!/usr/bin/env python3
import json
import subprocess
import sys
def main() -> int:
if len(sys.argv) != 2:
print("Usage: notify.py <NOTIFICATION_JSON>")
return 1
try:
notification = json.loads(sys.argv[1])
except json.JSONDecodeError:
return 1
match notification_type := notification.get("type"):
case "agent-turn-complete":
assistant_message = notification.get("last-assistant-message")
if assistant_message:
title = f"Codex: {assistant_message}"
else:
title = "Codex: Turn Complete!"
input_messages = notification.get("input_messages", [])
message = " ".join(input_messages)
title += message
case _:
print(f"not sending a push notification for: {notification_type}")
return 0
subprocess.check_output(
[
"terminal-notifier",
"-title",
title,
"-message",
message,
"-group",
"codex",
"-ignoreDnD",
"-activate",
"com.googlecode.iterm2",
]
)
return 0
if __name__ == "__main__":
sys.exit(main())
```
To have Codex use this script for notifications, you would configure it via `notify` in `~/.codex/config.toml` using the appropriate path to `notify.py` on your computer:
```toml
notify = ["python3", "/Users/mbolin/.codex/notify.py"]
```

View File

@@ -95,7 +95,7 @@ pub enum ApplyPatchFileChange {
pub enum MaybeApplyPatchVerified {
/// `argv` corresponded to an `apply_patch` invocation, and these are the
/// resulting proposed file changes.
Body(HashMap<PathBuf, ApplyPatchFileChange>),
Body(ApplyPatchAction),
/// `argv` could not be parsed to determine whether it corresponds to an
/// `apply_patch` invocation.
ShellParseError(Error),
@@ -106,7 +106,38 @@ pub enum MaybeApplyPatchVerified {
NotApplyPatch,
}
pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerified {
#[derive(Debug)]
/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
/// construction, all paths should be absolute paths.
pub struct ApplyPatchAction {
changes: HashMap<PathBuf, ApplyPatchFileChange>,
}
impl ApplyPatchAction {
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
/// Returns the changes that would be made by applying the patch.
pub fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
&self.changes
}
/// Should be used exclusively for testing. (Not worth the overhead of
/// creating a feature flag for this.)
pub fn new_add_for_test(path: &Path, content: String) -> Self {
if !path.is_absolute() {
panic!("path must be absolute");
}
let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
Self { changes }
}
}
/// cwd must be an absolute path so that we can resolve relative paths in the
/// patch.
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
match maybe_parse_apply_patch(argv) {
MaybeApplyPatch::Body(hunks) => {
let mut changes = HashMap::new();
@@ -114,14 +145,14 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
match hunk {
Hunk::AddFile { path, contents } => {
changes.insert(
path,
cwd.join(path),
ApplyPatchFileChange::Add {
content: contents.clone(),
},
);
}
Hunk::DeleteFile { path } => {
changes.insert(path, ApplyPatchFileChange::Delete);
changes.insert(cwd.join(path), ApplyPatchFileChange::Delete);
}
Hunk::UpdateFile {
path,
@@ -138,17 +169,17 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
}
};
changes.insert(
path.clone(),
cwd.join(path),
ApplyPatchFileChange::Update {
unified_diff,
move_path,
move_path: move_path.map(|p| cwd.join(p)),
new_content: contents,
},
);
}
}
}
MaybeApplyPatchVerified::Body(changes)
MaybeApplyPatchVerified::Body(ApplyPatchAction { changes })
}
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),

View File

@@ -19,8 +19,8 @@ path = "src/lib.rs"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-core = { path = "../core" }
codex-common = { path = "../common", features = ["cli"] }
codex-exec = { path = "../exec" }
codex-repl = { path = "../repl" }
codex-tui = { path = "../tui" }
serde_json = "1"
tokio = { version = "1", features = [

View File

@@ -18,7 +18,8 @@ pub fn run_landlock(command: Vec<String>, sandbox_policy: SandboxPolicy) -> anyh
// Spawn a new thread and apply the sandbox policies there.
let handle = std::thread::spawn(move || -> anyhow::Result<ExitStatus> {
codex_core::linux::apply_sandbox_policy_to_current_thread(sandbox_policy)?;
let cwd = std::env::current_dir()?;
codex_core::linux::apply_sandbox_policy_to_current_thread(sandbox_policy, &cwd)?;
let status = Command::new(&command[0]).args(&command[1..]).status()?;
Ok(status)
});

View File

@@ -4,8 +4,8 @@ pub mod proto;
pub mod seatbelt;
use clap::Parser;
use codex_common::SandboxPermissionOption;
use codex_core::protocol::SandboxPolicy;
use codex_core::SandboxPermissionOption;
#[derive(Debug, Parser)]
pub struct SeatbeltCommand {

View File

@@ -5,7 +5,6 @@ use codex_cli::seatbelt;
use codex_cli::LandlockCommand;
use codex_cli::SeatbeltCommand;
use codex_exec::Cli as ExecCli;
use codex_repl::Cli as ReplCli;
use codex_tui::Cli as TuiCli;
use crate::proto::ProtoCli;
@@ -34,10 +33,6 @@ enum Subcommand {
#[clap(visible_alias = "e")]
Exec(ExecCli),
/// Run the REPL.
#[clap(visible_alias = "r")]
Repl(ReplCli),
/// Run the Protocol stream via stdin/stdout
#[clap(visible_alias = "p")]
Proto(ProtoCli),
@@ -75,9 +70,6 @@ async fn main() -> anyhow::Result<()> {
Some(Subcommand::Exec(exec_cli)) => {
codex_exec::run_main(exec_cli).await?;
}
Some(Subcommand::Repl(repl_cli)) => {
codex_repl::run_main(repl_cli).await?;
}
Some(Subcommand::Proto(proto_cli)) => {
proto::run_main(proto_cli).await?;
}

View File

@@ -5,7 +5,8 @@ pub async fn run_seatbelt(
command: Vec<String>,
sandbox_policy: SandboxPolicy,
) -> anyhow::Result<()> {
let seatbelt_command = create_seatbelt_command(command, &sandbox_policy);
let cwd = std::env::current_dir().expect("failed to get cwd");
let seatbelt_command = create_seatbelt_command(command, &sandbox_policy, &cwd);
let status = tokio::process::Command::new(seatbelt_command[0].clone())
.args(&seatbelt_command[1..])
.spawn()

View File

@@ -0,0 +1,14 @@
[package]
name = "codex-common"
version = "0.1.0"
edition = "2021"
[dependencies]
chrono = { version = "0.4.40", optional = true }
clap = { version = "4", features = ["derive", "wrap_help"], optional = true }
codex-core = { path = "../core" }
[features]
# Separate feature so that `clap` is not a mandatory dependency.
cli = ["clap"]
elapsed = ["chrono"]

View File

@@ -0,0 +1,5 @@
# codex-common
This crate is designed for utilities that need to be shared across other crates in the workspace, but should not go in `core`.
For narrow utility features, the pattern is to add introduce a new feature under `[features]` in `Cargo.toml` and then gate it with `#[cfg]` in `lib.rs`, as appropriate.

View File

@@ -1,14 +1,13 @@
//! Standard type to use with the `--approval-mode` CLI option.
//! Available when the `cli` feature is enabled for the crate.
use std::path::PathBuf;
use clap::ArgAction;
use clap::Parser;
use clap::ValueEnum;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPermission;
use codex_core::config::parse_sandbox_permission_with_base_path;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPermission;
#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
@@ -72,49 +71,3 @@ fn parse_sandbox_permission(raw: &str) -> std::io::Result<SandboxPermission> {
let base_path = std::env::current_dir()?;
parse_sandbox_permission_with_base_path(raw, base_path)
}
pub(crate) fn parse_sandbox_permission_with_base_path(
raw: &str,
base_path: PathBuf,
) -> std::io::Result<SandboxPermission> {
use SandboxPermission::*;
if let Some(path) = raw.strip_prefix("disk-write-folder=") {
return if path.is_empty() {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--sandbox-permission disk-write-folder=<PATH> requires a non-empty PATH",
))
} else {
use path_absolutize::*;
let file = PathBuf::from(path);
let absolute_path = if file.is_relative() {
file.absolutize_from(base_path)
} else {
file.absolutize()
}
.map(|path| path.into_owned())?;
Ok(DiskWriteFolder {
folder: absolute_path,
})
};
}
match raw {
"disk-full-read-access" => Ok(DiskFullReadAccess),
"disk-write-platform-user-temp-folder" => Ok(DiskWritePlatformUserTempFolder),
"disk-write-platform-global-temp-folder" => Ok(DiskWritePlatformGlobalTempFolder),
"disk-write-cwd" => Ok(DiskWriteCwd),
"disk-full-write-access" => Ok(DiskFullWriteAccess),
"network-full-access" => Ok(NetworkFullAccess),
_ => Err(
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"`{raw}` is not a recognised permission.\nRun with `--help` to see the accepted values."
),
)
),
}
}

View File

@@ -0,0 +1,72 @@
use chrono::Utc;
/// Returns a string representing the elapsed time since `start_time` like
/// "1m15s" or "1.50s".
pub fn format_elapsed(start_time: chrono::DateTime<Utc>) -> String {
let elapsed = Utc::now().signed_duration_since(start_time);
format_time_delta(elapsed)
}
fn format_time_delta(elapsed: chrono::TimeDelta) -> String {
let millis = elapsed.num_milliseconds();
format_elapsed_millis(millis)
}
pub fn format_duration(duration: std::time::Duration) -> String {
let millis = duration.as_millis() as i64;
format_elapsed_millis(millis)
}
fn format_elapsed_millis(millis: i64) -> String {
if millis < 1000 {
format!("{}ms", millis)
} else if millis < 60_000 {
format!("{:.2}s", millis as f64 / 1000.0)
} else {
let minutes = millis / 60_000;
let seconds = (millis % 60_000) / 1000;
format!("{minutes}m{seconds:02}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_format_time_delta_subsecond() {
// Durations < 1s should be rendered in milliseconds with no decimals.
let dur = Duration::milliseconds(250);
assert_eq!(format_time_delta(dur), "250ms");
// Exactly zero should still work.
let dur_zero = Duration::milliseconds(0);
assert_eq!(format_time_delta(dur_zero), "0ms");
}
#[test]
fn test_format_time_delta_seconds() {
// Durations between 1s (inclusive) and 60s (exclusive) should be
// printed with 2-decimal-place seconds.
let dur = Duration::milliseconds(1_500); // 1.5s
assert_eq!(format_time_delta(dur), "1.50s");
// 59.999s rounds to 60.00s
let dur2 = Duration::milliseconds(59_999);
assert_eq!(format_time_delta(dur2), "60.00s");
}
#[test]
fn test_format_time_delta_minutes() {
// Durations ≥ 1 minute should be printed mmss.
let dur = Duration::milliseconds(75_000); // 1m15s
assert_eq!(format_time_delta(dur), "1m15s");
let dur_exact = Duration::milliseconds(60_000); // 1m0s
assert_eq!(format_time_delta(dur_exact), "1m00s");
let dur_long = Duration::milliseconds(3_601_000);
assert_eq!(format_time_delta(dur_long), "60m01s");
}
}

View File

@@ -0,0 +1,10 @@
#[cfg(feature = "cli")]
mod approval_mode_cli_arg;
#[cfg(feature = "elapsed")]
pub mod elapsed;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::ApprovalModeCliArg;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::SandboxPermissionOption;

View File

@@ -14,11 +14,13 @@ base64 = "0.21"
bytes = "1.10.1"
clap = { version = "4", features = ["derive", "wrap_help"], optional = true }
codex-apply-patch = { path = "../apply-patch" }
codex-mcp-client = { path = "../mcp-client" }
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
fs-err = "3.1.0"
futures = "0.3"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0"
patch = "0.7"
path-absolutize = "3.1.1"
@@ -54,9 +56,3 @@ assert_cmd = "2"
predicates = "3"
tempfile = "3"
wiremock = "0.6"
[features]
default = []
# Separate feature so that `clap` is not a mandatory dependency.
cli = ["clap"]

View File

@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::Path;
use std::pin::Pin;
@@ -13,6 +14,7 @@ use futures::prelude::*;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio::time::timeout;
@@ -42,6 +44,11 @@ pub struct Prompt {
pub instructions: Option<String>,
/// Whether to store response on server side (disable_response_storage = !store).
pub store: bool,
/// Additional tools sourced from external MCP servers. Note each key is
/// the "fully qualified" tool name (i.e., prefixed with the server name),
/// which should be reported to the model in place of Tool::name.
pub extra_tools: HashMap<String, mcp_types::Tool>,
}
#[derive(Debug)]
@@ -59,7 +66,7 @@ struct Payload<'a> {
// we code defensively to avoid this case, but perhaps we should use a
// separate enum for serialization.
input: &'a Vec<ResponseItem>,
tools: &'a [Tool],
tools: &'a [serde_json::Value],
tool_choice: &'static str,
parallel_tool_calls: bool,
reasoning: Option<Reasoning>,
@@ -77,11 +84,12 @@ struct Reasoning {
generate_summary: Option<bool>,
}
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
#[derive(Debug, Serialize)]
struct Tool {
struct ResponsesApiTool {
name: &'static str,
#[serde(rename = "type")]
kind: &'static str, // "function"
r#type: &'static str, // "function"
description: &'static str,
strict: bool,
parameters: JsonSchema,
@@ -105,7 +113,7 @@ enum JsonSchema {
}
/// Tool usage specification
static TOOLS: LazyLock<Vec<Tool>> = LazyLock::new(|| {
static DEFAULT_TOOLS: LazyLock<Vec<ResponsesApiTool>> = LazyLock::new(|| {
let mut properties = BTreeMap::new();
properties.insert(
"command".to_string(),
@@ -116,9 +124,9 @@ static TOOLS: LazyLock<Vec<Tool>> = LazyLock::new(|| {
properties.insert("workdir".to_string(), JsonSchema::String);
properties.insert("timeout".to_string(), JsonSchema::Number);
vec![Tool {
vec![ResponsesApiTool {
name: "shell",
kind: "function",
r#type: "function",
description: "Runs a shell command, and returns its output.",
strict: false,
parameters: JsonSchema::Object {
@@ -149,11 +157,26 @@ impl ModelClient {
return stream_from_fixture(path).await;
}
// Assemble tool list: built-in tools + any extra tools from the prompt.
let mut tools_json: Vec<serde_json::Value> = DEFAULT_TOOLS
.iter()
.map(|t| serde_json::to_value(t).expect("serialize builtin tool"))
.collect();
tools_json.extend(
prompt
.extra_tools
.clone()
.into_iter()
.map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)),
);
debug!("tools_json: {}", serde_json::to_string_pretty(&tools_json)?);
let payload = Payload {
model: &self.model,
instructions: prompt.instructions.as_ref(),
input: &prompt.input,
tools: &TOOLS,
tools: &tools_json,
tool_choice: "auto",
parallel_tool_calls: false,
reasoning: Some(Reasoning {
@@ -235,6 +258,20 @@ impl ModelClient {
}
}
fn mcp_tool_to_openai_tool(
fully_qualified_name: String,
tool: mcp_types::Tool,
) -> serde_json::Value {
// TODO(mbolin): Change the contract of this function to return
// ResponsesApiTool.
json!({
"name": fully_qualified_name,
"description": tool.description,
"parameters": tool.input_schema,
"type": "function",
})
}
#[derive(Debug, Deserialize, Serialize)]
struct SseEvent {
#[serde(rename = "type")]

View File

@@ -12,15 +12,18 @@ use async_channel::Sender;
use codex_apply_patch::maybe_parse_apply_patch_verified;
use codex_apply_patch::print_summary;
use codex_apply_patch::AffectedPaths;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::MaybeApplyPatchVerified;
use fs_err as fs;
use futures::prelude::*;
use serde::Serialize;
use serde_json;
use tokio::sync::oneshot;
use tokio::sync::Notify;
use tokio::task::AbortHandle;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::trace;
use tracing::warn;
@@ -28,6 +31,8 @@ use tracing::warn;
use crate::client::ModelClient;
use crate::client::Prompt;
use crate::client::ResponseEvent;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::exec::process_exec_tool_call;
@@ -35,10 +40,14 @@ use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::flags::OPENAI_STREAM_MAX_RETRIES;
use crate::mcp_connection_manager::try_parse_fully_qualified_tool_name;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::models::ContentItem;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::protocol::AskForApproval;
use crate::protocol::Event;
use crate::protocol::EventMsg;
@@ -51,6 +60,7 @@ use crate::protocol::Submission;
use crate::safety::assess_command_safety;
use crate::safety::assess_patch_safety;
use crate::safety::SafetyCheck;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use crate::zdr_transcript::ZdrTranscript;
@@ -183,19 +193,37 @@ impl Recorder {
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
struct Session {
pub(crate) struct Session {
client: ModelClient,
tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
cwd: PathBuf,
instructions: Option<String>,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
writable_roots: Mutex<Vec<PathBuf>>,
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
/// External notifier command (will be passed as args to exec()). When
/// `None` this feature is disabled.
notify: Option<Vec<String>>,
state: Mutex<State>,
}
impl Session {
fn resolve_path(&self, path: Option<String>) -> PathBuf {
path.as_ref()
.map(PathBuf::from)
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
}
}
/// Mutable state of the agent
#[derive(Default)]
struct State {
@@ -225,6 +253,14 @@ impl Session {
}
}
/// Sends the given event to the client and swallows the send event, if
/// any, logging it as an error.
pub(crate) async fn send_event(&self, event: Event) {
if let Err(e) = self.tx_event.send(event).await {
error!("failed to send tool call event: {e}");
}
}
pub async fn request_command_approval(
&self,
sub_id: String,
@@ -252,7 +288,7 @@ impl Session {
pub async fn request_patch_approval(
&self,
sub_id: String,
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
action: &ApplyPatchAction,
reason: Option<String>,
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
@@ -260,7 +296,7 @@ impl Session {
let event = Event {
id: sub_id.clone(),
msg: EventMsg::ApplyPatchApprovalRequest {
changes: convert_apply_patch_to_protocol(changes),
changes: convert_apply_patch_to_protocol(action),
reason,
grant_root,
},
@@ -285,26 +321,13 @@ impl Session {
state.approved_commands.insert(cmd);
}
async fn notify_exec_command_begin(
&self,
sub_id: &str,
call_id: &str,
command: Vec<String>,
cwd: Option<String>,
) {
let cwd = cwd
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.unwrap_or_else(|| "<unknown cwd>".to_string());
async fn notify_exec_command_begin(&self, sub_id: &str, call_id: &str, params: &ExecParams) {
let event = Event {
id: sub_id.to_string(),
msg: EventMsg::ExecCommandBegin {
call_id: call_id.to_string(),
command,
cwd,
command: params.command.clone(),
cwd: params.cwd.clone(),
},
};
let _ = self.tx_event.send(event).await;
@@ -368,6 +391,17 @@ impl Session {
}
}
pub async fn call_tool(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
) -> anyhow::Result<mcp_types::CallToolResult> {
self.mcp_connection_manager
.call_tool(server, tool, arguments)
.await
}
pub fn abort(&self) {
info!("Aborting existing session");
let mut state = self.state.lock().unwrap();
@@ -377,6 +411,35 @@ impl Session {
task.abort();
}
}
/// Spawn the configured notifier (if any) with the given JSON payload as
/// the last argument. Failures are logged but otherwise ignored so that
/// notification issues do not interfere with the main workflow.
fn maybe_notify(&self, notification: UserNotification) {
let Some(notify_command) = &self.notify else {
return;
};
if notify_command.is_empty() {
return;
}
let Ok(json) = serde_json::to_string(&notification) else {
tracing::error!("failed to serialise notification payload");
return;
};
let mut command = std::process::Command::new(&notify_command[0]);
if notify_command.len() > 1 {
command.args(&notify_command[1..]);
}
command.arg(json);
// Fire-and-forget we do not wait for completion.
if let Err(e) = command.spawn() {
tracing::warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
}
}
}
impl Drop for Session {
@@ -397,7 +460,7 @@ impl State {
}
/// A series of Turns in response to user input.
struct AgentTask {
pub(crate) struct AgentTask {
sess: Arc<Session>,
sub_id: String,
handle: AbortHandle,
@@ -482,8 +545,23 @@ async fn submission_loop(
approval_policy,
sandbox_policy,
disable_response_storage,
notify,
cwd,
} => {
info!(model, "Configuring session");
if !cwd.is_absolute() {
let message = format!("cwd is not absolute: {cwd:?}");
error!(message);
let event = Event {
id: sub.id,
msg: EventMsg::Error { message },
};
if let Err(e) = tx_event.send(event).await {
error!("failed to send error message: {e:?}");
}
return;
}
let client = ModelClient::new(model.clone());
// abort any current running session and clone its state
@@ -502,7 +580,27 @@ async fn submission_loop(
},
};
// update session
let writable_roots = Mutex::new(get_writable_roots(&cwd));
// Load config to initialize the MCP connection manager.
let config = match Config::load_with_overrides(ConfigOverrides::default()) {
Ok(cfg) => cfg,
Err(e) => {
error!("Failed to load config for MCP servers: {e:#}");
// Fall back to empty server map so the session can still proceed.
Config::load_default_config_for_test()
}
};
let mcp_connection_manager =
match McpConnectionManager::new(config.mcp_servers.clone()).await {
Ok(mgr) => mgr,
Err(e) => {
error!("Failed to create MCP connection manager: {e:#}");
McpConnectionManager::default()
}
};
sess = Some(Arc::new(Session {
client,
tx_event: tx_event.clone(),
@@ -510,7 +608,10 @@ async fn submission_loop(
instructions,
approval_policy,
sandbox_policy,
writable_roots: Mutex::new(get_writable_roots()),
cwd,
writable_roots,
mcp_connection_manager,
notify,
state: Mutex::new(state),
}));
@@ -610,6 +711,19 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
net_new_turn_input
};
let turn_input_messages: Vec<String> = turn_input
.iter()
.filter_map(|item| match item {
ResponseItem::Message { content, .. } => Some(content),
_ => None,
})
.flat_map(|content| {
content.iter().filter_map(|item| match item {
ContentItem::OutputText { text } => Some(text.clone()),
_ => None,
})
})
.collect();
match run_turn(&sess, sub_id.clone(), turn_input).await {
Ok(turn_output) => {
let (items, responses): (Vec<_>, Vec<_>) = turn_output
@@ -620,6 +734,7 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
.into_iter()
.flatten()
.collect::<Vec<ResponseInputItem>>();
let last_assistant_message = get_last_assistant_message_from_turn(&items);
// Only attempt to take the lock if there is something to record.
if !items.is_empty() {
@@ -630,6 +745,11 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
if responses.is_empty() {
debug!("Turn completed");
sess.maybe_notify(UserNotification::AgentTurnComplete {
turn_id: sub_id.clone(),
input_messages: turn_input_messages,
last_assistant_message,
});
break;
}
@@ -681,11 +801,14 @@ async fn run_turn(
} else {
None
};
let extra_tools = sess.mcp_connection_manager.list_all_tools();
let prompt = Prompt {
input,
prev_id,
instructions,
store,
extra_tools,
};
let mut retries = 0;
@@ -809,8 +932,12 @@ async fn handle_function_call(
match name.as_str() {
"container.exec" | "shell" => {
// parse command
let params = match serde_json::from_str::<ExecParams>(&arguments) {
Ok(v) => v,
let params: ExecParams = match serde_json::from_str::<ShellToolCallParams>(&arguments) {
Ok(shell_tool_call_params) => ExecParams {
command: shell_tool_call_params.command,
cwd: sess.resolve_path(shell_tool_call_params.workdir.clone()),
timeout_ms: shell_tool_call_params.timeout_ms,
},
Err(e) => {
// allow model to re-sample
let output = ResponseInputItem::FunctionCallOutput {
@@ -825,7 +952,7 @@ async fn handle_function_call(
};
// check if this was a patch, and apply it if so
match maybe_parse_apply_patch_verified(&params.command) {
match maybe_parse_apply_patch_verified(&params.command, &params.cwd) {
MaybeApplyPatchVerified::Body(changes) => {
return apply_patch(sess, sub_id, call_id, changes).await;
}
@@ -847,14 +974,6 @@ async fn handle_function_call(
MaybeApplyPatchVerified::NotApplyPatch => (),
}
// this was not a valid patch, execute command
let repo_root = std::env::current_dir().expect("no current dir");
let workdir: PathBuf = params
.workdir
.as_ref()
.map(PathBuf::from)
.unwrap_or(repo_root.clone());
// safety checks
let safety = {
let state = sess.state.lock().unwrap();
@@ -872,7 +991,7 @@ async fn handle_function_call(
.request_command_approval(
sub_id.clone(),
params.command.clone(),
workdir.clone(),
params.cwd.clone(),
None,
)
.await;
@@ -908,13 +1027,8 @@ async fn handle_function_call(
}
};
sess.notify_exec_command_begin(
&sub_id,
&call_id,
params.command.clone(),
params.workdir.clone(),
)
.await;
sess.notify_exec_command_begin(&sub_id, &call_id, &params)
.await;
let output_result = process_exec_tool_call(
params.clone(),
@@ -974,7 +1088,7 @@ async fn handle_function_call(
.request_command_approval(
sub_id.clone(),
params.command.clone(),
workdir,
params.cwd.clone(),
Some("command failed; retry without sandbox?".to_string()),
)
.await;
@@ -995,18 +1109,13 @@ async fn handle_function_call(
// Emit a fresh Begin event so progress bars reset.
let retry_call_id = format!("{call_id}-retry");
sess.notify_exec_command_begin(
&sub_id,
&retry_call_id,
params.command.clone(),
params.workdir.clone(),
)
.await;
sess.notify_exec_command_begin(&sub_id, &retry_call_id, &params)
.await;
// This is an escalated retry; the policy will not be
// examined and the sandbox has been set to `None`.
let retry_output_result = process_exec_tool_call(
params.clone(),
params,
SandboxType::None,
sess.ctrl_c.clone(),
&sess.sandbox_policy,
@@ -1083,13 +1192,20 @@ async fn handle_function_call(
}
}
_ => {
// Unknown function: reply with structured failure so the model can adapt.
ResponseInputItem::FunctionCallOutput {
call_id,
output: crate::models::FunctionCallOutputPayload {
content: format!("unsupported call: {}", name),
success: None,
},
match try_parse_fully_qualified_tool_name(&name) {
Some((server, tool_name)) => {
handle_mcp_tool_call(sess, &sub_id, call_id, server, tool_name, arguments).await
}
None => {
// Unknown function: reply with structured failure so the model can adapt.
ResponseInputItem::FunctionCallOutput {
call_id,
output: crate::models::FunctionCallOutputPayload {
content: format!("unsupported call: {}", name),
success: None,
},
}
}
}
}
}
@@ -1099,50 +1215,54 @@ async fn apply_patch(
sess: &Session,
sub_id: String,
call_id: String,
changes: HashMap<PathBuf, ApplyPatchFileChange>,
action: ApplyPatchAction,
) -> ResponseInputItem {
let writable_roots_snapshot = {
let guard = sess.writable_roots.lock().unwrap();
guard.clone()
};
let auto_approved =
match assess_patch_safety(&changes, sess.approval_policy, &writable_roots_snapshot) {
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
let rx_approve = sess
.request_patch_approval(sub_id.clone(), &changes, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
let auto_approved = match assess_patch_safety(
&action,
sess.approval_policy,
&writable_roots_snapshot,
&sess.cwd,
) {
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
let rx_approve = sess
.request_patch_approval(sub_id.clone(), &action, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
}
SafetyCheck::Reject { reason } => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
};
}
};
}
SafetyCheck::Reject { reason } => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
};
}
};
// Verify write permissions before touching the filesystem.
let writable_snapshot = { sess.writable_roots.lock().unwrap().clone() };
if let Some(offending) = first_offending_path(&changes, &writable_snapshot) {
if let Some(offending) = first_offending_path(&action, &writable_snapshot, &sess.cwd) {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
let reason = Some(format!(
@@ -1151,7 +1271,7 @@ async fn apply_patch(
));
let rx = sess
.request_patch_approval(sub_id.clone(), &changes, reason.clone(), Some(root.clone()))
.request_patch_approval(sub_id.clone(), &action, reason.clone(), Some(root.clone()))
.await;
if !matches!(
@@ -1178,7 +1298,7 @@ async fn apply_patch(
msg: EventMsg::PatchApplyBegin {
call_id: call_id.clone(),
auto_approved,
changes: convert_apply_patch_to_protocol(&changes),
changes: convert_apply_patch_to_protocol(&action),
},
})
.await;
@@ -1187,35 +1307,43 @@ async fn apply_patch(
let mut stderr = Vec::new();
// Enforce writable roots. If a write is blocked, collect offending root
// and prompt the user to extend permissions.
let mut result = apply_changes_from_apply_patch_and_report(&changes, &mut stdout, &mut stderr);
let mut result = apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr);
if let Err(err) = &result {
if err.kind() == std::io::ErrorKind::PermissionDenied {
// Determine first offending path.
let offending_opt = changes.iter().find_map(|(path, change)| {
let path_ref = match change {
ApplyPatchFileChange::Add { .. } => path,
ApplyPatchFileChange::Delete => path,
ApplyPatchFileChange::Update { .. } => path,
};
let offending_opt = action
.changes()
.iter()
.flat_map(|(path, change)| match change {
ApplyPatchFileChange::Add { .. } => vec![path.as_ref()],
ApplyPatchFileChange::Delete => vec![path.as_ref()],
ApplyPatchFileChange::Update {
move_path: Some(move_path),
..
} => {
vec![path.as_ref(), move_path.as_ref()]
}
ApplyPatchFileChange::Update {
move_path: None, ..
} => vec![path.as_ref()],
})
.find_map(|path: &Path| {
// ApplyPatchAction promises to guarantee absolute paths.
if !path.is_absolute() {
panic!("apply_patch invariant failed: path is not absolute: {path:?}");
}
// Reuse safety normalisation logic: treat absolute path.
let abs = if path_ref.is_absolute() {
path_ref.clone()
} else {
std::env::current_dir().unwrap_or_default().join(path_ref)
};
let writable = {
let roots = sess.writable_roots.lock().unwrap();
roots.iter().any(|root| abs.starts_with(root))
};
if writable {
None
} else {
Some(path_ref.clone())
}
});
let writable = {
let roots = sess.writable_roots.lock().unwrap();
roots.iter().any(|root| path.starts_with(root))
};
if writable {
None
} else {
Some(path.to_path_buf())
}
});
if let Some(offending) = offending_opt {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
@@ -1227,7 +1355,7 @@ async fn apply_patch(
let rx = sess
.request_patch_approval(
sub_id.clone(),
&changes,
&action,
reason.clone(),
Some(root.clone()),
)
@@ -1241,7 +1369,7 @@ async fn apply_patch(
stdout.clear();
stderr.clear();
result = apply_changes_from_apply_patch_and_report(
&changes,
&action,
&mut stdout,
&mut stderr,
);
@@ -1287,11 +1415,11 @@ async fn apply_patch(
/// `writable_roots` (after normalising). If all paths are acceptable,
/// returns None.
fn first_offending_path(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
action: &ApplyPatchAction,
writable_roots: &[PathBuf],
cwd: &Path,
) -> Option<PathBuf> {
let cwd = std::env::current_dir().unwrap_or_default();
let changes = action.changes();
for (path, change) in changes {
let candidate = match change {
ApplyPatchFileChange::Add { .. } => path,
@@ -1325,9 +1453,8 @@ fn first_offending_path(
None
}
fn convert_apply_patch_to_protocol(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
) -> HashMap<PathBuf, FileChange> {
fn convert_apply_patch_to_protocol(action: &ApplyPatchAction) -> HashMap<PathBuf, FileChange> {
let changes = action.changes();
let mut result = HashMap::with_capacity(changes.len());
for (path, change) in changes {
let protocol_change = match change {
@@ -1350,11 +1477,11 @@ fn convert_apply_patch_to_protocol(
}
fn apply_changes_from_apply_patch_and_report(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
action: &ApplyPatchAction,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
) -> std::io::Result<()> {
match apply_changes_from_apply_patch(changes) {
match apply_changes_from_apply_patch(action) {
Ok(affected_paths) => {
print_summary(&affected_paths, stdout)?;
}
@@ -1366,13 +1493,12 @@ fn apply_changes_from_apply_patch_and_report(
Ok(())
}
fn apply_changes_from_apply_patch(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
) -> anyhow::Result<AffectedPaths> {
fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result<AffectedPaths> {
let mut added: Vec<PathBuf> = Vec::new();
let mut modified: Vec<PathBuf> = Vec::new();
let mut deleted: Vec<PathBuf> = Vec::new();
let changes = action.changes();
for (path, change) in changes {
match change {
ApplyPatchFileChange::Add { content } => {
@@ -1429,7 +1555,7 @@ fn apply_changes_from_apply_patch(
})
}
fn get_writable_roots() -> Vec<PathBuf> {
fn get_writable_roots(cwd: &Path) -> Vec<std::path::PathBuf> {
let mut writable_roots = Vec::new();
if cfg!(target_os = "macos") {
// On macOS, $TMPDIR is private to the user.
@@ -1451,9 +1577,7 @@ fn get_writable_roots() -> Vec<PathBuf> {
}
}
if let Ok(cwd) = std::env::current_dir() {
writable_roots.push(cwd);
}
writable_roots.push(cwd.to_path_buf());
writable_roots
}
@@ -1485,3 +1609,23 @@ fn format_exec_output(output: &str, exit_code: i32, duration: std::time::Duratio
serde_json::to_string(&payload).expect("serialize ExecOutput")
}
fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
responses.iter().rev().find_map(|item| {
if let ResponseItem::Message { role, content } = item {
if role == "assistant" {
content.iter().rev().find_map(|ci| {
if let ContentItem::OutputText { text } = ci {
Some(text.clone())
} else {
None
}
})
} else {
None
}
} else {
None
}
})
}

View File

@@ -25,6 +25,8 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event,
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy,
disable_response_storage: config.disable_response_storage,
notify: config.notify.clone(),
cwd: config.cwd.clone(),
})
.await?;

View File

@@ -1,10 +1,11 @@
use crate::approval_mode_cli_arg::parse_sandbox_permission_with_base_path;
use crate::flags::OPENAI_DEFAULT_MODEL;
use crate::mcp_server_config::McpServerConfig;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPermission;
use crate::protocol::SandboxPolicy;
use dirs::home_dir;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
/// Embedded fallback instructions that mirror the TypeScript CLIs default
@@ -30,6 +31,36 @@ pub struct Config {
/// System instructions.
pub instructions: Option<String>,
/// Optional external notifier command. When set, Codex will spawn this
/// program after each completed *turn* (i.e. when the agent finishes
/// processing a user submission). The value must be the full command
/// broken into argv tokens **without** the trailing JSON argument - Codex
/// appends one extra argument containing a JSON payload describing the
/// event.
///
/// Example `~/.codex/config.toml` snippet:
///
/// ```toml
/// notify = ["notify-send", "Codex"]
/// ```
///
/// which will be invoked as:
///
/// ```shell
/// notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345"}'
/// ```
///
/// If unset the feature is disabled.
pub notify: Option<Vec<String>>,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
pub cwd: PathBuf,
/// Definition for MCP servers that Codex can reach out to for tool calls.
pub mcp_servers: HashMap<String, McpServerConfig>,
}
/// Base config deserialized from ~/.codex/config.toml.
@@ -52,8 +83,16 @@ pub struct ConfigToml {
/// 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>>,
/// System instructions.
pub instructions: Option<String>,
/// Definition for MCP servers that Codex can reach out to for tool calls.
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
}
impl ConfigToml {
@@ -109,6 +148,7 @@ where
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
pub model: Option<String>,
pub cwd: Option<PathBuf>,
pub approval_policy: Option<AskForApproval>,
pub sandbox_policy: Option<SandboxPolicy>,
pub disable_response_storage: Option<bool>,
@@ -132,6 +172,7 @@ impl Config {
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
model,
cwd,
approval_policy,
sandbox_policy,
disable_response_storage,
@@ -154,6 +195,23 @@ impl Config {
Self {
model: model.or(cfg.model).unwrap_or_else(default_model),
cwd: cwd.map_or_else(
|| {
tracing::info!("cwd not set, using current dir");
std::env::current_dir().expect("cannot determine current dir")
},
|p| {
if p.is_absolute() {
p
} else {
// Resolve relative paths against the current working directory.
tracing::info!("cwd is relative, resolving against current dir");
let mut cwd = std::env::current_dir().expect("cannot determine cwd");
cwd.push(p);
cwd
}
},
),
approval_policy: approval_policy
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default),
@@ -161,7 +219,9 @@ impl Config {
disable_response_storage: disable_response_storage
.or(cfg.disable_response_storage)
.unwrap_or(false),
notify: cfg.notify,
instructions,
mcp_servers: cfg.mcp_servers,
}
}
@@ -206,6 +266,52 @@ pub fn log_dir() -> std::io::Result<PathBuf> {
Ok(p)
}
pub fn parse_sandbox_permission_with_base_path(
raw: &str,
base_path: PathBuf,
) -> std::io::Result<SandboxPermission> {
use SandboxPermission::*;
if let Some(path) = raw.strip_prefix("disk-write-folder=") {
return if path.is_empty() {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--sandbox-permission disk-write-folder=<PATH> requires a non-empty PATH",
))
} else {
use path_absolutize::*;
let file = PathBuf::from(path);
let absolute_path = if file.is_relative() {
file.absolutize_from(base_path)
} else {
file.absolutize()
}
.map(|path| path.into_owned())?;
Ok(DiskWriteFolder {
folder: absolute_path,
})
};
}
match raw {
"disk-full-read-access" => Ok(DiskFullReadAccess),
"disk-write-platform-user-temp-folder" => Ok(DiskWritePlatformUserTempFolder),
"disk-write-platform-global-temp-folder" => Ok(DiskWritePlatformGlobalTempFolder),
"disk-write-cwd" => Ok(DiskWriteCwd),
"disk-full-write-access" => Ok(DiskFullWriteAccess),
"network-full-access" => Ok(NetworkFullAccess),
_ => Err(
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"`{raw}` is not a recognised permission.\nRun with `--help` to see the accepted values."
),
)
),
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,13 +1,14 @@
use std::io;
#[cfg(target_family = "unix")]
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use serde::Deserialize;
use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
@@ -40,15 +41,10 @@ const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl
/// already has root access.
const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
#[derive(Deserialize, Debug, Clone)]
#[derive(Debug, Clone)]
pub struct ExecParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in seconds that the command is allowed to run.
#[serde(rename = "timeout")]
// The wire format uses `timeout`, which has ambiguous units, so we use
// `timeout_ms` as the field name so it is clear in code.
pub cwd: PathBuf,
pub timeout_ms: Option<u64>,
}
@@ -97,14 +93,14 @@ pub async fn process_exec_tool_call(
SandboxType::MacosSeatbelt => {
let ExecParams {
command,
workdir,
cwd,
timeout_ms,
} = params;
let seatbelt_command = create_seatbelt_command(command, sandbox_policy);
let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &cwd);
exec(
ExecParams {
command: seatbelt_command,
workdir,
cwd,
timeout_ms,
},
ctrl_c,
@@ -157,6 +153,7 @@ pub async fn process_exec_tool_call(
pub fn create_seatbelt_command(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
cwd: &Path,
) -> Vec<String> {
let (file_write_policy, extra_cli_args) = {
if sandbox_policy.has_full_disk_write_access() {
@@ -166,7 +163,7 @@ pub fn create_seatbelt_command(
Vec::<String>::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots();
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
let (writable_folder_policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
.iter()
.enumerate()
@@ -234,7 +231,7 @@ pub struct ExecToolCallOutput {
pub async fn exec(
ExecParams {
command,
workdir,
cwd,
timeout_ms,
}: ExecParams,
ctrl_c: Arc<Notify>,
@@ -251,9 +248,7 @@ pub async fn exec(
if command.len() > 1 {
cmd.args(&command[1..]);
}
if let Some(dir) = &workdir {
cmd.current_dir(dir);
}
cmd.current_dir(cwd);
// Do not create a file descriptor for stdin because otherwise some
// commands may hang forever waiting for input. For example, ripgrep has

View File

@@ -15,17 +15,14 @@ mod flags;
mod is_safe_command;
#[cfg(target_os = "linux")]
pub mod linux;
mod mcp_connection_manager;
pub mod mcp_server_config;
mod mcp_tool_call;
mod models;
pub mod protocol;
mod safety;
mod user_notification;
pub mod util;
mod zdr_transcript;
pub use codex::Codex;
#[cfg(feature = "cli")]
mod approval_mode_cli_arg;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::ApprovalModeCliArg;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::SandboxPermissionOption;

View File

@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -48,7 +49,7 @@ pub async fn exec_linux(
.expect("Failed to create runtime");
rt.block_on(async {
apply_sandbox_policy_to_current_thread(sandbox_policy)?;
apply_sandbox_policy_to_current_thread(sandbox_policy, &params.cwd)?;
exec(params, ctrl_c_copy).await
})
})
@@ -66,13 +67,16 @@ pub async fn exec_linux(
/// Apply sandbox policies inside this thread so only the child inherits
/// them, not the entire CLI process.
pub fn apply_sandbox_policy_to_current_thread(sandbox_policy: SandboxPolicy) -> Result<()> {
pub fn apply_sandbox_policy_to_current_thread(
sandbox_policy: SandboxPolicy,
cwd: &Path,
) -> Result<()> {
if !sandbox_policy.has_full_network_access() {
install_network_seccomp_filter_on_current_thread()?;
}
if !sandbox_policy.has_full_disk_write_access() {
let writable_roots = sandbox_policy.get_writable_roots();
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd);
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
}
@@ -189,7 +193,7 @@ mod tests_linux {
async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let params = ExecParams {
command: cmd.iter().map(|elm| elm.to_string()).collect(),
workdir: None,
cwd: std::env::current_dir().expect("cwd should exist"),
timeout_ms: Some(timeout_ms),
};
@@ -262,7 +266,7 @@ mod tests_linux {
async fn assert_network_blocked(cmd: &[&str]) {
let params = ExecParams {
command: cmd.iter().map(|s| s.to_string()).collect(),
workdir: None,
cwd: std::env::current_dir().expect("cwd should exist"),
// Give the tool a generous 2second timeout so even slow DNS timeouts
// do not stall the suite.
timeout_ms: Some(2_000),

View File

@@ -0,0 +1,162 @@
//! Connection manager for Model Context Protocol (MCP) servers.
//!
//! The [`McpConnectionManager`] owns one [`codex_mcp_client::McpClient`] per
//! configured server (keyed by the *server name*). It offers convenience
//! helpers to query the available tools across *all* servers and returns them
//! in a single aggregated map using the fully-qualified tool name
//! `"<server><MCP_TOOL_NAME_DELIMITER><tool>"` as the key.
use std::collections::HashMap;
use anyhow::anyhow;
use anyhow::Context;
use anyhow::Result;
use codex_mcp_client::McpClient;
use mcp_types::Tool;
use tokio::task::JoinSet;
use tracing::info;
use crate::mcp_server_config::McpServerConfig;
/// Delimiter used to separate the server name from the tool name in a fully
/// qualified tool name.
///
/// OpenAI requires tool names to conform to `^[a-zA-Z0-9_-]+$`, so we must
/// choose a delimiter from this character set.
const MCP_TOOL_NAME_DELIMITER: &str = "__OAI_CODEX_MCP__";
fn fully_qualified_tool_name(server: &str, tool: &str) -> String {
format!("{server}{MCP_TOOL_NAME_DELIMITER}{tool}")
}
pub(crate) fn try_parse_fully_qualified_tool_name(fq_name: &str) -> Option<(String, String)> {
let (server, tool) = fq_name.split_once(MCP_TOOL_NAME_DELIMITER)?;
if server.is_empty() || tool.is_empty() {
return None;
}
Some((server.to_string(), tool.to_string()))
}
/// A thin wrapper around a set of running [`McpClient`] instances.
#[derive(Default)]
pub(crate) struct McpConnectionManager {
/// Server-name -> client instance.
///
/// The server name originates from the keys of the `mcp_servers` map in
/// the user configuration.
clients: HashMap<String, std::sync::Arc<McpClient>>,
/// Fully qualified tool name -> tool instance.
tools: HashMap<String, Tool>,
}
impl McpConnectionManager {
/// Spawn a [`McpClient`] for each configured server.
///
/// * `mcp_servers` Map loaded from the user configuration where *keys*
/// are human-readable server identifiers and *values* are the spawn
/// instructions.
pub async fn new(mcp_servers: HashMap<String, McpServerConfig>) -> Result<Self> {
// Early exit if no servers are configured.
if mcp_servers.is_empty() {
return Ok(Self::default());
}
// Spin up all servers concurrently.
let mut join_set = JoinSet::new();
// Spawn tasks to launch each server.
for (server_name, cfg) in mcp_servers {
// TODO: Verify server name: require `^[a-zA-Z0-9_-]+$`?
join_set.spawn(async move {
let McpServerConfig { command, args, env } = cfg;
let client_res = McpClient::new_stdio_client(command, args, env).await;
(server_name, client_res)
});
}
let mut clients: HashMap<String, std::sync::Arc<McpClient>> =
HashMap::with_capacity(join_set.len());
while let Some(res) = join_set.join_next().await {
let (server_name, client_res) = res?;
let client = client_res
.with_context(|| format!("failed to spawn MCP server `{server_name}`"))?;
clients.insert(server_name, std::sync::Arc::new(client));
}
let tools = list_all_tools(&clients).await?;
Ok(Self { clients, tools })
}
/// Returns a single map that contains **all** tools. Each key is the
/// fully-qualified name for the tool.
pub fn list_all_tools(&self) -> HashMap<String, Tool> {
self.tools.clone()
}
/// Invoke the tool indicated by the (server, tool) pair.
pub async fn call_tool(
&self,
server: &str,
tool: &str,
arguments: Option<serde_json::Value>,
) -> Result<mcp_types::CallToolResult> {
let client = self
.clients
.get(server)
.ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?
.clone();
client
.call_tool(tool.to_string(), arguments)
.await
.with_context(|| format!("tool call failed for `{server}/{tool}`"))
}
}
/// 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.
pub async fn list_all_tools(
clients: &HashMap<String, std::sync::Arc<McpClient>>,
) -> Result<HashMap<String, Tool>> {
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 {
let server_name_cloned = server_name.clone();
let client_clone = client.clone();
join_set.spawn(async move {
let res = client_clone.list_tools(None).await;
(server_name_cloned, res)
});
}
let mut aggregated: HashMap<String, Tool> = HashMap::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?;
for tool in list_result.tools {
// TODO(mbolin): escape tool names that contain invalid characters.
let fq_name = fully_qualified_tool_name(&server_name, &tool.name);
if aggregated.insert(fq_name.clone(), tool).is_some() {
panic!("tool name collision for '{fq_name}': suspicious");
}
}
}
info!(
"aggregated {} tools from {} servers",
aggregated.len(),
clients.len()
);
Ok(aggregated)
}

View File

@@ -0,0 +1,14 @@
use std::collections::HashMap;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
pub struct McpServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Option<HashMap<String, String>>,
}

View File

@@ -0,0 +1,99 @@
use tracing::error;
use crate::codex::Session;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::protocol::Event;
use crate::protocol::EventMsg;
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
pub(crate) async fn handle_mcp_tool_call(
sess: &Session,
sub_id: &str,
call_id: String,
server: String,
tool_name: String,
arguments: String,
) -> ResponseInputItem {
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
// is not.
let arguments_value = if arguments.trim().is_empty() {
None
} else {
match serde_json::from_str::<serde_json::Value>(&arguments) {
Ok(value) => Some(value),
Err(e) => {
error!("failed to parse tool call arguments: {e}");
return ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: format!("err: {e}"),
success: Some(false),
},
};
}
}
};
let tool_call_begin_event = EventMsg::McpToolCallBegin {
call_id: call_id.clone(),
server: server.clone(),
tool: tool_name.clone(),
arguments: arguments_value.clone(),
};
notify_mcp_tool_call_event(sess, sub_id, tool_call_begin_event).await;
// Perform the tool call.
let (tool_call_end_event, tool_call_err) =
match sess.call_tool(&server, &tool_name, arguments_value).await {
Ok(result) => (
EventMsg::McpToolCallEnd {
call_id,
success: !result.is_error.unwrap_or(false),
result: Some(result),
},
None,
),
Err(e) => (
EventMsg::McpToolCallEnd {
call_id,
success: false,
result: None,
},
Some(e),
),
};
notify_mcp_tool_call_event(sess, sub_id, tool_call_end_event.clone()).await;
let EventMsg::McpToolCallEnd {
call_id,
success,
result,
} = tool_call_end_event
else {
unimplemented!("unexpected event type");
};
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: result.map_or_else(
|| format!("err: {tool_call_err:?}"),
|result| {
serde_json::to_string(&result)
.unwrap_or_else(|e| format!("JSON serialization error: {e}"))
},
),
success: Some(success),
},
}
}
async fn notify_mcp_tool_call_event(sess: &Session, sub_id: &str, event: EventMsg) {
sess.send_event(Event {
id: sub_id.to_string(),
msg: event,
})
.await;
}

View File

@@ -102,6 +102,20 @@ 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)]
pub struct ShellToolCallParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in seconds that the command is allowed to run.
#[serde(rename = "timeout")]
// The wire format uses `timeout`, which has ambiguous units, so we use
// `timeout_ms` as the field name so it is clear in code.
pub timeout_ms: Option<u64>,
}
#[expect(dead_code)]
#[derive(Deserialize, Debug, Clone)]
pub struct FunctionCallOutputPayload {
@@ -183,4 +197,23 @@ mod tests {
assert_eq!(v.get("output").unwrap().as_str().unwrap(), "bad");
}
#[test]
fn deserialize_shell_tool_call_params() {
let json = r#"{
"command": ["ls", "-l"],
"workdir": "/tmp",
"timeout": 1000
}"#;
let params: ShellToolCallParams = serde_json::from_str(json).unwrap();
assert_eq!(
ShellToolCallParams {
command: vec!["ls".to_string(), "-l".to_string()],
workdir: Some("/tmp".to_string()),
timeout_ms: Some(1000),
},
params
);
}
}

View File

@@ -4,8 +4,10 @@
//! between user and agent.
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use mcp_types::CallToolResult;
use serde::Deserialize;
use serde::Serialize;
@@ -36,6 +38,22 @@ pub enum Op {
/// Disable server-side response storage (send full context each request)
#[serde(default)]
disable_response_storage: bool,
/// Optional external notifier command tokens. Present only when the
/// client wants the agent to spawn a program after each completed
/// turn.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
notify: Option<Vec<String>>,
/// Working directory that should be treated as the *root* of the
/// session. All relative paths supplied by the model as well as the
/// execution sandbox are resolved against this directory **instead**
/// of the process-wide current working directory. CLI front-ends are
/// expected to expand this to an absolute path before sending the
/// `ConfigureSession` operation so that the business-logic layer can
/// operate deterministically.
cwd: std::path::PathBuf,
},
/// Abort current task.
@@ -150,7 +168,7 @@ impl SandboxPolicy {
.any(|perm| matches!(perm, SandboxPermission::NetworkFullAccess))
}
pub fn get_writable_roots(&self) -> Vec<PathBuf> {
pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec<PathBuf> {
let mut writable_roots = Vec::<PathBuf>::new();
for perm in &self.permissions {
use SandboxPermission::*;
@@ -186,12 +204,9 @@ impl SandboxPolicy {
writable_roots.push(PathBuf::from("/tmp"));
}
}
DiskWriteCwd => match std::env::current_dir() {
Ok(cwd) => writable_roots.push(cwd),
Err(err) => {
tracing::error!("Failed to get current working directory: {err}");
}
},
DiskWriteCwd => {
writable_roots.push(cwd.to_path_buf());
}
DiskWriteFolder { folder } => {
writable_roots.push(folder.clone());
}
@@ -302,6 +317,32 @@ pub enum EventMsg {
model: String,
},
McpToolCallBegin {
/// Identifier so this can be paired with the McpToolCallEnd event.
call_id: String,
/// Name of the MCP server as defined in the config.
server: String,
/// Name of the tool as given by the MCP server.
tool: String,
/// Arguments to the tool call.
arguments: Option<serde_json::Value>,
},
McpToolCallEnd {
/// Identifier for the McpToolCallBegin that finished.
call_id: String,
/// Whether the tool call was successful. If `false`, `result` might
/// not be present.
success: bool,
/// Result of the tool call. Note this could be an error.
result: Option<CallToolResult>,
},
/// Notification that the server is about to execute a command.
ExecCommandBegin {
/// Identifier so this can be paired with the ExecCommandEnd event.
@@ -310,7 +351,7 @@ pub enum EventMsg {
command: Vec<String>,
/// The command's working directory if not the default cwd for the
/// agent.
cwd: String,
cwd: PathBuf,
},
ExecCommandEnd {

View File

@@ -1,9 +1,9 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use crate::exec::SandboxType;
@@ -19,11 +19,12 @@ pub enum SafetyCheck {
}
pub fn assess_patch_safety(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
action: &ApplyPatchAction,
policy: AskForApproval,
writable_roots: &[PathBuf],
cwd: &Path,
) -> SafetyCheck {
if changes.is_empty() {
if action.is_empty() {
return SafetyCheck::Reject {
reason: "empty patch".to_string(),
};
@@ -40,7 +41,7 @@ pub fn assess_patch_safety(
}
}
if is_write_patch_constrained_to_writable_paths(changes, writable_roots) {
if is_write_patch_constrained_to_writable_paths(action, writable_roots, cwd) {
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
}
@@ -113,8 +114,9 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
}
fn is_write_patch_constrained_to_writable_paths(
changes: &HashMap<PathBuf, ApplyPatchFileChange>,
action: &ApplyPatchAction,
writable_roots: &[PathBuf],
cwd: &Path,
) -> bool {
// Earlyexit if there are no declared writable roots.
if writable_roots.is_empty() {
@@ -141,11 +143,6 @@ fn is_write_patch_constrained_to_writable_paths(
// and roots are converted to absolute, normalized forms before the
// prefix check.
let is_path_writable = |p: &PathBuf| {
let cwd = match std::env::current_dir() {
Ok(cwd) => cwd,
Err(_) => return false,
};
let abs = if p.is_absolute() {
p.clone()
} else {
@@ -167,7 +164,7 @@ fn is_write_patch_constrained_to_writable_paths(
})
};
for (path, change) in changes {
for (path, change) in action.changes() {
match change {
ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Delete => {
if !is_path_writable(path) {
@@ -201,35 +198,29 @@ mod tests {
// Helper to build a singleentry map representing a patch that adds a
// file at `p`.
let make_add_change = |p: PathBuf| {
let mut m = HashMap::new();
m.insert(
p.clone(),
ApplyPatchFileChange::Add {
content: String::new(),
},
);
m
};
let make_add_change = |p: PathBuf| ApplyPatchAction::new_add_for_test(&p, "".to_string());
let add_inside = make_add_change(PathBuf::from("inner.txt"));
let add_inside = make_add_change(cwd.join("inner.txt"));
let add_outside = make_add_change(parent.join("outside.txt"));
assert!(is_write_patch_constrained_to_writable_paths(
&add_inside,
&[PathBuf::from(".")]
&[PathBuf::from(".")],
&cwd,
));
let add_outside_2 = make_add_change(parent.join("outside.txt"));
assert!(!is_write_patch_constrained_to_writable_paths(
&add_outside_2,
&[PathBuf::from(".")]
&[PathBuf::from(".")],
&cwd,
));
// With parent dir added as writable root, it should pass.
assert!(is_write_patch_constrained_to_writable_paths(
&add_outside,
&[PathBuf::from("..")]
&[PathBuf::from("..")],
&cwd,
))
}
}

View File

@@ -0,0 +1,40 @@
use serde::Serialize;
/// User can configure a program that will receive notifications. Each
/// notification is serialized as JSON and passed as an argument to the
/// program.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub(crate) enum UserNotification {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
turn_id: String,
/// Messages that the user sent to the agent to initiate the turn.
input_messages: Vec<String>,
/// The last message sent by the assistant in the turn.
last_assistant_message: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_notification() {
let notification = UserNotification::AgentTurnComplete {
turn_id: "12345".to_string(),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
};
let serialized = serde_json::to_string(&notification).unwrap();
assert_eq!(
serialized,
r#"{"type":"agent-turn-complete","turn-id":"12345","input-messages":["Rename `foo` to `bar` and update the callsites."],"last-assistant-message":"Rename complete and verified `cargo build` succeeds."}"#
);
}
}

View File

@@ -5,6 +5,8 @@ use rand::Rng;
use tokio::sync::Notify;
use tracing::debug;
use crate::config::Config;
const INITIAL_DELAY_MS: u64 = 200;
const BACKOFF_FACTOR: f64 = 1.3;
@@ -33,26 +35,20 @@ pub(crate) fn backoff(attempt: u64) -> Duration {
Duration::from_millis((base as f64 * jitter) as u64)
}
/// Return `true` if the current working directory is inside a Git repository.
/// Return `true` if the project folder specified by the `Config` is inside a
/// Git repository.
///
/// The check walks up the directory hierarchy looking for a `.git` folder. This
/// The check walks up the directory hierarchy looking for a `.git` file or
/// directory (note `.git` can be a file that contains a `gitdir` entry). This
/// approach does **not** require the `git` binary or the `git2` crate and is
/// therefore fairly lightweight. It intentionally only looks for the
/// presence of a *directory* named `.git` this is good enough for regular
/// worktrees and bare repos that live inside a worktree (common for
/// developers running Codex locally).
/// therefore fairly lightweight.
///
/// Note that this does **not** detect *worktrees* created with
/// `git worktree add` where the checkout lives outside the main repository
/// directory. If you need Codex to work from such a checkout simply pass the
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
pub fn is_inside_git_repo() -> bool {
// Besteffort: any IO error is treated as "not a repo" the caller can
// decide what to do with the result.
let mut dir = match std::env::current_dir() {
Ok(d) => d,
Err(_) => return false,
};
pub fn is_inside_git_repo(config: &Config) -> bool {
let mut dir = config.cwd.to_path_buf();
loop {
if dir.join(".git").exists() {

View File

@@ -57,6 +57,8 @@ async fn spawn_codex() -> Codex {
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
notify: None,
cwd: std::env::current_dir().unwrap(),
},
})
.await

View File

@@ -97,6 +97,8 @@ async fn keeps_previous_response_id_between_tasks() {
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
notify: None,
cwd: std::env::current_dir().unwrap(),
},
})
.await

View File

@@ -80,6 +80,8 @@ async fn retries_on_early_close() {
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
notify: None,
cwd: std::env::current_dir().unwrap(),
},
})
.await

View File

@@ -15,8 +15,11 @@ path = "src/lib.rs"
anyhow = "1"
chrono = "0.4.40"
clap = { version = "4", features = ["derive"] }
codex-core = { path = "../core", features = ["cli"] }
codex-core = { path = "../core" }
codex-common = { path = "../common", features = ["cli", "elapsed"] }
mcp-types = { path = "../mcp-types" }
owo-colors = "4.2.0"
serde_json = "1"
shlex = "1.3.0"
tokio = { version = "1", features = [
"io-std",

View File

@@ -1,6 +1,6 @@
use clap::Parser;
use clap::ValueEnum;
use codex_core::SandboxPermissionOption;
use codex_common::SandboxPermissionOption;
use std::path::PathBuf;
#[derive(Parser, Debug)]
@@ -21,6 +21,10 @@ pub struct Cli {
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Tell the agent to use the specified directory as its working root.
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,

View File

@@ -1,4 +1,5 @@
use chrono::Utc;
use codex_common::elapsed::format_elapsed;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FileChange;
@@ -15,6 +16,11 @@ pub(crate) struct EventProcessor {
call_id_to_command: HashMap<String, ExecCommandBegin>,
call_id_to_patch: HashMap<String, PatchApplyBegin>,
/// Tracks in-flight MCP tool calls so we can calculate duration and print
/// a concise summary when the corresponding `McpToolCallEnd` event is
/// received.
call_id_to_tool_call: HashMap<String, McpToolCallBegin>,
// To ensure that --color=never is respected, ANSI escapes _must_ be added
// using .style() with one of these fields. If you need a new style, add a
// new field here.
@@ -30,6 +36,7 @@ impl EventProcessor {
pub(crate) fn create_with_ansi(with_ansi: bool) -> Self {
let call_id_to_command = HashMap::new();
let call_id_to_patch = HashMap::new();
let call_id_to_tool_call = HashMap::new();
if with_ansi {
Self {
@@ -40,6 +47,7 @@ impl EventProcessor {
magenta: Style::new().magenta(),
red: Style::new().red(),
green: Style::new().green(),
call_id_to_tool_call,
}
} else {
Self {
@@ -50,6 +58,7 @@ impl EventProcessor {
magenta: Style::new(),
red: Style::new(),
green: Style::new(),
call_id_to_tool_call,
}
}
}
@@ -60,6 +69,14 @@ struct ExecCommandBegin {
start_time: chrono::DateTime<Utc>,
}
/// Metadata captured when an `McpToolCallBegin` event is received.
struct McpToolCallBegin {
/// Formatted invocation string, e.g. `server.tool({"city":"sf"})`.
invocation: String,
/// Timestamp when the call started so we can compute duration later.
start_time: chrono::DateTime<Utc>,
}
struct PatchApplyBegin {
start_time: chrono::DateTime<Utc>,
auto_approved: bool,
@@ -113,7 +130,7 @@ impl EventProcessor {
"{} {} in {}",
"exec".style(self.magenta),
escape_command(&command).style(self.bold),
cwd,
cwd.to_string_lossy(),
);
}
EventMsg::ExecCommandEnd {
@@ -129,7 +146,7 @@ impl EventProcessor {
}) = exec_command
{
(
format_duration(start_time),
format!(" in {}", format_elapsed(start_time)),
format!("{}", escape_command(&command).style(self.bold)),
)
} else {
@@ -144,7 +161,7 @@ impl EventProcessor {
.join("\n");
match exit_code {
0 => {
let title = format!("{call} succeded{duration}:");
let title = format!("{call} succeeded{duration}:");
ts_println!("{}", title.style(self.green));
}
_ => {
@@ -154,6 +171,78 @@ impl EventProcessor {
}
println!("{}", truncated_output.style(self.dimmed));
}
// Handle MCP tool calls (e.g. calling external functions via MCP).
EventMsg::McpToolCallBegin {
call_id,
server,
tool,
arguments,
} => {
// Build fully-qualified tool name: server.tool
let fq_tool_name = format!("{server}.{tool}");
// Format arguments as compact JSON so they fit on one line.
let args_str = arguments
.as_ref()
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()))
.unwrap_or_default();
let invocation = if args_str.is_empty() {
format!("{fq_tool_name}()")
} else {
format!("{fq_tool_name}({args_str})")
};
self.call_id_to_tool_call.insert(
call_id.clone(),
McpToolCallBegin {
invocation: invocation.clone(),
start_time: Utc::now(),
},
);
ts_println!(
"{} {}",
"tool".style(self.magenta),
invocation.style(self.bold),
);
}
EventMsg::McpToolCallEnd {
call_id,
success,
result,
} => {
// Retrieve start time and invocation for duration calculation and labeling.
let info = self.call_id_to_tool_call.remove(&call_id);
let (duration, invocation) = if let Some(McpToolCallBegin {
invocation,
start_time,
..
}) = info
{
(format!(" in {}", format_elapsed(start_time)), invocation)
} else {
(String::new(), format!("tool('{call_id}')"))
};
let status_str = if success { "success" } else { "failed" };
let title_style = if success { self.green } else { self.red };
let title = format!("{invocation} {status_str}{duration}:");
ts_println!("{}", title.style(title_style));
if let Some(res) = result {
let val: serde_json::Value = res.into();
let pretty =
serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
println!("{}", line.style(self.dimmed));
}
}
}
EventMsg::PatchApplyBegin {
call_id,
auto_approved,
@@ -247,7 +336,7 @@ impl EventProcessor {
}) = patch_begin
{
(
format_duration(start_time),
format!(" in {}", format_elapsed(start_time)),
format!("apply_patch(auto_approved={})", auto_approved),
)
} else {
@@ -295,13 +384,3 @@ fn format_file_change(change: &FileChange) -> &'static str {
} => "M",
}
}
fn format_duration(start_time: chrono::DateTime<Utc>) -> String {
let elapsed = Utc::now().signed_duration_since(start_time);
let millis = elapsed.num_milliseconds();
if millis < 1000 {
format!(" in {}ms", millis)
} else {
format!(" in {:.2}s", millis as f64 / 1000.0)
}
}

View File

@@ -29,6 +29,7 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
model,
full_auto,
sandbox,
cwd,
skip_git_repo_check,
disable_response_storage,
color,
@@ -46,23 +47,6 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
assert_api_key(stderr_with_ansi);
if !skip_git_repo_check && !is_inside_git_repo() {
eprintln!("Not inside a Git repo and --skip-git-repo-check was not specified.");
std::process::exit(1);
}
// TODO(mbolin): Take a more thoughtful approach to logging.
let default_level = "error";
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_level))
.unwrap(),
)
.with_ansi(stderr_with_ansi)
.with_writer(std::io::stderr)
.try_init();
let sandbox_policy = if full_auto {
Some(SandboxPolicy::new_full_auto_policy())
} else {
@@ -81,8 +65,27 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
} else {
None
},
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
};
let config = Config::load_with_overrides(overrides)?;
if !skip_git_repo_check && !is_inside_git_repo(&config) {
eprintln!("Not inside a Git repo and --skip-git-repo-check was not specified.");
std::process::exit(1);
}
// TODO(mbolin): Take a more thoughtful approach to logging.
let default_level = "error";
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_level))
.unwrap(),
)
.with_ansi(stderr_with_ansi)
.with_writer(std::io::stderr)
.try_init();
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
let codex = Arc::new(codex_wrapper);
info!("Codex initialized with event: {event:?}");

View File

@@ -10,10 +10,6 @@ install:
tui *args:
cargo run --bin codex -- tui {{args}}
# Run the REPL app
repl *args:
cargo run --bin codex -- repl {{args}}
# Run the Proto app
proto *args:
cargo run --bin codex -- proto {{args}}

View File

@@ -0,0 +1,22 @@
[package]
name = "codex-mcp-client"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
mcp-types = { path = "../mcp-types" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tokio = { version = "1", features = [
"io-util",
"macros",
"process",
"rt-multi-thread",
"sync",
] }
[dev-dependencies]
pretty_assertions = "1.4.1"

View File

@@ -0,0 +1,3 @@
mod mcp_client;
pub use mcp_client::McpClient;

View File

@@ -0,0 +1,46 @@
//! Simple command-line utility to exercise `McpClient`.
//!
//! Example usage:
//!
//! ```bash
//! cargo run -p codex-mcp-client -- `codex-mcp-server`
//! ```
//!
//! Any additional arguments after the first one are forwarded to the spawned
//! program. The utility connects, issues a `tools/list` request and prints the
//! server's response as pretty JSON.
use anyhow::Context;
use anyhow::Result;
use codex_mcp_client::McpClient;
use mcp_types::ListToolsRequestParams;
#[tokio::main]
async fn main() -> Result<()> {
// Collect command-line arguments excluding the program name itself.
let mut args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
eprintln!("Usage: mcp-client <program> [args..]\n\nExample: mcp-client codex-mcp-server");
std::process::exit(1);
}
let original_args = args.clone();
// Spawn the subprocess and connect the client.
let program = args.remove(0);
let env = None;
let client = McpClient::new_stdio_client(program, args, env)
.await
.with_context(|| format!("failed to spawn subprocess: {original_args:?}"))?;
// Issue `tools/list` request (no params).
let tools = client
.list_tools(None::<ListToolsRequestParams>)
.await
.context("tools/list request failed")?;
// Print the result in a human readable form.
println!("{}", serde_json::to_string_pretty(&tools)?);
Ok(())
}

View File

@@ -0,0 +1,382 @@
//! A minimal async client for the Model Context Protocol (MCP).
//!
//! The client is intentionally lightweight it is only capable of:
//! 1. Spawning a subprocess that launches a conforming MCP server that
//! communicates over stdio.
//! 2. Sending MCP requests and pairing them with their corresponding
//! responses.
//! 3. Offering a convenience helper for the common `tools/list` request.
//!
//! The crate hides all JSONRPC framing details behind a typed API. Users
//! interact with the [`ModelContextProtocolRequest`] trait from `mcp-types` to
//! issue requests and receive strongly-typed results.
use std::collections::HashMap;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use anyhow::anyhow;
use anyhow::Result;
use mcp_types::CallToolRequest;
use mcp_types::CallToolRequestParams;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::ListToolsRequest;
use mcp_types::ListToolsRequestParams;
use mcp_types::ListToolsResult;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use mcp_types::JSONRPC_VERSION;
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::Mutex;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::warn;
/// Capacity of the bounded channels used for transporting messages between the
/// client API and the IO tasks.
const CHANNEL_CAPACITY: usize = 128;
/// Internal representation of a pending request sender.
type PendingSender = oneshot::Sender<JSONRPCMessage>;
/// A running MCP client instance.
pub struct McpClient {
/// Retain this child process until the client is dropped. The Tokio runtime
/// will make a "best effort" to reap the process after it exits, but it is
/// not a guarantee. See the `kill_on_drop` documentation for details.
#[allow(dead_code)]
child: tokio::process::Child,
/// Channel for sending JSON-RPC messages *to* the background writer task.
outgoing_tx: mpsc::Sender<JSONRPCMessage>,
/// Map of `request.id -> oneshot::Sender` used to dispatch responses back
/// to the originating caller.
pending: Arc<Mutex<HashMap<i64, PendingSender>>>,
/// Monotonically increasing counter used to generate request IDs.
id_counter: AtomicI64,
}
impl McpClient {
/// Spawn the given command and establish an MCP session over its STDIO.
pub async fn new_stdio_client(
program: String,
args: Vec<String>,
env: Option<HashMap<String, String>>,
) -> std::io::Result<Self> {
let mut child = Command::new(program)
.args(args)
.envs(create_env_for_mcp_server(env))
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
// As noted in the `kill_on_drop` documentation, the Tokio runtime makes
// a "best effort" to reap-after-exit to avoid zombie processes, but it
// is not a guarantee.
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "failed to capture child stdin")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "failed to capture child stdout")
})?;
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let pending: Arc<Mutex<HashMap<i64, PendingSender>>> = Arc::new(Mutex::new(HashMap::new()));
// Spawn writer task. It listens on the `outgoing_rx` channel and
// writes messages to the child's STDIN.
let writer_handle = {
let mut stdin = stdin;
tokio::spawn(async move {
while let Some(msg) = outgoing_rx.recv().await {
match serde_json::to_string(&msg) {
Ok(json) => {
debug!("MCP message to server: {json}");
if stdin.write_all(json.as_bytes()).await.is_err() {
error!("failed to write message to child stdin");
break;
}
if stdin.write_all(b"\n").await.is_err() {
error!("failed to write newline to child stdin");
break;
}
if stdin.flush().await.is_err() {
error!("failed to flush child stdin");
break;
}
}
Err(e) => error!("failed to serialize JSONRPCMessage: {e}"),
}
}
})
};
// Spawn reader task. It reads line-delimited JSON from the child's
// STDOUT and dispatches responses to the pending map.
let reader_handle = {
let pending = pending.clone();
let mut lines = BufReader::new(stdout).lines();
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
debug!("MCP message from server: {line}");
match serde_json::from_str::<JSONRPCMessage>(&line) {
Ok(JSONRPCMessage::Response(resp)) => {
Self::dispatch_response(resp, &pending).await;
}
Ok(JSONRPCMessage::Error(err)) => {
Self::dispatch_error(err, &pending).await;
}
Ok(JSONRPCMessage::Notification(JSONRPCNotification { .. })) => {
// For now we only log server-initiated notifications.
info!("<- notification: {}", line);
}
Ok(other) => {
// Batch responses and requests are currently not
// expected from the server log and ignore.
info!("<- unhandled message: {:?}", other);
}
Err(e) => {
error!("failed to deserialize JSONRPCMessage: {e}; line = {}", line)
}
}
}
})
};
// We intentionally *detach* the tasks. They will keep running in the
// background as long as their respective resources (channels/stdin/
// stdout) are alive. Dropping `McpClient` cancels the tasks due to
// dropped resources.
let _ = (writer_handle, reader_handle);
Ok(Self {
child,
outgoing_tx,
pending,
id_counter: AtomicI64::new(1),
})
}
/// Send an arbitrary MCP request and await the typed result.
pub async fn send_request<R>(&self, params: R::Params) -> Result<R::Result>
where
R: ModelContextProtocolRequest,
R::Params: Serialize,
R::Result: DeserializeOwned,
{
// Create a new unique ID.
let id = self.id_counter.fetch_add(1, Ordering::SeqCst);
let request_id = RequestId::Integer(id);
// Serialize params -> JSON. For many request types `Params` is
// `Option<T>` and `None` should be encoded as *absence* of the field.
let params_json = serde_json::to_value(&params)?;
let params_field = if params_json.is_null() {
None
} else {
Some(params_json)
};
let jsonrpc_request = JSONRPCRequest {
id: request_id.clone(),
jsonrpc: JSONRPC_VERSION.to_string(),
method: R::METHOD.to_string(),
params: params_field,
};
let message = JSONRPCMessage::Request(jsonrpc_request);
// oneshot channel for the response.
let (tx, rx) = oneshot::channel();
// Register in pending map *before* sending the message so a race where
// the response arrives immediately cannot be lost.
{
let mut guard = self.pending.lock().await;
guard.insert(id, tx);
}
// Send to writer task.
if self.outgoing_tx.send(message).await.is_err() {
return Err(anyhow!(
"failed to send message to writer task - channel closed"
));
}
// Await the response.
let msg = rx
.await
.map_err(|_| anyhow!("response channel closed before a reply was received"))?;
match msg {
JSONRPCMessage::Response(JSONRPCResponse { result, .. }) => {
let typed: R::Result = serde_json::from_value(result)?;
Ok(typed)
}
JSONRPCMessage::Error(err) => Err(anyhow!(format!(
"server returned JSON-RPC error: code = {}, message = {}",
err.error.code, err.error.message
))),
other => Err(anyhow!(format!(
"unexpected message variant received in reply path: {:?}",
other
))),
}
}
/// Convenience wrapper around `tools/list`.
pub async fn list_tools(
&self,
params: Option<ListToolsRequestParams>,
) -> Result<ListToolsResult> {
self.send_request::<ListToolsRequest>(params).await
}
/// Convenience wrapper around `tools/call`.
pub async fn call_tool(
&self,
name: String,
arguments: Option<serde_json::Value>,
) -> Result<mcp_types::CallToolResult> {
let params = CallToolRequestParams { name, arguments };
debug!("MCP tool call: {params:?}");
self.send_request::<CallToolRequest>(params).await
}
/// Internal helper: route a JSON-RPC *response* object to the pending map.
async fn dispatch_response(
resp: JSONRPCResponse,
pending: &Arc<Mutex<HashMap<i64, PendingSender>>>,
) {
let id = match resp.id {
RequestId::Integer(i) => i,
RequestId::String(_) => {
// We only ever generate integer IDs. Receiving a string here
// means we will not find a matching entry in `pending`.
error!("response with string ID - no matching pending request");
return;
}
};
if let Some(tx) = pending.lock().await.remove(&id) {
// Ignore send errors the receiver might have been dropped.
let _ = tx.send(JSONRPCMessage::Response(resp));
} else {
warn!(id, "no pending request found for response");
}
}
/// Internal helper: route a JSON-RPC *error* object to the pending map.
async fn dispatch_error(
err: mcp_types::JSONRPCError,
pending: &Arc<Mutex<HashMap<i64, PendingSender>>>,
) {
let id = match err.id {
RequestId::Integer(i) => i,
RequestId::String(_) => return, // see comment above
};
if let Some(tx) = pending.lock().await.remove(&id) {
let _ = tx.send(JSONRPCMessage::Error(err));
}
}
}
impl Drop for McpClient {
fn drop(&mut self) {
// Even though we have already tagged this process with
// `kill_on_drop(true)` above, this extra check has the benefit of
// forcing the process to be reaped immediately if it has already exited
// instead of waiting for the Tokio runtime to reap it later.
let _ = self.child.try_wait();
}
}
/// Environment variables that are always included when spawning a new MCP
/// server.
#[rustfmt::skip]
#[cfg(unix)]
const DEFAULT_ENV_VARS: &[&str] = &[
// https://modelcontextprotocol.io/docs/tools/debugging#environment-variables
// states:
//
// > MCP servers inherit only a subset of environment variables automatically,
// > like `USER`, `HOME`, and `PATH`.
//
// But it does not fully enumerate the list. Empirically, when spawning a
// an MCP server via Claude Desktop on macOS, it reports the following
// environment variables:
"HOME",
"LOGNAME",
"PATH",
"SHELL",
"USER",
"__CF_USER_TEXT_ENCODING",
// Additional environment variables Codex chooses to include by default:
"LANG",
"LC_ALL",
"TERM",
"TMPDIR",
"TZ",
];
#[cfg(windows)]
const DEFAULT_ENV_VARS: &[&str] = &[
// TODO: More research is necessary to curate this list.
"PATH",
"PATHEXT",
"USERNAME",
"USERDOMAIN",
"USERPROFILE",
"TEMP",
"TMP",
];
/// `extra_env` comes from the config for an entry in `mcp_servers` in
/// `config.toml`.
fn create_env_for_mcp_server(
extra_env: Option<HashMap<String, String>>,
) -> HashMap<String, String> {
DEFAULT_ENV_VARS
.iter()
.filter_map(|var| match std::env::var(var) {
Ok(value) => Some((var.to_string(), value)),
Err(_) => None,
})
.chain(extra_env.unwrap_or_default())
.collect::<HashMap<_, _>>()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_env_for_mcp_server() {
let env_var = "USER";
let env_var_existing_value = std::env::var(env_var).unwrap_or_default();
let env_var_new_value = format!("{env_var_existing_value}-extra");
let extra_env = HashMap::from([(env_var.to_owned(), env_var_new_value.clone())]);
let mcp_server_env = create_env_for_mcp_server(Some(extra_env));
assert!(mcp_server_env.contains_key("PATH"));
assert_eq!(Some(&env_var_new_value), mcp_server_env.get(env_var));
}
}

View File

@@ -0,0 +1,23 @@
[package]
name = "codex-mcp-server"
version = "0.1.0"
edition = "2021"
[dependencies]
codex-core = { path = "../core" }
mcp-types = { path = "../mcp-types" }
schemars = "0.8.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
tokio = { version = "1", features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
[dev-dependencies]
pretty_assertions = "1.4.1"

View File

@@ -0,0 +1,244 @@
//! Configuration object accepted by the `codex` MCP tool-call.
use std::path::PathBuf;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
use schemars::r#gen::SchemaSettings;
use schemars::JsonSchema;
use serde::Deserialize;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
/// Client-supplied configuration for a `codex` tool-call.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct CodexToolCallParam {
/// The *initial user prompt* to start the Codex conversation.
pub prompt: String,
/// Optional override for the model name (e.g. "o3", "o4-mini")
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Working directory for the session. If relative, it is resolved against
/// the server process's current working directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Execution approval policy expressed as the kebab-case variant name
/// (`unless-allow-listed`, `auto-edit`, `on-failure`, `never`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<CodexToolCallApprovalPolicy>,
/// Sandbox permissions using the same string values accepted by the CLI
/// (e.g. "disk-write-cwd", "network-full-access").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_permissions: Option<Vec<CodexToolCallSandboxPermission>>,
/// Disable server-side response storage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disable_response_storage: Option<bool>,
// Custom system instructions.
// #[serde(default, skip_serializing_if = "Option::is_none")]
// pub instructions: Option<String>,
}
// Create custom enums for use with `CodexToolCallApprovalPolicy` where we
// intentionally exclude docstrings from the generated schema because they
// introduce anyOf in the the generated JSON schema, which makes it more complex
// without adding any real value since we aspire to use self-descriptive names.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum CodexToolCallApprovalPolicy {
AutoEdit,
UnlessAllowListed,
OnFailure,
Never,
}
impl From<CodexToolCallApprovalPolicy> for AskForApproval {
fn from(value: CodexToolCallApprovalPolicy) -> Self {
match value {
CodexToolCallApprovalPolicy::AutoEdit => AskForApproval::AutoEdit,
CodexToolCallApprovalPolicy::UnlessAllowListed => AskForApproval::UnlessAllowListed,
CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
}
}
}
// TODO: Support additional writable folders via a separate property on
// CodexToolCallParam.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum CodexToolCallSandboxPermission {
DiskFullReadAccess,
DiskWriteCwd,
DiskWritePlatformUserTempFolder,
DiskWritePlatformGlobalTempFolder,
DiskFullWriteAccess,
NetworkFullAccess,
}
impl From<CodexToolCallSandboxPermission> for codex_core::protocol::SandboxPermission {
fn from(value: CodexToolCallSandboxPermission) -> Self {
match value {
CodexToolCallSandboxPermission::DiskFullReadAccess => {
codex_core::protocol::SandboxPermission::DiskFullReadAccess
}
CodexToolCallSandboxPermission::DiskWriteCwd => {
codex_core::protocol::SandboxPermission::DiskWriteCwd
}
CodexToolCallSandboxPermission::DiskWritePlatformUserTempFolder => {
codex_core::protocol::SandboxPermission::DiskWritePlatformUserTempFolder
}
CodexToolCallSandboxPermission::DiskWritePlatformGlobalTempFolder => {
codex_core::protocol::SandboxPermission::DiskWritePlatformGlobalTempFolder
}
CodexToolCallSandboxPermission::DiskFullWriteAccess => {
codex_core::protocol::SandboxPermission::DiskFullWriteAccess
}
CodexToolCallSandboxPermission::NetworkFullAccess => {
codex_core::protocol::SandboxPermission::NetworkFullAccess
}
}
}
}
pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool {
let schema = SchemaSettings::draft2019_09()
.with(|s| {
s.inline_subschemas = true;
s.option_add_null_type = false
})
.into_generator()
.into_root_schema_for::<CodexToolCallParam>();
let schema_value =
serde_json::to_value(&schema).expect("Codex tool schema should serialise to JSON");
let tool_input_schema =
serde_json::from_value::<ToolInputSchema>(schema_value).unwrap_or_else(|e| {
panic!("failed to create Tool from schema: {e}");
});
Tool {
name: "codex".to_string(),
input_schema: tool_input_schema,
description: Some(
"Run a Codex session. Accepts configuration parameters matching the Codex Config struct."
.to_string(),
),
annotations: None,
}
}
impl CodexToolCallParam {
/// Returns the initial user prompt to start the Codex conversation and the
/// Config.
pub fn into_config(self) -> std::io::Result<(String, codex_core::config::Config)> {
let Self {
prompt,
model,
cwd,
approval_policy,
sandbox_permissions,
disable_response_storage,
} = self;
let sandbox_policy = sandbox_permissions.map(|perms| {
SandboxPolicy::from(perms.into_iter().map(Into::into).collect::<Vec<_>>())
});
// Build ConfigOverrides recognised by codex-core.
let overrides = codex_core::config::ConfigOverrides {
model,
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy.map(Into::into),
sandbox_policy,
disable_response_storage,
};
let cfg = codex_core::config::Config::load_with_overrides(overrides)?;
Ok((prompt, cfg))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
/// We include a test to verify the exact JSON schema as "executable
/// documentation" for the schema. When can track changes to this test as a
/// way to audit changes to the generated schema.
///
/// Seeing the fully expanded schema makes it easier to casually verify that
/// the generated JSON for enum types such as "approval-policy" is compact.
/// Ideally, modelcontextprotocol/inspector would provide a simpler UI for
/// enum fields versus open string fields to take advantage of this.
///
/// As of 2025-05-04, there is an open PR for this:
/// https://github.com/modelcontextprotocol/inspector/pull/196
#[test]
fn verify_codex_tool_json_schema() {
let tool = create_tool_for_codex_tool_call_param();
let tool_json = serde_json::to_value(&tool).expect("tool serializes");
let expected_tool_json = serde_json::json!({
"name": "codex",
"description": "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.",
"inputSchema": {
"type": "object",
"properties": {
"approval-policy": {
"description": "Execution approval policy expressed as the kebab-case variant name (`unless-allow-listed`, `auto-edit`, `on-failure`, `never`).",
"enum": [
"auto-edit",
"unless-allow-listed",
"on-failure",
"never"
],
"type": "string"
},
"cwd": {
"description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.",
"type": "string"
},
"disable-response-storage": {
"description": "Disable server-side response storage.",
"type": "boolean"
},
"model": {
"description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\")",
"type": "string"
},
"prompt": {
"description": "The *initial user prompt* to start the Codex conversation.",
"type": "string"
},
"sandbox-permissions": {
"description": "Sandbox permissions using the same string values accepted by the CLI (e.g. \"disk-write-cwd\", \"network-full-access\").",
"items": {
"enum": [
"disk-full-read-access",
"disk-write-cwd",
"disk-write-platform-user-temp-folder",
"disk-write-platform-global-temp-folder",
"disk-full-write-access",
"network-full-access"
],
"type": "string"
},
"type": "array"
}
},
"required": [
"prompt"
]
}
});
assert_eq!(expected_tool_json, tool_json);
}
}

View File

@@ -0,0 +1,181 @@
//! Asynchronous worker that executes a **Codex** tool-call inside a spawned
//! Tokio task. Separated from `message_processor.rs` to keep that file small
//! and to make future feature-growth easier to manage.
use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use mcp_types::CallToolResult;
use mcp_types::CallToolResultContent;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use mcp_types::TextContent;
use mcp_types::JSONRPC_VERSION;
use tokio::sync::mpsc::Sender;
/// Convert a Codex [`Event`] to an MCP notification.
fn codex_event_to_notification(event: &Event) -> JSONRPCMessage {
JSONRPCMessage::Notification(mcp_types::JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method: "codex/event".into(),
params: Some(serde_json::to_value(event).expect("Event must serialize")),
})
}
/// Run a complete Codex session and stream events back to the client.
///
/// On completion (success or error) the function sends the appropriate
/// `tools/call` response so the LLM can continue the conversation.
pub async fn run_codex_tool_session(
id: RequestId,
initial_prompt: String,
config: CodexConfig,
outgoing: Sender<JSONRPCMessage>,
) {
let (codex, first_event, _ctrl_c) = match init_codex(config).await {
Ok(res) => res,
Err(e) => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Failed to start Codex session: {e}"),
annotations: None,
})],
is_error: Some(true),
};
let _ = outgoing
.send(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result: result.into(),
}))
.await;
return;
}
};
// Send initial SessionConfigured event.
let _ = outgoing
.send(codex_event_to_notification(&first_event))
.await;
if let Err(e) = codex
.submit(Op::UserInput {
items: vec![InputItem::Text {
text: initial_prompt.clone(),
}],
})
.await
{
tracing::error!("Failed to submit initial prompt: {e}");
}
let mut last_agent_message: Option<String> = None;
// Stream events until the task needs to pause for user interaction or
// completes.
loop {
match codex.next_event().await {
Ok(event) => {
let _ = outgoing.send(codex_event_to_notification(&event)).await;
match &event.msg {
EventMsg::AgentMessage { message } => {
last_agent_message = Some(message.clone());
}
EventMsg::ExecApprovalRequest { .. } => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: "EXEC_APPROVAL_REQUIRED".to_string(),
annotations: None,
})],
is_error: None,
};
let _ = outgoing
.send(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: id.clone(),
result: result.into(),
}))
.await;
break;
}
EventMsg::ApplyPatchApprovalRequest { .. } => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: "PATCH_APPROVAL_REQUIRED".to_string(),
annotations: None,
})],
is_error: None,
};
let _ = outgoing
.send(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: id.clone(),
result: result.into(),
}))
.await;
break;
}
EventMsg::TaskComplete => {
let result = if let Some(msg) = last_agent_message {
CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: msg,
annotations: None,
})],
is_error: None,
}
} else {
CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: String::new(),
annotations: None,
})],
is_error: None,
}
};
let _ = outgoing
.send(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: id.clone(),
result: result.into(),
}))
.await;
break;
}
EventMsg::SessionConfigured { .. } => {
tracing::error!("unexpected SessionConfigured event");
}
_ => {}
}
}
Err(e) => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Codex runtime error: {e}"),
annotations: None,
})],
is_error: Some(true),
};
let _ = outgoing
.send(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id: id.clone(),
result: result.into(),
}))
.await;
break;
}
}
}
}

View File

@@ -0,0 +1,114 @@
//! Prototype MCP server.
#![deny(clippy::print_stdout, clippy::print_stderr)]
use std::io::Result as IoResult;
use mcp_types::JSONRPCMessage;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::io::{self};
use tokio::sync::mpsc;
use tracing::debug;
use tracing::error;
use tracing::info;
mod codex_tool_config;
mod codex_tool_runner;
mod message_processor;
use crate::message_processor::MessageProcessor;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage 128 messages should be
/// plenty for an interactive CLI.
const CHANNEL_CAPACITY: usize = 128;
#[tokio::main]
async fn main() -> IoResult<()> {
// Install a simple subscriber so `tracing` output is visible. Users can
// control the log level with `RUST_LOG`.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.init();
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
// Task: read from stdin, push to `incoming_tx`.
let stdin_reader_handle = tokio::spawn({
let incoming_tx = incoming_tx.clone();
async move {
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
while let Some(line) = lines.next_line().await.unwrap_or_default() {
match serde_json::from_str::<JSONRPCMessage>(&line) {
Ok(msg) => {
if incoming_tx.send(msg).await.is_err() {
// Receiver gone nothing left to do.
break;
}
}
Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"),
}
}
debug!("stdin reader finished (EOF)");
}
});
// Task: process incoming messages.
let processor_handle = tokio::spawn({
let mut processor = MessageProcessor::new(outgoing_tx.clone());
async move {
while let Some(msg) = incoming_rx.recv().await {
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r),
JSONRPCMessage::Response(r) => processor.process_response(r),
JSONRPCMessage::Notification(n) => processor.process_notification(n),
JSONRPCMessage::BatchRequest(b) => processor.process_batch_request(b),
JSONRPCMessage::Error(e) => processor.process_error(e),
JSONRPCMessage::BatchResponse(b) => processor.process_batch_response(b),
}
}
info!("processor task exited (channel closed)");
}
});
// Task: write outgoing messages to stdout.
let stdout_writer_handle = tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(msg) = outgoing_rx.recv().await {
match serde_json::to_string(&msg) {
Ok(json) => {
if let Err(e) = stdout.write_all(json.as_bytes()).await {
error!("Failed to write to stdout: {e}");
break;
}
if let Err(e) = stdout.write_all(b"\n").await {
error!("Failed to write newline to stdout: {e}");
break;
}
if let Err(e) = stdout.flush().await {
error!("Failed to flush stdout: {e}");
break;
}
}
Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"),
}
}
info!("stdout writer exited (channel closed)");
});
// Wait for all tasks to finish. The typical exit path is the stdin reader
// hitting EOF which, once it drops `incoming_tx`, propagates shutdown to
// the processor and then to the stdout task.
let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle);
Ok(())
}

View File

@@ -0,0 +1,467 @@
use crate::codex_tool_config::create_tool_for_codex_tool_call_param;
use crate::codex_tool_config::CodexToolCallParam;
use codex_core::config::Config as CodexConfig;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::CallToolResultContent;
use mcp_types::ClientRequest;
use mcp_types::JSONRPCBatchRequest;
use mcp_types::JSONRPCBatchResponse;
use mcp_types::JSONRPCError;
use mcp_types::JSONRPCErrorError;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::ListToolsResult;
use mcp_types::ModelContextProtocolRequest;
use mcp_types::RequestId;
use mcp_types::ServerCapabilitiesTools;
use mcp_types::ServerNotification;
use mcp_types::TextContent;
use mcp_types::JSONRPC_VERSION;
use serde_json::json;
use tokio::sync::mpsc;
use tokio::task;
pub(crate) struct MessageProcessor {
outgoing: mpsc::Sender<JSONRPCMessage>,
initialized: bool,
}
impl MessageProcessor {
/// Create a new `MessageProcessor`, retaining a handle to the outgoing
/// `Sender` so handlers can enqueue messages to be written to stdout.
pub(crate) fn new(outgoing: mpsc::Sender<JSONRPCMessage>) -> Self {
Self {
outgoing,
initialized: false,
}
}
pub(crate) fn process_request(&mut self, request: JSONRPCRequest) {
// Hold on to the ID so we can respond.
let request_id = request.id.clone();
let client_request = match ClientRequest::try_from(request) {
Ok(client_request) => client_request,
Err(e) => {
tracing::warn!("Failed to convert request: {e}");
return;
}
};
// Dispatch to a dedicated handler for each request type.
match client_request {
ClientRequest::InitializeRequest(params) => {
self.handle_initialize(request_id, params);
}
ClientRequest::PingRequest(params) => {
self.handle_ping(request_id, params);
}
ClientRequest::ListResourcesRequest(params) => {
self.handle_list_resources(params);
}
ClientRequest::ListResourceTemplatesRequest(params) => {
self.handle_list_resource_templates(params);
}
ClientRequest::ReadResourceRequest(params) => {
self.handle_read_resource(params);
}
ClientRequest::SubscribeRequest(params) => {
self.handle_subscribe(params);
}
ClientRequest::UnsubscribeRequest(params) => {
self.handle_unsubscribe(params);
}
ClientRequest::ListPromptsRequest(params) => {
self.handle_list_prompts(params);
}
ClientRequest::GetPromptRequest(params) => {
self.handle_get_prompt(params);
}
ClientRequest::ListToolsRequest(params) => {
self.handle_list_tools(request_id, params);
}
ClientRequest::CallToolRequest(params) => {
self.handle_call_tool(request_id, params);
}
ClientRequest::SetLevelRequest(params) => {
self.handle_set_level(params);
}
ClientRequest::CompleteRequest(params) => {
self.handle_complete(params);
}
}
}
/// Handle a standalone JSON-RPC response originating from the peer.
pub(crate) fn process_response(&mut self, response: JSONRPCResponse) {
tracing::info!("<- response: {:?}", response);
}
/// Handle a fire-and-forget JSON-RPC notification.
pub(crate) fn process_notification(&mut self, notification: JSONRPCNotification) {
let server_notification = match ServerNotification::try_from(notification) {
Ok(n) => n,
Err(e) => {
tracing::warn!("Failed to convert notification: {e}");
return;
}
};
// Similar to requests, route each notification type to its own stub
// handler so additional logic can be implemented incrementally.
match server_notification {
ServerNotification::CancelledNotification(params) => {
self.handle_cancelled_notification(params);
}
ServerNotification::ProgressNotification(params) => {
self.handle_progress_notification(params);
}
ServerNotification::ResourceListChangedNotification(params) => {
self.handle_resource_list_changed(params);
}
ServerNotification::ResourceUpdatedNotification(params) => {
self.handle_resource_updated(params);
}
ServerNotification::PromptListChangedNotification(params) => {
self.handle_prompt_list_changed(params);
}
ServerNotification::ToolListChangedNotification(params) => {
self.handle_tool_list_changed(params);
}
ServerNotification::LoggingMessageNotification(params) => {
self.handle_logging_message(params);
}
}
}
/// Handle a batch of requests and/or notifications.
pub(crate) fn process_batch_request(&mut self, batch: JSONRPCBatchRequest) {
tracing::info!("<- batch request containing {} item(s)", batch.len());
for item in batch {
match item {
mcp_types::JSONRPCBatchRequestItem::JSONRPCRequest(req) => {
self.process_request(req);
}
mcp_types::JSONRPCBatchRequestItem::JSONRPCNotification(note) => {
self.process_notification(note);
}
}
}
}
/// Handle an error object received from the peer.
pub(crate) fn process_error(&mut self, err: JSONRPCError) {
tracing::error!("<- error: {:?}", err);
}
/// Handle a batch of responses/errors.
pub(crate) fn process_batch_response(&mut self, batch: JSONRPCBatchResponse) {
tracing::info!("<- batch response containing {} item(s)", batch.len());
for item in batch {
match item {
mcp_types::JSONRPCBatchResponseItem::JSONRPCResponse(resp) => {
self.process_response(resp);
}
mcp_types::JSONRPCBatchResponseItem::JSONRPCError(err) => {
self.process_error(err);
}
}
}
}
fn handle_initialize(
&mut self,
id: RequestId,
params: <mcp_types::InitializeRequest as ModelContextProtocolRequest>::Params,
) {
tracing::info!("initialize -> params: {:?}", params);
if self.initialized {
// Already initialised: send JSON-RPC error response.
let error_msg = JSONRPCMessage::Error(JSONRPCError {
jsonrpc: JSONRPC_VERSION.into(),
id,
error: JSONRPCErrorError {
code: -32600, // Invalid Request
message: "initialize called more than once".to_string(),
data: None,
},
});
if let Err(e) = self.outgoing.try_send(error_msg) {
tracing::error!("Failed to send initialization error: {e}");
}
return;
}
self.initialized = true;
// Build a minimal InitializeResult. Fill with placeholders.
let result = mcp_types::InitializeResult {
capabilities: mcp_types::ServerCapabilities {
completions: None,
experimental: None,
logging: None,
prompts: None,
resources: None,
tools: Some(ServerCapabilitiesTools {
list_changed: Some(true),
}),
},
instructions: None,
protocol_version: params.protocol_version.clone(),
server_info: mcp_types::Implementation {
name: "codex-mcp-server".to_string(),
version: mcp_types::MCP_SCHEMA_VERSION.to_string(),
},
};
self.send_response::<mcp_types::InitializeRequest>(id, result);
}
fn send_response<T>(&self, id: RequestId, result: T::Result)
where
T: ModelContextProtocolRequest,
{
let response = JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result: serde_json::to_value(result).unwrap(),
});
if let Err(e) = self.outgoing.try_send(response) {
tracing::error!("Failed to send response: {e}");
}
}
fn handle_ping(
&self,
id: RequestId,
params: <mcp_types::PingRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("ping -> params: {:?}", params);
let result = json!({});
self.send_response::<mcp_types::PingRequest>(id, result);
}
fn handle_list_resources(
&self,
params: <mcp_types::ListResourcesRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("resources/list -> params: {:?}", params);
}
fn handle_list_resource_templates(
&self,
params:
<mcp_types::ListResourceTemplatesRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("resources/templates/list -> params: {:?}", params);
}
fn handle_read_resource(
&self,
params: <mcp_types::ReadResourceRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("resources/read -> params: {:?}", params);
}
fn handle_subscribe(
&self,
params: <mcp_types::SubscribeRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("resources/subscribe -> params: {:?}", params);
}
fn handle_unsubscribe(
&self,
params: <mcp_types::UnsubscribeRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("resources/unsubscribe -> params: {:?}", params);
}
fn handle_list_prompts(
&self,
params: <mcp_types::ListPromptsRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("prompts/list -> params: {:?}", params);
}
fn handle_get_prompt(
&self,
params: <mcp_types::GetPromptRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("prompts/get -> params: {:?}", params);
}
fn handle_list_tools(
&self,
id: RequestId,
params: <mcp_types::ListToolsRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::trace!("tools/list -> {params:?}");
let result = ListToolsResult {
tools: vec![create_tool_for_codex_tool_call_param()],
next_cursor: None,
};
self.send_response::<mcp_types::ListToolsRequest>(id, result);
}
fn handle_call_tool(
&self,
id: RequestId,
params: <mcp_types::CallToolRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("tools/call -> params: {:?}", params);
let CallToolRequestParams { name, arguments } = params;
// We only support the "codex" tool for now.
if name != "codex" {
// Tool not found return error result so the LLM can react.
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text: format!("Unknown tool '{name}'"),
annotations: None,
})],
is_error: Some(true),
};
self.send_response::<mcp_types::CallToolRequest>(id, result);
return;
}
let (initial_prompt, config): (String, CodexConfig) = match arguments {
Some(json_val) => match serde_json::from_value::<CodexToolCallParam>(json_val) {
Ok(tool_cfg) => match tool_cfg.into_config() {
Ok(cfg) => cfg,
Err(e) => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!(
"Failed to load Codex configuration from overrides: {e}"
),
annotations: None,
})],
is_error: Some(true),
};
self.send_response::<mcp_types::CallToolRequest>(id, result);
return;
}
},
Err(e) => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_owned(),
text: format!("Failed to parse configuration for Codex tool: {e}"),
annotations: None,
})],
is_error: Some(true),
};
self.send_response::<mcp_types::CallToolRequest>(id, result);
return;
}
},
None => {
let result = CallToolResult {
content: vec![CallToolResultContent::TextContent(TextContent {
r#type: "text".to_string(),
text:
"Missing arguments for codex tool-call; the `prompt` field is required."
.to_string(),
annotations: None,
})],
is_error: Some(true),
};
self.send_response::<mcp_types::CallToolRequest>(id, result);
return;
}
};
// Clone outgoing sender to move into async task.
let outgoing = self.outgoing.clone();
// Spawn an async task to handle the Codex session so that we do not
// block the synchronous message-processing loop.
task::spawn(async move {
// Run the Codex session and stream events back to the client.
crate::codex_tool_runner::run_codex_tool_session(id, initial_prompt, config, outgoing)
.await;
});
}
fn handle_set_level(
&self,
params: <mcp_types::SetLevelRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("logging/setLevel -> params: {:?}", params);
}
fn handle_complete(
&self,
params: <mcp_types::CompleteRequest as mcp_types::ModelContextProtocolRequest>::Params,
) {
tracing::info!("completion/complete -> params: {:?}", params);
}
// ---------------------------------------------------------------------
// Notification handlers
// ---------------------------------------------------------------------
fn handle_cancelled_notification(
&self,
params: <mcp_types::CancelledNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/cancelled -> params: {:?}", params);
}
fn handle_progress_notification(
&self,
params: <mcp_types::ProgressNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/progress -> params: {:?}", params);
}
fn handle_resource_list_changed(
&self,
params: <mcp_types::ResourceListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!(
"notifications/resources/list_changed -> params: {:?}",
params
);
}
fn handle_resource_updated(
&self,
params: <mcp_types::ResourceUpdatedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/resources/updated -> params: {:?}", params);
}
fn handle_prompt_list_changed(
&self,
params: <mcp_types::PromptListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/prompts/list_changed -> params: {:?}", params);
}
fn handle_tool_list_changed(
&self,
params: <mcp_types::ToolListChangedNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/tools/list_changed -> params: {:?}", params);
}
fn handle_logging_message(
&self,
params: <mcp_types::LoggingMessageNotification as mcp_types::ModelContextProtocolNotification>::Params,
) {
tracing::info!("notifications/message -> params: {:?}", params);
}
}

View File

@@ -0,0 +1,8 @@
[package]
name = "mcp-types"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,8 @@
# mcp-types
Types for Model Context Protocol. Inspired by https://crates.io/crates/lsp-types.
As documented on https://modelcontextprotocol.io/specification/2025-03-26/basic:
- TypeScript schema is the source of truth: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-03-26/schema.ts
- JSON schema is amenable to automated tooling: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-03-26/schema.json

View File

@@ -0,0 +1,660 @@
#!/usr/bin/env python3
# flake8: noqa: E501
import json
import subprocess
import sys
from dataclasses import (
dataclass,
)
from pathlib import Path
# Helper first so it is defined when other functions call it.
from typing import Any, Literal
SCHEMA_VERSION = "2025-03-26"
JSONRPC_VERSION = "2.0"
STANDARD_DERIVE = "#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]\n"
# Will be populated with the schema's `definitions` map in `main()` so that
# helper functions (for example `define_any_of`) can perform look-ups while
# generating code.
DEFINITIONS: dict[str, Any] = {}
# Names of the concrete *Request types that make up the ClientRequest enum.
CLIENT_REQUEST_TYPE_NAMES: list[str] = []
# Concrete *Notification types that make up the ServerNotification enum.
SERVER_NOTIFICATION_TYPE_NAMES: list[str] = []
def main() -> int:
num_args = len(sys.argv)
if num_args == 1:
schema_file = (
Path(__file__).resolve().parent / "schema" / SCHEMA_VERSION / "schema.json"
)
elif num_args == 2:
schema_file = Path(sys.argv[1])
else:
print("Usage: python3 codegen.py <schema.json>")
return 1
lib_rs = Path(__file__).resolve().parent / "src/lib.rs"
global DEFINITIONS # Allow helper functions to access the schema.
with schema_file.open(encoding="utf-8") as f:
schema_json = json.load(f)
DEFINITIONS = schema_json["definitions"]
out = [
f"""
// @generated
// DO NOT EDIT THIS FILE DIRECTLY.
// Run the following in the crate root to regenerate this file:
//
// ```shell
// ./generate_mcp_types.py
// ```
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::convert::TryFrom;
pub const MCP_SCHEMA_VERSION: &str = "{SCHEMA_VERSION}";
pub const JSONRPC_VERSION: &str = "{JSONRPC_VERSION}";
/// Paired request/response types for the Model Context Protocol (MCP).
pub trait ModelContextProtocolRequest {{
const METHOD: &'static str;
type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
type Result: DeserializeOwned + Serialize + Send + Sync + 'static;
}}
/// One-way message in the Model Context Protocol (MCP).
pub trait ModelContextProtocolNotification {{
const METHOD: &'static str;
type Params: DeserializeOwned + Serialize + Send + Sync + 'static;
}}
fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
"""
]
definitions = schema_json["definitions"]
# Keep track of every *Request type so we can generate the TryFrom impl at
# the end.
# The concrete *Request types referenced by the ClientRequest enum will be
# captured dynamically while we are processing that definition.
for name, definition in definitions.items():
add_definition(name, definition, out)
# No-op: list collected via define_any_of("ClientRequest").
# Generate TryFrom impl string and append to out before writing to file.
try_from_impl_lines: list[str] = []
try_from_impl_lines.append("impl TryFrom<JSONRPCRequest> for ClientRequest {\n")
try_from_impl_lines.append(" type Error = serde_json::Error;\n")
try_from_impl_lines.append(
" fn try_from(req: JSONRPCRequest) -> std::result::Result<Self, Self::Error> {\n"
)
try_from_impl_lines.append(" match req.method.as_str() {\n")
for req_name in CLIENT_REQUEST_TYPE_NAMES:
defn = definitions[req_name]
method_const = (
defn.get("properties", {}).get("method", {}).get("const", req_name)
)
payload_type = f"<{req_name} as ModelContextProtocolRequest>::Params"
try_from_impl_lines.append(f' "{method_const}" => {{\n')
try_from_impl_lines.append(
" let params_json = req.params.unwrap_or(serde_json::Value::Null);\n"
)
try_from_impl_lines.append(
f" let params: {payload_type} = serde_json::from_value(params_json)?;\n"
)
try_from_impl_lines.append(
f" Ok(ClientRequest::{req_name}(params))\n"
)
try_from_impl_lines.append(" },\n")
try_from_impl_lines.append(
' _ => Err(serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Unknown method: {}", req.method)))),\n'
)
try_from_impl_lines.append(" }\n")
try_from_impl_lines.append(" }\n")
try_from_impl_lines.append("}\n\n")
out.extend(try_from_impl_lines)
# Generate TryFrom for ServerNotification
notif_impl_lines: list[str] = []
notif_impl_lines.append(
"impl TryFrom<JSONRPCNotification> for ServerNotification {\n"
)
notif_impl_lines.append(" type Error = serde_json::Error;\n")
notif_impl_lines.append(
" fn try_from(n: JSONRPCNotification) -> std::result::Result<Self, Self::Error> {\n"
)
notif_impl_lines.append(" match n.method.as_str() {\n")
for notif_name in SERVER_NOTIFICATION_TYPE_NAMES:
n_def = definitions[notif_name]
method_const = (
n_def.get("properties", {}).get("method", {}).get("const", notif_name)
)
payload_type = f"<{notif_name} as ModelContextProtocolNotification>::Params"
notif_impl_lines.append(f' "{method_const}" => {{\n')
# params may be optional
notif_impl_lines.append(
" let params_json = n.params.unwrap_or(serde_json::Value::Null);\n"
)
notif_impl_lines.append(
f" let params: {payload_type} = serde_json::from_value(params_json)?;\n"
)
notif_impl_lines.append(
f" Ok(ServerNotification::{notif_name}(params))\n"
)
notif_impl_lines.append(" },\n")
notif_impl_lines.append(
' _ => Err(serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Unknown method: {}", n.method)))),\n'
)
notif_impl_lines.append(" }\n")
notif_impl_lines.append(" }\n")
notif_impl_lines.append("}\n")
out.extend(notif_impl_lines)
with open(lib_rs, "w", encoding="utf-8") as f:
for chunk in out:
f.write(chunk)
subprocess.check_call(
["cargo", "fmt", "--", "--config", "imports_granularity=Item"],
cwd=lib_rs.parent.parent,
stderr=subprocess.DEVNULL,
)
return 0
def add_definition(name: str, definition: dict[str, Any], out: list[str]) -> None:
if name == "Result":
out.append("pub type Result = serde_json::Value;\n\n")
return
# Capture description
description = definition.get("description")
properties = definition.get("properties", {})
if properties:
required_props = set(definition.get("required", []))
out.extend(define_struct(name, properties, required_props, description))
# Special carve-out for Result types:
if name.endswith("Result"):
out.extend(f"impl From<{name}> for serde_json::Value {{\n")
out.append(f" fn from(value: {name}) -> Self {{\n")
out.append(" serde_json::to_value(value).unwrap()\n")
out.append(" }\n")
out.append("}\n\n")
return
enum_values = definition.get("enum", [])
if enum_values:
assert definition.get("type") == "string"
define_string_enum(name, enum_values, out, description)
return
any_of = definition.get("anyOf", [])
if any_of:
assert isinstance(any_of, list)
if name == "JSONRPCMessage":
# Special case for JSONRPCMessage because its definition in the
# JSON schema does not quite match how we think about this type
# definition in Rust.
deep_copied_any_of = json.loads(json.dumps(any_of))
deep_copied_any_of[2] = {
"$ref": "#/definitions/JSONRPCBatchRequest",
}
deep_copied_any_of[5] = {
"$ref": "#/definitions/JSONRPCBatchResponse",
}
out.extend(define_any_of(name, deep_copied_any_of, description))
else:
out.extend(define_any_of(name, any_of, description))
return
type_prop = definition.get("type", None)
if type_prop:
if type_prop == "string":
# Newtype pattern
out.append(STANDARD_DERIVE)
out.append(f"pub struct {name}(String);\n\n")
return
elif types := check_string_list(type_prop):
define_untagged_enum(name, types, out)
return
elif type_prop == "array":
item_name = name + "Item"
out.extend(define_any_of(item_name, definition["items"]["anyOf"]))
out.append(f"pub type {name} = Vec<{item_name}>;\n\n")
return
raise ValueError(f"Unknown type: {type_prop} in {name}")
ref_prop = definition.get("$ref", None)
if ref_prop:
ref = type_from_ref(ref_prop)
out.extend(f"pub type {name} = {ref};\n\n")
return
raise ValueError(f"Definition for {name} could not be processed.")
extra_defs = []
@dataclass
class StructField:
viz: Literal["pub"] | Literal["const"]
name: str
type_name: str
serde: str | None = None
def append(self, out: list[str], supports_const: bool) -> None:
if self.serde:
out.append(f" {self.serde}\n")
if self.viz == "const":
if supports_const:
out.append(f" const {self.name}: {self.type_name};\n")
else:
out.append(f" pub {self.name}: String, // {self.type_name}\n")
else:
out.append(f" pub {self.name}: {self.type_name},\n")
def define_struct(
name: str,
properties: dict[str, Any],
required_props: set[str],
description: str | None,
) -> list[str]:
out: list[str] = []
fields: list[StructField] = []
for prop_name, prop in properties.items():
if prop_name == "_meta":
# TODO?
continue
elif prop_name == "jsonrpc":
fields.append(
StructField(
"pub",
"jsonrpc",
"String", # cannot use `&'static str` because of Deserialize
'#[serde(rename = "jsonrpc", default = "default_jsonrpc")]',
)
)
continue
prop_type = map_type(prop, prop_name, name)
is_optional = prop_name not in required_props
if is_optional:
prop_type = f"Option<{prop_type}>"
rs_prop = rust_prop_name(prop_name, is_optional)
if prop_type.startswith("&'static str"):
fields.append(StructField("const", rs_prop.name, prop_type, rs_prop.serde))
else:
fields.append(StructField("pub", rs_prop.name, prop_type, rs_prop.serde))
if implements_request_trait(name):
add_trait_impl(name, "ModelContextProtocolRequest", fields, out)
elif implements_notification_trait(name):
add_trait_impl(name, "ModelContextProtocolNotification", fields, out)
else:
# Add doc comment if available.
emit_doc_comment(description, out)
out.append(STANDARD_DERIVE)
out.append(f"pub struct {name} {{\n")
for field in fields:
field.append(out, supports_const=False)
out.append("}\n\n")
# Declare any extra structs after the main struct.
if extra_defs:
out.extend(extra_defs)
# Clear the extra structs for the next definition.
extra_defs.clear()
return out
def infer_result_type(request_type_name: str) -> str:
"""Return the corresponding Result type name for a given *Request name."""
if not request_type_name.endswith("Request"):
return "Result" # fallback
candidate = request_type_name[:-7] + "Result"
if candidate in DEFINITIONS:
return candidate
# Fallback to generic Result if specific one missing.
return "Result"
def implements_request_trait(name: str) -> bool:
return name.endswith("Request") and name not in (
"Request",
"JSONRPCRequest",
"PaginatedRequest",
)
def implements_notification_trait(name: str) -> bool:
return name.endswith("Notification") and name not in (
"Notification",
"JSONRPCNotification",
)
def add_trait_impl(
type_name: str, trait_name: str, fields: list[StructField], out: list[str]
) -> None:
out.append(STANDARD_DERIVE)
out.append(f"pub enum {type_name} {{}}\n\n")
out.append(f"impl {trait_name} for {type_name} {{\n")
for field in fields:
if field.name == "method":
field.name = "METHOD"
field.append(out, supports_const=True)
elif field.name == "params":
out.append(f" type Params = {field.type_name};\n")
else:
print(f"Warning: {type_name} has unexpected field {field.name}.")
if trait_name == "ModelContextProtocolRequest":
result_type = infer_result_type(type_name)
out.append(f" type Result = {result_type};\n")
out.append("}\n\n")
def define_string_enum(
name: str, enum_values: Any, out: list[str], description: str | None
) -> None:
emit_doc_comment(description, out)
out.append(STANDARD_DERIVE)
out.append(f"pub enum {name} {{\n")
for value in enum_values:
assert isinstance(value, str)
out.append(f' #[serde(rename = "{value}")]\n')
out.append(f" {capitalize(value)},\n")
out.append("}\n\n")
return out
def define_untagged_enum(name: str, type_list: list[str], out: list[str]) -> None:
out.append(STANDARD_DERIVE)
out.append("#[serde(untagged)]\n")
out.append(f"pub enum {name} {{\n")
for simple_type in type_list:
match simple_type:
case "string":
out.append(" String(String),\n")
case "integer":
out.append(" Integer(i64),\n")
case _:
raise ValueError(
f"Unknown type in untagged enum: {simple_type} in {name}"
)
out.append("}\n\n")
def define_any_of(
name: str, list_of_refs: list[Any], description: str | None = None
) -> list[str]:
"""Generate a Rust enum for a JSON-Schema `anyOf` union.
For most types we simply map each `$ref` inside the `anyOf` list to a
similarly named enum variant that holds the referenced type as its
payload. For certain well-known composite types (currently only
`ClientRequest`) we need a little bit of extra intelligence:
* The JSON shape of a request is `{ "method": <string>, "params": <object?> }`.
* We want to deserialize directly into `ClientRequest` using Serde's
`#[serde(tag = "method", content = "params")]` representation so that
the enum payload is **only** the request's `params` object.
* Therefore each enum variant needs to carry the dedicated `…Params` type
(wrapped in `Option<…>` if the `params` field is not required), not the
full `…Request` struct from the schema definition.
"""
# Verify each item in list_of_refs is a dict with a $ref key.
refs = [item["$ref"] for item in list_of_refs if isinstance(item, dict)]
out: list[str] = []
if description:
emit_doc_comment(description, out)
out.append(STANDARD_DERIVE)
if serde := get_serde_annotation_for_anyof_type(name):
out.append(serde + "\n")
out.append(f"pub enum {name} {{\n")
if name == "ClientRequest":
# Record the set of request type names so we can later generate a
# `TryFrom<JSONRPCRequest>` implementation.
global CLIENT_REQUEST_TYPE_NAMES
CLIENT_REQUEST_TYPE_NAMES = [type_from_ref(r) for r in refs]
if name == "ServerNotification":
global SERVER_NOTIFICATION_TYPE_NAMES
SERVER_NOTIFICATION_TYPE_NAMES = [type_from_ref(r) for r in refs]
for ref in refs:
ref_name = type_from_ref(ref)
# For JSONRPCMessage variants, drop the common "JSONRPC" prefix to
# make the enum easier to read (e.g. `Request` instead of
# `JSONRPCRequest`). The payload type remains unchanged.
variant_name = (
ref_name[len("JSONRPC") :]
if name == "JSONRPCMessage" and ref_name.startswith("JSONRPC")
else ref_name
)
# Special-case for `ClientRequest` and `ServerNotification` so the enum
# variant's payload is the *Params type rather than the full *Request /
# *Notification marker type.
if name in ("ClientRequest", "ServerNotification"):
# Rely on the trait implementation to tell us the exact Rust type
# of the `params` payload. This guarantees we stay in sync with any
# special-case logic used elsewhere (e.g. objects with
# `additionalProperties` mapping to `serde_json::Value`).
if name == "ClientRequest":
payload_type = f"<{ref_name} as ModelContextProtocolRequest>::Params"
else:
payload_type = (
f"<{ref_name} as ModelContextProtocolNotification>::Params"
)
# Determine the wire value for `method` so we can annotate the
# variant appropriately. If for some reason the schema does not
# specify a constant we fall back to the type name, which will at
# least compile (although deserialization will likely fail).
request_def = DEFINITIONS.get(ref_name, {})
method_const = (
request_def.get("properties", {})
.get("method", {})
.get("const", ref_name)
)
out.append(f' #[serde(rename = "{method_const}")]\n')
out.append(f" {variant_name}({payload_type}),\n")
else:
# The regular/straight-forward case.
out.append(f" {variant_name}({ref_name}),\n")
out.append("}\n\n")
return out
def get_serde_annotation_for_anyof_type(type_name: str) -> str | None:
# TODO: Solve this in a more generic way.
match type_name:
case "ClientRequest":
return '#[serde(tag = "method", content = "params")]'
case "ServerNotification":
return '#[serde(tag = "method", content = "params")]'
case _:
return "#[serde(untagged)]"
def map_type(
typedef: dict[str, any],
prop_name: str | None = None,
struct_name: str | None = None,
) -> str:
"""typedef must have a `type` key, but may also have an `items`key."""
ref_prop = typedef.get("$ref", None)
if ref_prop:
return type_from_ref(ref_prop)
any_of = typedef.get("anyOf", None)
if any_of:
assert prop_name is not None
assert struct_name is not None
custom_type = struct_name + capitalize(prop_name)
extra_defs.extend(define_any_of(custom_type, any_of))
return custom_type
type_prop = typedef.get("type", None)
if type_prop is None:
# Likely `unknown` in TypeScript, like the JSONRPCError.data property.
return "serde_json::Value"
if type_prop == "string":
if const_prop := typedef.get("const", None):
assert isinstance(const_prop, str)
return f'&\'static str = "{const_prop }"'
else:
return "String"
elif type_prop == "integer":
return "i64"
elif type_prop == "number":
return "f64"
elif type_prop == "boolean":
return "bool"
elif type_prop == "array":
item_type = typedef.get("items", None)
if item_type:
item_type = map_type(item_type, prop_name, struct_name)
assert isinstance(item_type, str)
return f"Vec<{item_type}>"
else:
raise ValueError("Array type without items.")
elif type_prop == "object":
# If the schema says `additionalProperties: {}` this is effectively an
# open-ended map, so deserialize into `serde_json::Value` for maximum
# flexibility.
if typedef.get("additionalProperties") is not None:
return "serde_json::Value"
# If there are *no* properties declared treat it similarly.
if not typedef.get("properties"):
return "serde_json::Value"
# Otherwise, synthesize a nested struct for the inline object.
assert prop_name is not None
assert struct_name is not None
custom_type = struct_name + capitalize(prop_name)
extra_defs.extend(
define_struct(
custom_type,
typedef["properties"],
set(typedef.get("required", [])),
typedef.get("description"),
)
)
return custom_type
else:
raise ValueError(f"Unknown type: {type_prop} in {typedef}")
@dataclass
class RustProp:
name: str
# serde annotation, if necessary
serde: str | None = None
def rust_prop_name(name: str, is_optional: bool) -> RustProp:
"""Convert a JSON property name to a Rust property name."""
prop_name: str
is_rename = False
if name == "type":
prop_name = "r#type"
elif name == "ref":
prop_name = "r#ref"
elif snake_case := to_snake_case(name):
prop_name = snake_case
is_rename = True
else:
prop_name = name
serde_annotations = []
if is_rename:
serde_annotations.append(f'rename = "{name}"')
if is_optional:
serde_annotations.append("default")
serde_annotations.append('skip_serializing_if = "Option::is_none"')
if serde_annotations:
serde_str = f'#[serde({", ".join(serde_annotations)})]'
else:
serde_str = None
return RustProp(prop_name, serde_str)
def to_snake_case(name: str) -> str:
"""Convert a camelCase or PascalCase name to snake_case."""
snake_case = name[0].lower() + "".join(
"_" + c.lower() if c.isupper() else c for c in name[1:]
)
if snake_case != name:
return snake_case
else:
return None
def capitalize(name: str) -> str:
"""Capitalize the first letter of a name."""
return name[0].upper() + name[1:]
def check_string_list(value: Any) -> list[str] | None:
"""If the value is a list of strings, return it. Otherwise, return None."""
if not isinstance(value, list):
return None
for item in value:
if not isinstance(item, str):
return None
return value
def type_from_ref(ref: str) -> str:
"""Convert a JSON reference to a Rust type."""
assert ref.startswith("#/definitions/")
return ref.split("/")[-1]
def emit_doc_comment(text: str | None, out: list[str]) -> None:
"""Append Rust doc comments derived from the JSON-schema description."""
if not text:
return
for line in text.strip().split("\n"):
out.append(f"/// {line.rstrip()}\n")
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
use mcp_types::ClientCapabilities;
use mcp_types::ClientRequest;
use mcp_types::Implementation;
use mcp_types::InitializeRequestParams;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCRequest;
use mcp_types::RequestId;
use mcp_types::JSONRPC_VERSION;
use serde_json::json;
#[test]
fn deserialize_initialize_request() {
let raw = r#"{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"capabilities": {},
"clientInfo": { "name": "acme-client", "version": "1.2.3" },
"protocolVersion": "2025-03-26"
}
}"#;
// Deserialize full JSONRPCMessage first.
let msg: JSONRPCMessage =
serde_json::from_str(raw).expect("failed to deserialize JSONRPCMessage");
// Extract the request variant.
let JSONRPCMessage::Request(json_req) = msg else {
unreachable!()
};
let expected_req = JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(1),
method: "initialize".into(),
params: Some(json!({
"capabilities": {},
"clientInfo": { "name": "acme-client", "version": "1.2.3" },
"protocolVersion": "2025-03-26"
})),
};
assert_eq!(json_req, expected_req);
let client_req: ClientRequest =
ClientRequest::try_from(json_req).expect("conversion must succeed");
let ClientRequest::InitializeRequest(init_params) = client_req else {
unreachable!()
};
assert_eq!(
init_params,
InitializeRequestParams {
capabilities: ClientCapabilities {
experimental: None,
roots: None,
sampling: None,
},
client_info: Implementation {
name: "acme-client".into(),
version: "1.2.3".into(),
},
protocol_version: "2025-03-26".into(),
}
);
}

View File

@@ -0,0 +1,43 @@
use mcp_types::JSONRPCMessage;
use mcp_types::ProgressNotificationParams;
use mcp_types::ProgressToken;
use mcp_types::ServerNotification;
#[test]
fn deserialize_progress_notification() {
let raw = r#"{
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": {
"message": "Half way there",
"progress": 0.5,
"progressToken": 99,
"total": 1.0
}
}"#;
// Deserialize full JSONRPCMessage first.
let msg: JSONRPCMessage = serde_json::from_str(raw).expect("invalid JSONRPCMessage");
// Extract the notification variant.
let JSONRPCMessage::Notification(notif) = msg else {
unreachable!()
};
// Convert via generated TryFrom.
let server_notif: ServerNotification =
ServerNotification::try_from(notif).expect("conversion must succeed");
let ServerNotification::ProgressNotification(params) = server_notif else {
unreachable!()
};
let expected_params = ProgressNotificationParams {
message: Some("Half way there".into()),
progress: 0.5,
progress_token: ProgressToken::Integer(99),
total: Some(1.0),
};
assert_eq!(params, expected_params);
}

View File

@@ -1,28 +0,0 @@
[package]
name = "codex-repl"
version = { workspace = true }
edition = "2021"
[[bin]]
name = "codex-repl"
path = "src/main.rs"
[lib]
name = "codex_repl"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-core = { path = "../core", features = ["cli"] }
owo-colors = "4.2.0"
rand = "0.9"
tokio = { version = "1", features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -1,65 +0,0 @@
use clap::ArgAction;
use clap::Parser;
use codex_core::ApprovalModeCliArg;
use codex_core::SandboxPermissionOption;
use std::path::PathBuf;
/// Commandline arguments.
#[derive(Debug, Parser)]
#[command(
author,
version,
about = "Interactive Codex CLI that streams all agent actions."
)]
pub struct Cli {
/// User prompt to start the session.
pub prompt: Option<String>,
/// Override the default model from ~/.codex/config.toml.
#[arg(short, long)]
pub model: Option<String>,
/// Optional images to attach to the prompt.
#[arg(long, value_name = "FILE")]
pub images: Vec<PathBuf>,
/// Increase verbosity (-v info, -vv debug, -vvv trace).
///
/// The flag may be passed up to three times. Without any -v the CLI only prints warnings and errors.
#[arg(short, long, action = ArgAction::Count)]
pub verbose: u8,
/// Don't use colored ansi output for verbose logging
#[arg(long)]
pub no_ansi: bool,
/// Configure when the model requires human approval before executing a command.
#[arg(long = "ask-for-approval", short = 'a')]
pub approval_policy: Option<ApprovalModeCliArg>,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Allow running Codex outside a Git repository. By default the CLI
/// aborts early when the current working directory is **not** inside a
/// Git repo because most agents rely on `git` for interacting with the
/// codebase. Pass this flag if you really know what you are doing.
#[arg(long, action = ArgAction::SetTrue, default_value_t = false)]
pub allow_no_git_exec: bool,
/// Disable serverside response storage (sends the full conversation context with every request)
#[arg(long = "disable-response-storage", default_value_t = false)]
pub disable_response_storage: bool,
/// Record submissions into file as JSON
#[arg(short = 'S', long)]
pub record_submissions: Option<PathBuf>,
/// Record events into file as JSON
#[arg(short = 'E', long)]
pub record_events: Option<PathBuf>,
}

View File

@@ -1,448 +0,0 @@
use std::io::stdin;
use std::io::stdout;
use std::io::Write;
use std::sync::Arc;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::FileChange;
use codex_core::protocol::SandboxPolicy;
use codex_core::util::is_inside_git_repo;
use codex_core::util::notify_on_sigint;
use codex_core::Codex;
use owo_colors::OwoColorize;
use owo_colors::Style;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::io::Lines;
use tokio::io::Stdin;
use tokio::sync::Notify;
use tracing::debug;
use tracing_subscriber::EnvFilter;
mod cli;
pub use cli::Cli;
/// Initialize the global logger once at startup based on the `--verbose` flag.
fn init_logger(verbose: u8, allow_ansi: bool) {
// Map -v occurrences to explicit log levels:
// 0 → warn (default)
// 1 → info
// 2 → debug
// ≥3 → trace
let default_level = match verbose {
0 => "warn",
1 => "info",
2 => "codex=debug",
_ => "codex=trace",
};
// Only initialize the logger once repeated calls are ignored. `try_init` will return an
// error if another crate (like tests) initialized it first, which we can safely ignore.
// By default `tracing_subscriber::fmt()` writes formatted logs to stderr. That is fine when
// running the CLI manually but in our smoke tests we capture **stdout** (via `assert_cmd`) and
// ignore stderr. As a result none of the `tracing::info!` banners or warnings show up in the
// recorded output making it much harder to debug live runs.
// Switch the logger's writer to stdout so both human runs and the integration tests see the
// same stream. Disable ANSI colors because the binary already prints plain text and color
// escape codes make predicate matching brittle.
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_level))
.unwrap(),
)
.with_ansi(allow_ansi)
.with_writer(std::io::stdout)
.try_init();
}
pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
let ctrl_c = notify_on_sigint();
// Abort early when the user runs Codex outside a Git repository unless
// they explicitly acknowledged the risks with `--allow-no-git-exec`.
if !cli.allow_no_git_exec && !is_inside_git_repo() {
eprintln!(
"We recommend running codex inside a git repository. \
If you understand the risks, you can proceed with \
`--allow-no-git-exec`."
);
std::process::exit(1);
}
// Initialize logging before any other work so early errors are captured.
init_logger(cli.verbose, !cli.no_ansi);
let (sandbox_policy, approval_policy) = if cli.full_auto {
(
Some(SandboxPolicy::new_full_auto_policy()),
Some(AskForApproval::OnFailure),
)
} else {
let sandbox_policy = cli.sandbox.permissions.clone().map(Into::into);
(sandbox_policy, cli.approval_policy.map(Into::into))
};
// Load config file and apply CLI overrides (model & approval policy)
let overrides = ConfigOverrides {
model: cli.model.clone(),
approval_policy,
sandbox_policy,
disable_response_storage: if cli.disable_response_storage {
Some(true)
} else {
None
},
};
let config = Config::load_with_overrides(overrides)?;
codex_main(cli, config, ctrl_c).await
}
async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Result<()> {
let mut builder = Codex::builder();
if let Some(path) = cli.record_submissions {
builder = builder.record_submissions(path);
}
if let Some(path) = cli.record_events {
builder = builder.record_events(path);
}
let codex = builder.spawn(Arc::clone(&ctrl_c))?;
let init_id = random_id();
let init = protocol::Submission {
id: init_id.clone(),
op: protocol::Op::ConfigureSession {
model: cfg.model,
instructions: cfg.instructions,
approval_policy: cfg.approval_policy,
sandbox_policy: cfg.sandbox_policy,
disable_response_storage: cfg.disable_response_storage,
},
};
out(
"initializing session",
MessagePriority::BackgroundEvent,
MessageActor::User,
);
codex.submit(init).await?;
// init
loop {
out(
"waiting for session initialization",
MessagePriority::BackgroundEvent,
MessageActor::User,
);
let event = codex.next_event().await?;
if event.id == init_id {
if let protocol::EventMsg::Error { message } = event.msg {
anyhow::bail!("Error during initialization: {message}");
} else {
out(
"session initialized",
MessagePriority::BackgroundEvent,
MessageActor::User,
);
break;
}
}
}
// run loop
let mut reader = InputReader::new(ctrl_c.clone());
loop {
let text = match &cli.prompt {
Some(input) => input.clone(),
None => match reader.request_input().await? {
Some(input) => input,
None => {
// ctrl + d
println!();
return Ok(());
}
},
};
if text.trim().is_empty() {
continue;
}
// Interpret certain singleword commands as immediate termination requests.
let trimmed = text.trim();
if trimmed == "q" {
// Exit gracefully.
println!("Exiting…");
return Ok(());
}
let sub = protocol::Submission {
id: random_id(),
op: protocol::Op::UserInput {
items: vec![protocol::InputItem::Text { text }],
},
};
out(
"sending request to model",
MessagePriority::TaskProgress,
MessageActor::User,
);
codex.submit(sub).await?;
// Wait for agent events **or** user interrupts (Ctrl+C).
'inner: loop {
// Listen for either the next agent event **or** a SIGINT notification. Using
// `tokio::select!` allows the user to cancel a longrunning request that would
// otherwise leave the CLI stuck waiting for a server response.
let event = {
let interrupted = ctrl_c.notified();
tokio::select! {
_ = interrupted => {
// Forward an interrupt to the agent so it can abort any inflight task.
let _ = codex
.submit(protocol::Submission {
id: random_id(),
op: protocol::Op::Interrupt,
})
.await;
// Exit the inner loop and return to the main input prompt. The agent
// will emit a `TurnInterrupted` (Error) event which is drained later.
break 'inner;
}
res = codex.next_event() => res?
}
};
debug!(?event, "Got event");
let id = event.id;
match event.msg {
protocol::EventMsg::Error { message } => {
println!("Error: {message}");
break 'inner;
}
protocol::EventMsg::TaskComplete => break 'inner,
protocol::EventMsg::AgentMessage { message } => {
out(&message, MessagePriority::UserMessage, MessageActor::Agent)
}
protocol::EventMsg::SessionConfigured { model } => {
debug!(model, "Session initialized");
}
protocol::EventMsg::ExecApprovalRequest {
command,
cwd,
reason,
} => {
let reason_str = reason
.as_deref()
.map(|r| format!(" [{r}]"))
.unwrap_or_default();
let prompt = format!(
"approve command in {} {}{} (y/N): ",
cwd.display(),
command.join(" "),
reason_str
);
let decision = request_user_approval2(prompt)?;
let sub = protocol::Submission {
id: random_id(),
op: protocol::Op::ExecApproval { id, decision },
};
out(
"submitting command approval",
MessagePriority::TaskProgress,
MessageActor::User,
);
codex.submit(sub).await?;
}
protocol::EventMsg::ApplyPatchApprovalRequest {
changes,
reason: _,
grant_root: _,
} => {
let file_list = changes
.keys()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(", ");
let request =
format!("approve apply_patch that will touch? {file_list} (y/N): ");
let decision = request_user_approval2(request)?;
let sub = protocol::Submission {
id: random_id(),
op: protocol::Op::PatchApproval { id, decision },
};
out(
"submitting patch approval",
MessagePriority::UserMessage,
MessageActor::Agent,
);
codex.submit(sub).await?;
}
protocol::EventMsg::ExecCommandBegin {
command,
cwd,
call_id: _,
} => {
out(
&format!("running command: '{}' in '{}'", command.join(" "), cwd),
MessagePriority::BackgroundEvent,
MessageActor::Agent,
);
}
protocol::EventMsg::ExecCommandEnd {
stdout,
stderr,
exit_code,
call_id: _,
} => {
let msg = if exit_code == 0 {
"command completed (exit 0)".to_string()
} else {
// Prefer stderr but fall back to stdout if empty.
let err_snippet = if !stderr.trim().is_empty() {
stderr.trim()
} else {
stdout.trim()
};
format!("command failed (exit {exit_code}): {err_snippet}")
};
out(&msg, MessagePriority::BackgroundEvent, MessageActor::Agent);
out(
"sending results to model",
MessagePriority::TaskProgress,
MessageActor::Agent,
);
}
protocol::EventMsg::PatchApplyBegin { changes, .. } => {
// Emit PatchApplyBegin so the frontend can show progress.
let summary = changes
.iter()
.map(|(path, change)| match change {
FileChange::Add { .. } => format!("A {}", path.display()),
FileChange::Delete => format!("D {}", path.display()),
FileChange::Update { .. } => format!("M {}", path.display()),
})
.collect::<Vec<_>>()
.join(", ");
out(
&format!("applying patch: {summary}"),
MessagePriority::BackgroundEvent,
MessageActor::Agent,
);
}
protocol::EventMsg::PatchApplyEnd { success, .. } => {
let status = if success { "success" } else { "failed" };
out(
&format!("patch application {status}"),
MessagePriority::BackgroundEvent,
MessageActor::Agent,
);
out(
"sending results to model",
MessagePriority::TaskProgress,
MessageActor::Agent,
);
}
// Broad fallback; if the CLI is unaware of an event type, it will just
// print it as a generic BackgroundEvent.
e => {
out(
&format!("event: {e:?}"),
MessagePriority::BackgroundEvent,
MessageActor::Agent,
);
}
}
}
}
}
fn random_id() -> String {
let id: u64 = rand::random();
id.to_string()
}
fn request_user_approval2(request: String) -> anyhow::Result<protocol::ReviewDecision> {
println!("{}", request);
let mut line = String::new();
stdin().read_line(&mut line)?;
let answer = line.trim().to_ascii_lowercase();
let is_accepted = answer == "y" || answer == "yes";
let decision = if is_accepted {
protocol::ReviewDecision::Approved
} else {
protocol::ReviewDecision::Denied
};
Ok(decision)
}
#[derive(Debug, Clone, Copy)]
enum MessagePriority {
BackgroundEvent,
TaskProgress,
UserMessage,
}
enum MessageActor {
Agent,
User,
}
impl From<MessageActor> for String {
fn from(actor: MessageActor) -> Self {
match actor {
MessageActor::Agent => "codex".to_string(),
MessageActor::User => "user".to_string(),
}
}
}
fn out(msg: &str, priority: MessagePriority, actor: MessageActor) {
let actor: String = actor.into();
let style = match priority {
MessagePriority::BackgroundEvent => Style::new().fg_rgb::<127, 127, 127>(),
MessagePriority::TaskProgress => Style::new().fg_rgb::<200, 200, 200>(),
MessagePriority::UserMessage => Style::new().white(),
};
println!("{}> {}", actor.bold(), msg.style(style));
}
struct InputReader {
reader: Lines<BufReader<Stdin>>,
ctrl_c: Arc<Notify>,
}
impl InputReader {
pub fn new(ctrl_c: Arc<Notify>) -> Self {
Self {
reader: BufReader::new(tokio::io::stdin()).lines(),
ctrl_c,
}
}
pub async fn request_input(&mut self) -> std::io::Result<Option<String>> {
print!("user> ");
stdout().flush()?;
let interrupted = self.ctrl_c.notified();
tokio::select! {
line = self.reader.next_line() => {
match line? {
Some(input) => Ok(Some(input.trim().to_string())),
None => Ok(None),
}
}
_ = interrupted => {
println!();
Ok(Some(String::new()))
}
}
}
}

View File

@@ -1,11 +0,0 @@
use clap::Parser;
use codex_repl::run_main;
use codex_repl::Cli;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
run_main(cli).await?;
Ok(())
}

View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
# 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)
# Create a new branch for the release and make a commit with the new version.
VERSION=$(printf '0.0.%d' "$(date +%y%m%d%H%M)")
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

@@ -15,13 +15,16 @@ path = "src/lib.rs"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-ansi-escape = { path = "../ansi-escape" }
codex-core = { path = "../core", features = ["cli"] }
codex-core = { path = "../core" }
codex-common = { path = "../common", features = ["cli", "elapsed"] }
color-eyre = "0.6.3"
crossterm = "0.28.1"
mcp-types = { path = "../mcp-types" }
ratatui = { version = "0.29.0", features = [
"unstable-widget-ref",
"unstable-rendered-line-info",
] }
serde_json = "1"
shlex = "1.3.0"
tokio = { version = "1", features = [
"io-std",

View File

@@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::sync::mpsc::SendError;
use std::sync::mpsc::Sender;
use std::sync::Arc;
@@ -34,7 +35,6 @@ pub(crate) struct ChatWidget<'a> {
bottom_pane: BottomPane<'a>,
input_focus: InputFocus,
config: Config,
cwd: std::path::PathBuf,
}
#[derive(Clone, Copy, Eq, PartialEq)]
@@ -48,15 +48,10 @@ impl ChatWidget<'_> {
config: Config,
app_event_tx: Sender<AppEvent>,
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
initial_images: Vec<PathBuf>,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
// Determine the current working directory upfront so we can display
// it alongside the Session information when the session is
// initialised.
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let app_event_tx_clone = app_event_tx.clone();
// Create the Codex asynchronously so the UI loads as quickly as possible.
let config_for_agent_loop = config.clone();
@@ -105,7 +100,6 @@ impl ChatWidget<'_> {
}),
input_focus: InputFocus::BottomPane,
config,
cwd: cwd.clone(),
};
let _ = chat_widget.submit_welcome_message();
@@ -149,12 +143,7 @@ impl ChatWidget<'_> {
InputResult::Submitted(text) => {
// Special clientside commands start with a leading slash.
let trimmed = text.trim();
match trimmed {
"q" => {
// Gracefully request application shutdown.
let _ = self.app_event_tx.send(AppEvent::ExitRequest);
}
"/clear" => {
// Clear the current conversation history without exiting.
self.conversation_history.clear();
@@ -193,7 +182,7 @@ impl ChatWidget<'_> {
fn submit_user_message_with_images(
&mut self,
text: String,
image_paths: Vec<std::path::PathBuf>,
image_paths: Vec<PathBuf>,
) -> std::result::Result<(), SendError<AppEvent>> {
let mut items: Vec<InputItem> = Vec::new();
@@ -233,7 +222,7 @@ impl ChatWidget<'_> {
EventMsg::SessionConfigured { model } => {
// Record session information at the top of the conversation.
self.conversation_history
.add_session_info(&self.config, model, self.cwd.clone());
.add_session_info(&self.config, model);
self.request_redraw()?;
}
EventMsg::AgentMessage { message } => {
@@ -334,6 +323,25 @@ impl ChatWidget<'_> {
.record_completed_exec_command(call_id, stdout, stderr, exit_code);
self.request_redraw()?;
}
EventMsg::McpToolCallBegin {
call_id,
server,
tool,
arguments,
} => {
self.conversation_history
.add_active_mcp_tool_call(call_id, server, tool, arguments);
self.request_redraw()?;
}
EventMsg::McpToolCallEnd {
call_id,
success,
result,
} => {
self.conversation_history
.record_completed_mcp_tool_call(call_id, success, result);
self.request_redraw()?;
}
event => {
self.conversation_history
.add_background_event(format!("{event:?}"));

View File

@@ -1,6 +1,6 @@
use clap::Parser;
use codex_core::ApprovalModeCliArg;
use codex_core::SandboxPermissionOption;
use codex_common::ApprovalModeCliArg;
use codex_common::SandboxPermissionOption;
use std::path::PathBuf;
#[derive(Parser, Debug)]
@@ -28,6 +28,10 @@ pub struct Cli {
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Tell the agent to use the specified directory as its working root.
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,

View File

@@ -8,6 +8,7 @@ use crossterm::event::KeyEvent;
use ratatui::prelude::*;
use ratatui::style::Style;
use ratatui::widgets::*;
use serde_json::Value as JsonValue;
use std::cell::Cell as StdCell;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -184,14 +185,26 @@ impl ConversationHistoryWidget {
/// Note `model` could differ from `config.model` if the agent decided to
/// use a different model than the one requested by the user.
pub fn add_session_info(&mut self, config: &Config, model: String, cwd: PathBuf) {
self.add_to_history(HistoryCell::new_session_info(config, model, cwd));
pub fn add_session_info(&mut self, config: &Config, model: String) {
self.add_to_history(HistoryCell::new_session_info(config, model));
}
pub fn add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
self.add_to_history(HistoryCell::new_active_exec_command(call_id, command));
}
pub fn add_active_mcp_tool_call(
&mut self,
call_id: String,
server: String,
tool: String,
arguments: Option<JsonValue>,
) {
self.add_to_history(HistoryCell::new_active_mcp_tool_call(
call_id, server, tool, arguments,
));
}
fn add_to_history(&mut self, cell: HistoryCell) {
self.history.push(cell);
}
@@ -232,6 +245,43 @@ impl ConversationHistoryWidget {
}
}
}
pub fn record_completed_mcp_tool_call(
&mut self,
call_id: String,
success: bool,
result: Option<mcp_types::CallToolResult>,
) {
// Convert result into serde_json::Value early so we don't have to
// worry about lifetimes inside the match arm.
let result_val = result.map(|r| {
serde_json::to_value(r)
.unwrap_or_else(|_| serde_json::Value::String("<serialization error>".into()))
});
for cell in self.history.iter_mut() {
if let HistoryCell::ActiveMcpToolCall {
call_id: history_id,
fq_tool_name,
invocation,
start,
..
} = cell
{
if &call_id == history_id {
let completed = HistoryCell::new_completed_mcp_tool_call(
fq_tool_name.clone(),
invocation.clone(),
*start,
success,
result_val,
);
*cell = completed;
break;
}
}
}
}
}
impl WidgetRef for ConversationHistoryWidget {

View File

@@ -1,4 +1,5 @@
use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
use codex_core::config::Config;
use codex_core::protocol::FileChange;
use ratatui::prelude::*;
@@ -48,6 +49,22 @@ pub(crate) enum HistoryCell {
/// Completed exec tool call.
CompletedExecCommand { lines: Vec<Line<'static>> },
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall {
call_id: String,
/// `server.tool` fully-qualified name so we can show a concise label
fq_tool_name: String,
/// Formatted invocation that mirrors the `$ cmd ...` style of exec
/// commands. We keep this around so the completed state can reuse the
/// exact same text without re-formatting.
invocation: String,
start: Instant,
lines: Vec<Line<'static>>,
},
/// Completed MCP tool call.
CompletedMcpToolCall { lines: Vec<Line<'static>> },
/// Background event
BackgroundEvent { lines: Vec<Line<'static>> },
@@ -64,6 +81,8 @@ pub(crate) enum HistoryCell {
},
}
const TOOL_CALL_MAX_LINES: usize = 5;
impl HistoryCell {
pub(crate) fn new_user_prompt(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -114,17 +133,20 @@ impl HistoryCell {
// Title depends on whether we have output yet.
let title_line = Line::from(vec![
"command".magenta(),
format!(" (code: {}, duration: {:?})", exit_code, duration).dim(),
format!(
" (code: {}, duration: {})",
exit_code,
format_duration(duration)
)
.dim(),
]);
lines.push(title_line);
const MAX_LINES: usize = 5;
let src = if exit_code == 0 { stdout } else { stderr };
lines.push(Line::from(format!("$ {command}")));
let mut lines_iter = src.lines();
for raw in lines_iter.by_ref().take(MAX_LINES) {
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
lines.push(ansi_escape_line(raw).dim());
}
let remaining = lines_iter.count();
@@ -136,6 +158,84 @@ impl HistoryCell {
HistoryCell::CompletedExecCommand { lines }
}
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
server: String,
tool: String,
arguments: Option<serde_json::Value>,
) -> Self {
let fq_tool_name = format!("{server}.{tool}");
// Format the arguments as compact JSON so they roughly fit on one
// line. If there are no arguments we keep it empty so the invocation
// mirrors a function-style call.
let args_str = arguments
.as_ref()
.map(|v| {
// Use compact form to keep things short but readable.
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
})
.unwrap_or_default();
let invocation = if args_str.is_empty() {
format!("{fq_tool_name}()")
} else {
format!("{fq_tool_name}({args_str})")
};
let start = Instant::now();
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
let lines: Vec<Line<'static>> = vec![
title_line,
Line::from(format!("$ {invocation}")),
Line::from(""),
];
HistoryCell::ActiveMcpToolCall {
call_id,
fq_tool_name,
invocation,
start,
lines,
}
}
pub(crate) fn new_completed_mcp_tool_call(
fq_tool_name: String,
invocation: String,
start: Instant,
success: bool,
result: Option<serde_json::Value>,
) -> Self {
let duration = format_duration(start.elapsed());
let status_str = if success { "success" } else { "failed" };
let title_line = Line::from(vec![
"tool".magenta(),
format!(" {fq_tool_name} ({status_str}, duration: {})", duration).dim(),
]);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(title_line);
lines.push(Line::from(format!("$ {invocation}")));
if let Some(res_val) = result {
let json_pretty =
serde_json::to_string_pretty(&res_val).unwrap_or_else(|_| res_val.to_string());
let mut iter = json_pretty.lines();
for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
lines.push(Line::from(raw.to_string()).dim());
}
let remaining = iter.count();
if remaining > 0 {
lines.push(Line::from(format!("... {} additional lines", remaining)).dim());
}
}
lines.push(Line::from(""));
HistoryCell::CompletedMcpToolCall { lines }
}
pub(crate) fn new_background_event(message: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("event".dim()));
@@ -144,18 +244,14 @@ impl HistoryCell {
HistoryCell::BackgroundEvent { lines }
}
pub(crate) fn new_session_info(
config: &Config,
model: String,
cwd: std::path::PathBuf,
) -> Self {
pub(crate) fn new_session_info(config: &Config, model: String) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("codex session:".magenta().bold()));
lines.push(Line::from(vec!["↳ model: ".bold(), model.into()]));
lines.push(Line::from(vec![
"↳ cwd: ".bold(),
cwd.display().to_string().into(),
config.cwd.display().to_string().into(),
]));
lines.push(Line::from(vec![
"↳ approval: ".bold(),
@@ -238,6 +334,8 @@ impl HistoryCell {
| HistoryCell::SessionInfo { lines, .. }
| HistoryCell::ActiveExecCommand { lines, .. }
| HistoryCell::CompletedExecCommand { lines, .. }
| HistoryCell::ActiveMcpToolCall { lines, .. }
| HistoryCell::CompletedMcpToolCall { lines, .. }
| HistoryCell::PendingPatch { lines, .. } => lines,
}
}

View File

@@ -56,6 +56,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
} else {
None
},
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
};
#[allow(clippy::print_stderr)]
match Config::load_with_overrides(overrides) {
@@ -113,7 +114,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
// modal. The flag is shown when the current working directory is *not*
// inside a Git repository **and** the user did *not* pass the
// `--allow-no-git-exec` flag.
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo();
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
try_run_ratatui_app(cli, config, show_git_warning, log_rx);
Ok(())

144
pnpm-lock.yaml generated
View File

@@ -52,6 +52,9 @@ importers:
file-type:
specifier: ^20.1.0
version: 20.4.1
https-proxy-agent:
specifier: ^7.0.6
version: 7.0.6
ink:
specifier: ^5.2.0
version: 5.2.0(@types/react@18.3.20)(react@18.3.1)
@@ -164,9 +167,12 @@ importers:
typescript:
specifier: ^5.0.3
version: 5.8.3
vite:
specifier: ^6.3.4
version: 6.3.4(@types/node@22.14.1)(yaml@2.7.1)
vitest:
specifier: ^3.0.9
version: 3.1.1(@types/node@22.14.1)(yaml@2.7.1)
specifier: ^3.1.2
version: 3.1.2(@types/node@22.14.1)(yaml@2.7.1)
whatwg-url:
specifier: ^14.2.0
version: 14.2.0
@@ -630,11 +636,11 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@vitest/expect@3.1.1':
resolution: {integrity: sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==}
'@vitest/expect@3.1.2':
resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==}
'@vitest/mocker@3.1.1':
resolution: {integrity: sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==}
'@vitest/mocker@3.1.2':
resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0
@@ -644,20 +650,20 @@ packages:
vite:
optional: true
'@vitest/pretty-format@3.1.1':
resolution: {integrity: sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==}
'@vitest/pretty-format@3.1.2':
resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==}
'@vitest/runner@3.1.1':
resolution: {integrity: sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==}
'@vitest/runner@3.1.2':
resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==}
'@vitest/snapshot@3.1.1':
resolution: {integrity: sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==}
'@vitest/snapshot@3.1.2':
resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==}
'@vitest/spy@3.1.1':
resolution: {integrity: sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==}
'@vitest/spy@3.1.2':
resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==}
'@vitest/utils@3.1.1':
resolution: {integrity: sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==}
'@vitest/utils@3.1.2':
resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
@@ -677,6 +683,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
@@ -1189,8 +1199,8 @@ packages:
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
fdir@6.4.3:
resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==}
fdir@6.4.4:
resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
@@ -1380,6 +1390,10 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
@@ -2195,8 +2209,8 @@ packages:
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.12:
resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==}
tinyglobby@0.2.13:
resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==}
engines: {node: '>=12.0.0'}
tinypool@1.0.2:
@@ -2316,13 +2330,13 @@ packages:
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
vite-node@3.1.1:
resolution: {integrity: sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==}
vite-node@3.1.2:
resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@6.3.1:
resolution: {integrity: sha512-kkzzkqtMESYklo96HKKPE5KKLkC1amlsqt+RjFMlX2AvbRB/0wghap19NdBxxwGZ+h/C6DLCrcEphPIItlGrRQ==}
vite@6.3.4:
resolution: {integrity: sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
@@ -2361,16 +2375,16 @@ packages:
yaml:
optional: true
vitest@3.1.1:
resolution: {integrity: sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==}
vitest@3.1.2:
resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.1.1
'@vitest/ui': 3.1.1
'@vitest/browser': 3.1.2
'@vitest/ui': 3.1.2
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
@@ -2863,43 +2877,43 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitest/expect@3.1.1':
'@vitest/expect@3.1.2':
dependencies:
'@vitest/spy': 3.1.1
'@vitest/utils': 3.1.1
'@vitest/spy': 3.1.2
'@vitest/utils': 3.1.2
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.1.1(vite@6.3.1(@types/node@22.14.1)(yaml@2.7.1))':
'@vitest/mocker@3.1.2(vite@6.3.4(@types/node@22.14.1)(yaml@2.7.1))':
dependencies:
'@vitest/spy': 3.1.1
'@vitest/spy': 3.1.2
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 6.3.1(@types/node@22.14.1)(yaml@2.7.1)
vite: 6.3.4(@types/node@22.14.1)(yaml@2.7.1)
'@vitest/pretty-format@3.1.1':
'@vitest/pretty-format@3.1.2':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.1.1':
'@vitest/runner@3.1.2':
dependencies:
'@vitest/utils': 3.1.1
'@vitest/utils': 3.1.2
pathe: 2.0.3
'@vitest/snapshot@3.1.1':
'@vitest/snapshot@3.1.2':
dependencies:
'@vitest/pretty-format': 3.1.1
'@vitest/pretty-format': 3.1.2
magic-string: 0.30.17
pathe: 2.0.3
'@vitest/spy@3.1.1':
'@vitest/spy@3.1.2':
dependencies:
tinyspy: 3.0.2
'@vitest/utils@3.1.1':
'@vitest/utils@3.1.2':
dependencies:
'@vitest/pretty-format': 3.1.1
'@vitest/pretty-format': 3.1.2
loupe: 3.1.3
tinyrainbow: 2.0.0
@@ -2917,6 +2931,8 @@ snapshots:
acorn@8.14.1: {}
agent-base@7.1.3: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
@@ -3583,7 +3599,7 @@ snapshots:
dependencies:
reusify: 1.1.0
fdir@6.4.3(picomatch@4.0.2):
fdir@6.4.4(picomatch@4.0.2):
optionalDependencies:
picomatch: 4.0.2
@@ -3781,6 +3797,13 @@ snapshots:
highlight.js@10.7.3: {}
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.4.0
transitivePeerDependencies:
- supports-color
human-signals@5.0.0: {}
humanize-ms@1.2.1:
@@ -4643,9 +4666,9 @@ snapshots:
tinyexec@0.3.2: {}
tinyglobby@0.2.12:
tinyglobby@0.2.13:
dependencies:
fdir: 6.4.3(picomatch@4.0.2)
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
tinypool@1.0.2: {}
@@ -4768,13 +4791,13 @@ snapshots:
v8-compile-cache-lib@3.0.1: {}
vite-node@3.1.1(@types/node@22.14.1)(yaml@2.7.1):
vite-node@3.1.2(@types/node@22.14.1)(yaml@2.7.1):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.6.0
pathe: 2.0.3
vite: 6.3.1(@types/node@22.14.1)(yaml@2.7.1)
vite: 6.3.4(@types/node@22.14.1)(yaml@2.7.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -4789,28 +4812,28 @@ snapshots:
- tsx
- yaml
vite@6.3.1(@types/node@22.14.1)(yaml@2.7.1):
vite@6.3.4(@types/node@22.14.1)(yaml@2.7.1):
dependencies:
esbuild: 0.25.2
fdir: 6.4.3(picomatch@4.0.2)
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
postcss: 8.5.3
rollup: 4.40.0
tinyglobby: 0.2.12
tinyglobby: 0.2.13
optionalDependencies:
'@types/node': 22.14.1
fsevents: 2.3.3
yaml: 2.7.1
vitest@3.1.1(@types/node@22.14.1)(yaml@2.7.1):
vitest@3.1.2(@types/node@22.14.1)(yaml@2.7.1):
dependencies:
'@vitest/expect': 3.1.1
'@vitest/mocker': 3.1.1(vite@6.3.1(@types/node@22.14.1)(yaml@2.7.1))
'@vitest/pretty-format': 3.1.1
'@vitest/runner': 3.1.1
'@vitest/snapshot': 3.1.1
'@vitest/spy': 3.1.1
'@vitest/utils': 3.1.1
'@vitest/expect': 3.1.2
'@vitest/mocker': 3.1.2(vite@6.3.4(@types/node@22.14.1)(yaml@2.7.1))
'@vitest/pretty-format': 3.1.2
'@vitest/runner': 3.1.2
'@vitest/snapshot': 3.1.2
'@vitest/spy': 3.1.2
'@vitest/utils': 3.1.2
chai: 5.2.0
debug: 4.4.0
expect-type: 1.2.1
@@ -4819,10 +4842,11 @@ snapshots:
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.13
tinypool: 1.0.2
tinyrainbow: 2.0.0
vite: 6.3.1(@types/node@22.14.1)(yaml@2.7.1)
vite-node: 3.1.1(@types/node@22.14.1)(yaml@2.7.1)
vite: 6.3.4(@types/node@22.14.1)(yaml@2.7.1)
vite-node: 3.1.2(@types/node@22.14.1)(yaml@2.7.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.14.1