mirror of
https://github.com/openai/codex.git
synced 2026-05-01 01:47:18 +00:00
Compare commits
45 Commits
rust-v.0.0
...
codex-rs-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c577e94b67 | ||
|
|
7d8b38b37b | ||
|
|
6f87f4c69f | ||
|
|
aa36a15f9f | ||
|
|
88e7ca5f2b | ||
|
|
147a940449 | ||
|
|
49d040215a | ||
|
|
5f1b8f707c | ||
|
|
2cf7aeeeb6 | ||
|
|
76a979007e | ||
|
|
7e97980cb4 | ||
|
|
2b72d05c5e | ||
|
|
5d924d44cf | ||
|
|
a134bdde49 | ||
|
|
cd12f0c24a | ||
|
|
421e159888 | ||
|
|
4b61fb8bab | ||
|
|
0442458309 | ||
|
|
a180ed44e8 | ||
|
|
21cd953dbd | ||
|
|
865e518771 | ||
|
|
83961e0299 | ||
|
|
f6b1ce2e3a | ||
|
|
b864cc3810 | ||
|
|
a4b51f6b67 | ||
|
|
3f5975ad5a | ||
|
|
463a230991 | ||
|
|
985fd44ec0 | ||
|
|
bc4e6db749 | ||
|
|
bd82101859 | ||
|
|
033d379eca | ||
|
|
e6fe8d6fa1 | ||
|
|
b571249867 | ||
|
|
24278347b7 | ||
|
|
8f7a54501c | ||
|
|
2f1d96e77d | ||
|
|
84aaefa102 | ||
|
|
c432d9ef81 | ||
|
|
4746ee900f | ||
|
|
f2ed46ceca | ||
|
|
e42dacbdc8 | ||
|
|
5122fe647f | ||
|
|
1a39568e03 | ||
|
|
efb0acc152 | ||
|
|
85999d7277 |
19
.github/dotslash-config.json
vendored
19
.github/dotslash-config.json
vendored
@@ -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" },
|
||||
@@ -18,12 +9,12 @@
|
||||
}
|
||||
},
|
||||
|
||||
"codex-cli": {
|
||||
"codex": {
|
||||
"platforms": {
|
||||
"macos-aarch64": { "regex": "^codex-cli-aarch64-apple-darwin\\.zst$", "path": "codex-cli" },
|
||||
"macos-x86_64": { "regex": "^codex-cli-x86_64-apple-darwin\\.zst$", "path": "codex-cli" },
|
||||
"linux-x86_64": { "regex": "^codex-cli-x86_64-unknown-linux-musl\\.zst$", "path": "codex-cli" },
|
||||
"linux-aarch64": { "regex": "^codex-cli-aarch64-unknown-linux-gnu\\.zst$", "path": "codex-cli" }
|
||||
"macos-aarch64": { "regex": "^codex-aarch64-apple-darwin\\.zst$", "path": "codex" },
|
||||
"macos-x86_64": { "regex": "^codex-x86_64-apple-darwin\\.zst$", "path": "codex" },
|
||||
"linux-x86_64": { "regex": "^codex-x86_64-unknown-linux-musl\\.zst$", "path": "codex" },
|
||||
"linux-aarch64": { "regex": "^codex-aarch64-unknown-linux-gnu\\.zst$", "path": "codex" }
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/rust-ci.yml
vendored
11
.github/workflows/rust-ci.yml
vendored
@@ -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 != ''
|
||||
|
||||
19
.github/workflows/rust-release.yml
vendored
19
.github/workflows/rust-release.yml
vendored
@@ -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,19 +102,20 @@ 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-cli "$dest/codex-cli-${{ 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: |
|
||||
dest="dist/${{ matrix.target }}"
|
||||
cp target/${{ matrix.target }}/release/codex-linux-sandbox "$dest/codex-linux-sandbox-${{ matrix.target }}"
|
||||
|
||||
- name: Compress artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
dest="dist/${{ matrix.target }}"
|
||||
zstd -T0 -19 --rm "$dest"/*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -141,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:
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
118
README.md
@@ -8,48 +8,48 @@
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of 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
3
codex-cli/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Added by ./scripts/install_native_deps.sh
|
||||
/bin/codex-linux-sandbox-arm64
|
||||
/bin/codex-linux-sandbox-x64
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
61
codex-cli/scripts/install_native_deps.sh
Executable file
61
codex-cli/scripts/install_native_deps.sh
Executable 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
|
||||
# repository’s 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 repo’s 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"
|
||||
|
||||
28
codex-cli/scripts/stage_release.sh
Executable file
28
codex-cli/scripts/stage_release.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 Ink’s 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"] ||
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
175
codex-cli/src/utils/agent/sandbox/landlock.ts
Normal file
175
codex-cli/src/utils/agent/sandbox/landlock.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
62
codex-cli/src/utils/file-tag-utils.ts
Normal file
62
codex-cli/src/utils/file-tag-utils.ts
Normal 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
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 low‑level exec implementation so we can verify that AbortSignal
|
||||
// correctly terminates a spawned process. We bypass the higher‑level 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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
240
codex-cli/tests/file-tag-utils.test.ts
Normal file
240
codex-cli/tests/file-tag-utils.test.ts
Normal 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.`);
|
||||
});
|
||||
});
|
||||
@@ -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 non‑zero 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);
|
||||
|
||||
@@ -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 in‑flight `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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -43,18 +43,17 @@ describe("TextBuffer – word‑wise 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 – word‑wise 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
95
codex-rs/Cargo.lock
generated
95
codex-rs/Cargo.lock
generated
@@ -469,13 +469,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-cli"
|
||||
version = "0.1.0"
|
||||
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.1.0"
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -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.1.0"
|
||||
version = "0.0.0"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
|
||||
@@ -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"]
|
||||
```
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
14
codex-rs/common/Cargo.toml
Normal file
14
codex-rs/common/Cargo.toml
Normal 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"]
|
||||
5
codex-rs/common/README.md
Normal file
5
codex-rs/common/README.md
Normal 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.
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
72
codex-rs/common/src/elapsed.rs
Normal file
72
codex-rs/common/src/elapsed.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
10
codex-rs/common/src/lib.rs
Normal file
10
codex-rs/common/src/lib.rs
Normal 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;
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(¬ification) else {
|
||||
tracing::error!("failed to serialise notification payload");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut command = std::process::Command::new(¬ify_command[0]);
|
||||
if notify_command.len() > 1 {
|
||||
command.args(¬ify_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(¶ms.command) {
|
||||
match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.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, ¶ms)
|
||||
.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, ¶ms)
|
||||
.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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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 CLI’s 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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms.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 2‑second timeout so even slow DNS timeouts
|
||||
// do not stall the suite.
|
||||
timeout_ms: Some(2_000),
|
||||
|
||||
162
codex-rs/core/src/mcp_connection_manager.rs
Normal file
162
codex-rs/core/src/mcp_connection_manager.rs
Normal 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)
|
||||
}
|
||||
14
codex-rs/core/src/mcp_server_config.rs
Normal file
14
codex-rs/core/src/mcp_server_config.rs
Normal 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>>,
|
||||
}
|
||||
99
codex-rs/core/src/mcp_tool_call.rs
Normal file
99
codex-rs/core/src/mcp_tool_call.rs
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
// Early‑exit 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 single‑entry 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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
40
codex-rs/core/src/user_notification.rs
Normal file
40
codex-rs/core/src/user_notification.rs
Normal 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(¬ification).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."}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// work‑trees and bare repos that live inside a work‑tree (common for
|
||||
/// developers running Codex locally).
|
||||
/// therefore fairly lightweight.
|
||||
///
|
||||
/// Note that this does **not** detect *work‑trees* 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 {
|
||||
// Best‑effort: 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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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}}
|
||||
|
||||
22
codex-rs/mcp-client/Cargo.toml
Normal file
22
codex-rs/mcp-client/Cargo.toml
Normal 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"
|
||||
3
codex-rs/mcp-client/src/lib.rs
Normal file
3
codex-rs/mcp-client/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod mcp_client;
|
||||
|
||||
pub use mcp_client::McpClient;
|
||||
46
codex-rs/mcp-client/src/main.rs
Normal file
46
codex-rs/mcp-client/src/main.rs
Normal 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(())
|
||||
}
|
||||
382
codex-rs/mcp-client/src/mcp_client.rs
Normal file
382
codex-rs/mcp-client/src/mcp_client.rs
Normal 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 JSON‐RPC 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(¶ms)?;
|
||||
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));
|
||||
}
|
||||
}
|
||||
23
codex-rs/mcp-server/Cargo.toml
Normal file
23
codex-rs/mcp-server/Cargo.toml
Normal 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"
|
||||
244
codex-rs/mcp-server/src/codex_tool_config.rs
Normal file
244
codex-rs/mcp-server/src/codex_tool_config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
181
codex-rs/mcp-server/src/codex_tool_runner.rs
Normal file
181
codex-rs/mcp-server/src/codex_tool_runner.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
codex-rs/mcp-server/src/main.rs
Normal file
114
codex-rs/mcp-server/src/main.rs
Normal 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(())
|
||||
}
|
||||
467
codex-rs/mcp-server/src/message_processor.rs
Normal file
467
codex-rs/mcp-server/src/message_processor.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
8
codex-rs/mcp-types/Cargo.toml
Normal file
8
codex-rs/mcp-types/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "mcp-types"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
8
codex-rs/mcp-types/README.md
Normal file
8
codex-rs/mcp-types/README.md
Normal 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
|
||||
660
codex-rs/mcp-types/generate_mcp_types.py
Executable file
660
codex-rs/mcp-types/generate_mcp_types.py
Executable 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())
|
||||
2139
codex-rs/mcp-types/schema/2025-03-26/schema.json
Normal file
2139
codex-rs/mcp-types/schema/2025-03-26/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
1400
codex-rs/mcp-types/src/lib.rs
Normal file
1400
codex-rs/mcp-types/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
67
codex-rs/mcp-types/tests/initialize.rs
Normal file
67
codex-rs/mcp-types/tests/initialize.rs
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
43
codex-rs/mcp-types/tests/progress_notification.rs
Normal file
43
codex-rs/mcp-types/tests/progress_notification.rs
Normal 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);
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -1,65 +0,0 @@
|
||||
use clap::ArgAction;
|
||||
use clap::Parser;
|
||||
use codex_core::ApprovalModeCliArg;
|
||||
use codex_core::SandboxPermissionOption;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Command‑line 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
|
||||
/// code‑base. 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 server‑side 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>,
|
||||
}
|
||||
@@ -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 single‑word 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 long‑running 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 in‑flight 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 front‑end 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
26
codex-rs/scripts/create_github_release.sh
Executable file
26
codex-rs/scripts/create_github_release.sh
Executable 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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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 up‑front 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 client‑side 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:?}"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
144
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user