diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 259e59ab310..d020dfe7344 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 ARG DEBIAN_FRONTEND=noninteractive # enable 'universe' because musl-tools & clang live there @@ -11,19 +11,17 @@ RUN apt-get update && \ RUN apt-get update && \ apt-get install -y --no-install-recommends \ build-essential curl git ca-certificates \ - pkg-config clang musl-tools libssl-dev && \ + pkg-config clang musl-tools libssl-dev just && \ rm -rf /var/lib/apt/lists/* -# non-root dev user -ARG USER=dev -ARG UID=1000 -RUN useradd -m -u $UID $USER -USER $USER +# Ubuntu 24.04 ships with user 'ubuntu' already created with UID 1000. +USER ubuntu # install Rust + musl target as dev user RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && \ - ~/.cargo/bin/rustup target add aarch64-unknown-linux-musl + ~/.cargo/bin/rustup target add aarch64-unknown-linux-musl && \ + ~/.cargo/bin/rustup component add clippy rustfmt -ENV PATH="/home/${USER}/.cargo/bin:${PATH}" +ENV PATH="/home/ubuntu/.cargo/bin:${PATH}" WORKDIR /workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 17aee91421f..f2768684840 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,15 +15,13 @@ "CARGO_TARGET_DIR": "${containerWorkspaceFolder}/codex-rs/target-arm64" }, - "remoteUser": "dev", + "remoteUser": "ubuntu", "customizations": { "vscode": { "settings": { - "terminal.integrated.defaultProfile.linux": "bash" + "terminal.integrated.defaultProfile.linux": "bash" }, - "extensions": [ - "rust-lang.rust-analyzer" - ], + "extensions": ["rust-lang.rust-analyzer"] } } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24697f2f78f..9d8675fa5fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,12 @@ jobs: GH_TOKEN: ${{ github.token }} run: pnpm stage-release - - name: Ensure README.md contains only ASCII and certain Unicode code points + - name: Ensure root README.md contains only ASCII and certain Unicode code points run: ./scripts/asciicheck.py README.md - - name: Check README ToC + - name: Check root README ToC run: python3 scripts/readme_toc.py README.md + + - name: Ensure codex-cli/README.md contains only ASCII and certain Unicode code points + run: ./scripts/asciicheck.py codex-cli/README.md + - name: Check codex-cli/README ToC + run: python3 scripts/readme_toc.py codex-cli/README.md diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 83f160757ab..6531af6a523 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -15,9 +15,6 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true -env: - TAG_REGEX: '^rust-v[0-9]+\.[0-9]+\.[0-9]+$' - jobs: tag-check: runs-on: ubuntu-latest @@ -33,8 +30,8 @@ jobs: # 1. Must be a tag and match the regex [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ${TAG_REGEX} ]] \ - || { echo "❌ Tag '${GITHUB_REF_NAME}' != ${TAG_REGEX}"; exit 1; } + [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ + || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } # 2. Extract versions tag_ver="${GITHUB_REF_NAME#rust-v}" @@ -160,9 +157,7 @@ jobs: release: needs: build name: release - runs-on: ubuntu-24.04 - env: - RELEASE_TAG: codex-rs-${{ github.sha }}-${{ github.run_attempt }}-${{ github.ref_name }} + runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -172,9 +167,19 @@ jobs: - name: List run: ls -R dist/ - - uses: softprops/action-gh-release@v2 + - name: Define release name + id: release_name + run: | + # Extract the version from the tag name, which is in the format + # "rust-v0.1.0". + version="${GITHUB_REF_NAME#rust-v}" + echo "name=${version}" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 with: - tag_name: ${{ env.RELEASE_TAG }} + name: ${{ steps.release_name.outputs.name }} + tag_name: ${{ github.ref_name }} files: dist/** # For now, tag releases as "prerelease" because we are not claiming # the Rust CLI is stable yet. @@ -184,5 +189,5 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tag: ${{ env.RELEASE_TAG }} + tag: ${{ github.ref_name }} config: .github/dotslash-config.json diff --git a/README.md b/README.md index 24f362f77f8..23eeb7c86c0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@

OpenAI Codex CLI

Lightweight coding agent that runs in your terminal

-

npm i -g @openai/codex

+

brew install codex

-![Codex demo GIF using: codex "explain this codebase to me"](./.github/demo.gif) +This is the home of the **Codex CLI**, which is a coding agent from OpenAI that runs locally on your computer. If you are looking for the _cloud-based agent_ from OpenAI, **Codex [Web]**, see . + + --- @@ -14,6 +16,8 @@ - [Experimental technology disclaimer](#experimental-technology-disclaimer) - [Quickstart](#quickstart) + - [OpenAI API Users](#openai-api-users) + - [OpenAI Plus/Pro Users](#openai-pluspro-users) - [Why Codex?](#why-codex) - [Security model & permissions](#security-model--permissions) - [Platform sandboxing details](#platform-sandboxing-details) @@ -21,24 +25,17 @@ - [CLI reference](#cli-reference) - [Memory & project docs](#memory--project-docs) - [Non-interactive / CI mode](#non-interactive--ci-mode) +- [Model Context Protocol (MCP)](#model-context-protocol-mcp) - [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) + - [DotSlash](#dotslash) +- [Configuration](#configuration) - [FAQ](#faq) - [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) - - [Debugging](#debugging) - [Writing high-impact code changes](#writing-high-impact-code-changes) - [Opening a pull request](#opening-a-pull-request) - [Review process](#review-process) @@ -47,8 +44,6 @@ - [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) - [License](#license) @@ -74,51 +69,91 @@ Help us improve by filing issues or submitting PRs (see the section below for ho Install globally: ```shell -npm install -g @openai/codex +brew install codex ``` +Or go to the [latest GitHub Release](https://github.com/openai/codex/releases/latest) and download the appropriate binary for your platform. + +### OpenAI API Users + Next, set your OpenAI API key as an environment variable: ```shell export OPENAI_API_KEY="your-api-key-here" ``` -> **Note:** This command sets the key only for your current terminal session. You can add the `export` line to your shell's configuration file (e.g., `~/.zshrc`) but we recommend setting for the session. **Tip:** You can also place your API key into a `.env` file at the root of your project: -> -> ```env -> OPENAI_API_KEY=your-api-key-here -> ``` -> -> The CLI will automatically load variables from `.env` (via `dotenv/config`). +> [!NOTE] +> This command sets the key only for your current terminal session. You can add the `export` line to your shell's configuration file (e.g., `~/.zshrc`), but we recommend setting it for the session. + +### OpenAI Plus/Pro Users + +If you have a paid OpenAI account, run the following to start the login process: + +``` +codex login +``` + +If you complete the process successfully, you should have a `~/.codex/auth.json` file that contains the credentials that Codex will use. + +If you encounter problems with the login flow, please comment on .
-Use --provider to use other models - -> Codex also allows you to use other providers that support the OpenAI Chat Completions API. You can set the provider in the config file or use the `--provider` flag. The possible options for `--provider` are: -> -> - openai (default) -> - openrouter -> - azure -> - gemini -> - ollama -> - mistral -> - deepseek -> - xai -> - groq -> - arceeai -> - any other provider that is compatible with the OpenAI API -> -> If you use a provider other than OpenAI, you will need to set the API key for the provider in the config file or in the environment variable as: -> -> ```shell -> export _API_KEY="your-api-key-here" -> ``` -> -> If you use a provider not listed above, you must also set the base URL for the provider: -> -> ```shell -> export _BASE_URL="https://your-provider-api-base-url" -> ``` +Use --profile to use other models + +Codex also allows you to use other providers that support the OpenAI Chat Completions (or Responses) API. + +To do so, you must first define custom [providers](./config.md#model_providers) in `~/.codex/config.toml`. For example, the provider for a standard Ollama setup would be defined as follows: + +```toml +[model_providers.ollama] +name = "Ollama" +base_url = "http://localhost:11434/v1" +``` + +The `base_url` will have `/chat/completions` appended to it to build the full URL for the request. + +For providers that also require an `Authorization` header of the form `Bearer: SECRET`, an `env_key` can be specified, which indicates the environment variable to read to use as the value of `SECRET` when making a request: + +```toml +[model_providers.openrouter] +name = "OpenRouter" +base_url = "https://openrouter.ai/api/v1" +env_key = "OPENROUTER_API_KEY" +``` + +Providers that speak the Responses API are also supported by adding `wire_api = "responses"` as part of the definition. Accessing OpenAI models via Azure is an example of such a provider, though it also requires specifying additional `query_params` that need to be appended to the request URL: + +```toml +[model_providers.azure] +name = "Azure" +# Make sure you set the appropriate subdomain for this URL. +base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" +env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use. +# Newer versions appear to support the responses API, see https://github.com/openai/codex/pull/1321 +query_params = { api-version = "2025-04-01-preview" } +wire_api = "responses" +``` + +Once you have defined a provider you wish to use, you can configure it as your default provider as follows: + +```toml +model_provider = "azure" +``` + +> [!TIP] +> If you find yourself experimenting with a variety of models and providers, then you likely want to invest in defining a _profile_ for each configuration like so: + +```toml +[profiles.o3] +model_provider = "azure" +model = "o3" + +[profiles.mistral] +model_provider = "ollama" +model = "mistral" +``` + +This way, you can specify one command-line argument (.e.g., `--profile o3`, `--profile mistral`) to override multiple settings together.

@@ -136,7 +171,7 @@ codex "explain this codebase to me" ``` ```shell -codex --approval-mode full-auto "create the fanciest todo-list app" +codex --full-auto "create the fanciest todo-list app" ``` That's it - Codex will scaffold a file, run it inside a sandbox, install any @@ -162,41 +197,35 @@ And it's **fully open-source** so you can see and contribute to how it develops! ## 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): +Codex lets you decide _how much autonomy_ you want to grant the agent. The following options can be configured independently: -| Mode | What the agent may do without asking | Still requires approval | -| ------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| **Suggest**
(default) |
  • Read any file in the repo |
  • **All** file writes/patches
  • **Any** arbitrary shell commands (aside from reading files) | -| **Auto Edit** |
  • Read **and** apply-patch writes to files |
  • **All** shell commands | -| **Full Auto** |
  • Read/write files
  • Execute shell commands (network disabled, writes limited to your workdir) | - | +- [`approval_policy`](./codex-rs/config.md#approval_policy) determines when you should be prompted to approve whether Codex can execute a command +- [`sandbox`](./codex-rs/config.md#sandbox) determines the _sandbox policy_ that Codex uses to execute untrusted commands -In **Full Auto** every command is run **network-disabled** and confined to the -current working directory (plus temporary files) for defense-in-depth. Codex -will also show a warning/confirmation if you start in **auto-edit** or -**full-auto** while the directory is _not_ tracked by Git, so you always have a -safety net. +By default, Codex runs with `approval_policy = "untrusted"` and `sandbox.mode = "read-only"`, which means that: -Coming soon: you'll be able to whitelist specific commands to auto-execute with -the network enabled, once we're confident in additional safeguards. +- The user is prompted to approve every command not on the set of "trusted" commands built into Codex (`cat`, `ls`, etc.) +- Approved commands are run outside of a sandbox because user approval implies "trust," in this case. -### Platform sandboxing details +Though running Codex with the `--full-auto` option changes the configuration to `approval_policy = "on-failure"` and `sandbox.mode = "workspace-write"`, which means that: + +- Codex does not initially ask for user approval before running an individual command. +- Though when it runs a command, it is run under a sandbox in which: + - It can read any file on the system. + - It can only write files under the current directory (or the directory specified via `--cd`). + - Network requests are completely disabled. +- Only if the command exits with a non-zero exit code will it ask the user for approval. If granted, it will re-attempt the command outside of the sandbox. (A common case is when Codex cannot `npm install` a dependency because that requires network access.) + +Again, these two options can be configured independently. For example, if you want Codex to perform an "exploration" where you are happy for it to read anything it wants but you never want to be prompted, you could run Codex with `approval_policy = "never"` and `sandbox.mode = "read-only"`. -The hardening mechanism Codex uses depends on your OS: +### Platform sandboxing details -- **macOS 12+** - commands are wrapped with **Apple Seatbelt** (`sandbox-exec`). +The mechanism Codex uses to implement the sandbox policy depends on your OS: - - Everything is placed in a read-only jail except for a small set of - writable roots (`$PWD`, `$TMPDIR`, `~/.codex`, etc.). - - Outbound network is _fully blocked_ by default - even if a child process - tries to `curl` somewhere it will fail. +- **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `sandbox.mode` that was specified. +- **Linux** uses a combination of Landlock/seccomp APIs to enforce the `sandbox` configuration. -- **Linux** - there is no sandboxing by default. - We recommend using Docker for sandboxing, where Codex launches itself inside a **minimal - container image** and mounts your repo _read/write_ at the same path. A - custom `iptables`/`ipset` firewall script denies all egress except the - OpenAI API. This gives you deterministic, reproducible runs without needing - root on the host. You can use the [`run_in_container.sh`](./codex-cli/scripts/run_in_container.sh) script to set up the sandbox. +Note that when running Linux in a containerized environment such as Docker, sandboxing may not work if the host/container configuration does not support the necessary Landlock/seccomp APIs. In such cases, we recommend configuring your Docker container so that it provides the sandbox guarantees you are looking for and then running `codex` with `sandbox.mode = "danger-full-access"` (or more simply, the `--dangerously-bypass-approvals-and-sandbox` flag) within your container. --- @@ -205,24 +234,20 @@ The hardening mechanism Codex uses depends on your OS: | Requirement | Details | | --------------------------- | --------------------------------------------------------------- | | Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** | -| Node.js | **22 or newer** (LTS recommended) | | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | -> Never run `sudo npm install -g`; fix npm permissions instead. - --- ## CLI reference -| Command | Purpose | Example | -| ------------------------------------ | ----------------------------------- | ------------------------------------ | -| `codex` | Interactive REPL | `codex` | -| `codex "..."` | Initial prompt for interactive REPL | `codex "fix lint errors"` | -| `codex -q "..."` | Non-interactive "quiet mode" | `codex -q --json "explain utils.ts"` | -| `codex completion ` | Print shell completion script | `codex completion bash` | +| Command | Purpose | Example | +| ------------------ | ---------------------------------- | ------------------------------- | +| `codex` | Interactive TUI | `codex` | +| `codex "..."` | Initial prompt for interactive TUI | `codex "fix lint errors"` | +| `codex exec "..."` | Non-interactive "automation mode" | `codex exec "explain utils.ts"` | -Key flags: `--model/-m`, `--approval-mode/-a`, `--quiet/-q`, and `--notify`. +Key flags: `--model/-m`, `--ask-for-approval/-a`. --- @@ -234,8 +259,6 @@ You can give Codex extra instructions and guidance using `AGENTS.md` files. Code 2. `AGENTS.md` at repo root - shared project notes 3. `AGENTS.md` in the current working directory - sub-folder/feature specifics -Disable loading of these files with `--no-project-doc` or the environment variable `CODEX_DISABLE_PROJECT_DOC=1`. - --- ## Non-interactive / CI mode @@ -245,21 +268,40 @@ Run Codex head-less in pipelines. Example GitHub Action step: ```yaml - name: Update changelog via Codex run: | - npm install -g @openai/codex + npm install -g @openai/codex@native # Note: we plan to drop the need for `@native`. export OPENAI_API_KEY="${{ secrets.OPENAI_KEY }}" - codex -a auto-edit --quiet "update CHANGELOG for next release" + codex exec --full-auto "update CHANGELOG for next release" +``` + +## Model Context Protocol (MCP) + +The Codex CLI can be configured to leverage MCP servers by defining an [`mcp_servers`](./codex-rs/config.md#mcp_servers) section in `~/.codex/config.toml`. It is intended to mirror how tools such as Claude and Cursor define `mcpServers` in their respective JSON config files, though the Codex format is slightly different since it uses TOML rather than JSON, e.g.: + +```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" } ``` -Set `CODEX_QUIET_MODE=1` to silence interactive UI noise. +> [!TIP] +> It is somewhat experimental, but the Codex CLI can also be run as an MCP _server_ via `codex mcp`. If you launch it with an MCP client such as `npx @modelcontextprotocol/inspector codex mcp` and send it a `tools/list` request, you will see that there is only one tool, `codex`, that accepts a grab-bag of inputs, including a catch-all `config` map for anything you might want to override. Feel free to play around with it and provide feedback via GitHub issues. ## Tracing / verbose logging -Setting the environment variable `DEBUG=true` prints full API request and response details: +Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior. -```shell -DEBUG=true codex +The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written: + +``` +tail -F ~/.codex/log/codex-tui.log ``` +By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file. + +See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) for more information on the configuration options. + --- ## Recipes @@ -281,201 +323,70 @@ Below are a few bite-size examples you can copy-paste. Replace the text in quote ## Installation
    -From npm (Recommended) +From brew (Recommended) ```bash -npm install -g @openai/codex -# or -yarn global add @openai/codex -# or -bun install -g @openai/codex -# or -pnpm add -g @openai/codex +brew install codex ``` -
    +Or go to the [latest GitHub Release](https://github.com/openai/codex/releases/latest) and download the appropriate binary for your platform. -
    -Build from source +Admittedly, each GitHub Release contains many executables, but in practice, you likely want one of these: -```bash -# Clone the repository and navigate to the CLI package -git clone https://github.com/openai/codex.git -cd codex/codex-cli - -# Enable corepack -corepack enable - -# Install dependencies and build -pnpm install -pnpm build - -# Linux-only: download prebuilt sandboxing binaries (requires gh and zstd). -./scripts/install_native_deps.sh +- macOS + - Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz` + - x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz` +- Linux + - x86_64: `codex-x86_64-unknown-linux-musl.tar.gz` + - arm64: `codex-aarch64-unknown-linux-musl.tar.gz` -# Get the usage and the options -node ./dist/cli.js --help +Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it. -# Run the locally-built CLI directly -node ./dist/cli.js +### DotSlash -# Or link the command globally for convenience -pnpm link -``` +The GitHub Release also contains a [DotSlash](https://dotslash-cli.com/) file for the Codex CLI named `codex`. Using a DotSlash file makes it possible to make a lightweight commit to source control to ensure all contributors use the same version of an executable, regardless of what platform they use for development.
    ---- - -## Configuration guide - -Codex configuration files can be placed in the `~/.codex/` directory, supporting both YAML and JSON formats. - -### Basic configuration parameters - -| Parameter | Type | Default | Description | Available Options | -| ------------------- | ------- | ---------- | -------------------------------- | ---------------------------------------------------------------------------------------------- | -| `model` | string | `o4-mini` | AI model to use | Any model name supporting OpenAI API | -| `approvalMode` | string | `suggest` | AI assistant's permission mode | `suggest` (suggestions only)
    `auto-edit` (automatic edits)
    `full-auto` (fully automatic) | -| `fullAutoErrorMode` | string | `ask-user` | Error handling in full-auto mode | `ask-user` (prompt for user input)
    `ignore-and-continue` (ignore and proceed) | -| `notify` | boolean | `true` | Enable desktop notifications | `true`/`false` | - -### Custom AI provider configuration - -In the `providers` object, you can configure multiple AI service providers. Each provider requires the following parameters: - -| Parameter | Type | Description | Example | -| --------- | ------ | --------------------------------------- | ----------------------------- | -| `name` | string | Display name of the provider | `"OpenAI"` | -| `baseURL` | string | API service URL | `"https://api.openai.com/v1"` | -| `envKey` | string | Environment variable name (for API key) | `"OPENAI_API_KEY"` | - -### History configuration - -In the `history` object, you can configure conversation history settings: - -| Parameter | Type | Description | Example Value | -| ------------------- | ------- | ------------------------------------------------------ | ------------- | -| `maxSize` | number | Maximum number of history entries to save | `1000` | -| `saveHistory` | boolean | Whether to save history | `true` | -| `sensitivePatterns` | array | Patterns of sensitive information to filter in history | `[]` | +
    +Build from source -### Configuration examples +```bash +# Clone the repository and navigate to the root of the Cargo workspace. +git clone https://github.com/openai/codex.git +cd codex/codex-rs -1. YAML format (save as `~/.codex/config.yaml`): +# Install the Rust toolchain, if necessary. +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +source "$HOME/.cargo/env" +rustup component add rustfmt +rustup component add clippy -```yaml -model: o4-mini -approvalMode: suggest -fullAutoErrorMode: ask-user -notify: true -``` +# Build Codex. +cargo build -2. JSON format (save as `~/.codex/config.json`): +# Launch the TUI with a sample prompt. +cargo run --bin codex -- "explain this codebase to me" -```json -{ - "model": "o4-mini", - "approvalMode": "suggest", - "fullAutoErrorMode": "ask-user", - "notify": true -} -``` +# After making changes, ensure the code is clean. +cargo fmt -- --config imports_granularity=Item +cargo clippy --tests -### Full configuration example - -Below is a comprehensive example of `config.json` with multiple custom providers: - -```json -{ - "model": "o4-mini", - "provider": "openai", - "providers": { - "openai": { - "name": "OpenAI", - "baseURL": "https://api.openai.com/v1", - "envKey": "OPENAI_API_KEY" - }, - "azure": { - "name": "AzureOpenAI", - "baseURL": "https://YOUR_PROJECT_NAME.openai.azure.com/openai", - "envKey": "AZURE_OPENAI_API_KEY" - }, - "openrouter": { - "name": "OpenRouter", - "baseURL": "https://openrouter.ai/api/v1", - "envKey": "OPENROUTER_API_KEY" - }, - "gemini": { - "name": "Gemini", - "baseURL": "https://generativelanguage.googleapis.com/v1beta/openai", - "envKey": "GEMINI_API_KEY" - }, - "ollama": { - "name": "Ollama", - "baseURL": "http://localhost:11434/v1", - "envKey": "OLLAMA_API_KEY" - }, - "mistral": { - "name": "Mistral", - "baseURL": "https://api.mistral.ai/v1", - "envKey": "MISTRAL_API_KEY" - }, - "deepseek": { - "name": "DeepSeek", - "baseURL": "https://api.deepseek.com", - "envKey": "DEEPSEEK_API_KEY" - }, - "xai": { - "name": "xAI", - "baseURL": "https://api.x.ai/v1", - "envKey": "XAI_API_KEY" - }, - "groq": { - "name": "Groq", - "baseURL": "https://api.groq.com/openai/v1", - "envKey": "GROQ_API_KEY" - }, - "arceeai": { - "name": "ArceeAI", - "baseURL": "https://conductor.arcee.ai/v1", - "envKey": "ARCEEAI_API_KEY" - } - }, - "history": { - "maxSize": 1000, - "saveHistory": true, - "sensitivePatterns": [] - } -} +# Run the tests. +cargo test ``` -### Custom instructions - -You can create a `~/.codex/AGENTS.md` file to define custom guidance for the agent: - -```markdown -- Always respond with emojis -- Only use git commands when explicitly requested -``` +
    -### Environment variables setup +--- -For each AI provider, you need to set the corresponding API key in your environment variables. For example: +## Configuration -```bash -# OpenAI -export OPENAI_API_KEY="your-api-key-here" +Codex supports a rich set of configuration options documented in [`codex-rs/config.md`](./codex-rs/config.md). -# Azure OpenAI -export AZURE_OPENAI_API_KEY="your-azure-api-key-here" -export AZURE_OPENAI_API_VERSION="2025-03-01-preview" (Optional) +By default, Codex loads its configuration from `~/.codex/config.toml`. -# OpenRouter -export OPENROUTER_API_KEY="your-openrouter-key-here" - -# Similarly for other providers -``` +Though `--config` can be used to set/override ad-hoc config values for individual invocations of `codex`. --- @@ -524,7 +435,13 @@ Codex CLI **does** support OpenAI organizations with [Zero Data Retention (ZDR)] OpenAI rejected the request. Error details: Status: 400, Code: unsupported_parameter, Type: invalid_request_error, Message: 400 Previous response cannot be used for this organization due to Zero Data Retention. ``` -You may need to upgrade to a more recent version with: `npm i -g @openai/codex@latest` +Ensure you are running `codex` with `--config disable_response_storage=true` or add this line to `~/.codex/config.toml` to avoid specifying the command line option each time: + +```toml +disable_response_storage = true +``` + +See [the configuration documentation on `disable_response_storage`](./codex-rs/config.md#disable_response_storage) for details. --- @@ -549,51 +466,7 @@ More broadly we welcome contributions - whether you are opening your very first - Create a _topic branch_ from `main` - e.g. `feat/interactive-prompt`. - Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs. -- Use `pnpm test:watch` during development for super-fast feedback. -- 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 - -This project uses [Husky](https://typicode.github.io/husky/) to enforce code quality checks: - -- **Pre-commit hook**: Automatically runs lint-staged to format and lint files before committing -- **Pre-push hook**: Runs tests and type checking before pushing to the remote - -These hooks help maintain code quality and prevent pushing code with failing tests. For more details, see [HUSKY.md](./codex-cli/HUSKY.md). - -```bash -pnpm test && pnpm run lint && pnpm run typecheck -``` - -- If you have **not** yet signed the Contributor License Agreement (CLA), add a PR comment containing the exact text - - ```text - I have read the CLA Document and I hereby sign the CLA - ``` - - The CLA-Assistant bot will turn the PR status green once all authors have signed. - -```bash -# Watch mode (tests rerun on change) -pnpm test:watch - -# Type-check without emitting files -pnpm typecheck - -# Automatically fix lint + prettier issues -pnpm lint:fix -pnpm format:fix -``` - -### Debugging - -To debug the CLI with a visual debugger, do the following in the `codex-cli` folder: - -- Run `pnpm run build` to build the CLI, which will generate `cli.js.map` alongside `cli.js` in the `dist` folder. -- Run the CLI with `node --inspect-brk ./dist/cli.js` The program then waits until a debugger is attached before proceeding. Options: - - In VS Code, choose **Debug: Attach to Node Process** from the command palette and choose the option in the dropdown with debug port `9229` (likely the first option) - - Go to in Chrome and find **localhost:9229** and click **trace** +- Following the [development setup](#development-workflow) instructions above, ensure your change is free of lint warnings and test failures. ### Writing high-impact code changes @@ -605,7 +478,7 @@ To debug the CLI with a visual debugger, do the following in the `codex-cli` fol ### Opening a pull request - Fill in the PR template (or include similar information) - **What? Why? How?** -- Run **all** checks locally (`npm test && npm run lint && npm run typecheck`). CI failures that could have been caught locally slow down the process. +- Run **all** checks locally (`cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item`). CI failures that could have been caught locally slow down the process. - Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. - Mark the PR as **Ready for review** only when you believe it is in a merge-able state. @@ -652,73 +525,22 @@ The **DCO check** blocks merges until every commit in the PR carries the footer ### Releasing `codex` -To publish a new version of the CLI you first need to stage the npm package. A -helper script in `codex-cli/scripts/` does all the heavy lifting. Inside the -`codex-cli` folder run: - -```bash -# Classic, JS implementation that includes small, native binaries for Linux sandboxing. -pnpm stage-release - -# Optionally specify the temp directory to reuse between runs. -RELEASE_DIR=$(mktemp -d) -pnpm stage-release --tmp "$RELEASE_DIR" - -# "Fat" package that additionally bundles the native Rust CLI binaries for -# Linux. End-users can then opt-in at runtime by setting CODEX_RUST=1. -pnpm stage-release --native -``` - -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 -``` +_For admins only._ -### Alternative build options +Make sure you are on `main` and have no local changes. Then run: -#### Nix flake development - -Prerequisite: Nix >= 2.4 with flakes enabled (`experimental-features = nix-command flakes` in `~/.config/nix/nix.conf`). - -Enter a Nix development shell: - -```bash -# Use either one of the commands according to which implementation you want to work with -nix develop .#codex-cli # For entering codex-cli specific shell -nix develop .#codex-rs # For entering codex-rs specific shell -``` - -This shell includes Node.js, installs dependencies, builds the CLI, and provides a `codex` command alias. - -Build and run the CLI directly: - -```bash -# Use either one of the commands according to which implementation you want to work with -nix build .#codex-cli # For building codex-cli -nix build .#codex-rs # For building codex-rs -./result/bin/codex --help +```shell +VERSION=0.2.0 # Can also be 0.2.0-alpha.1 or any valid Rust version. +./codex-rs/scripts/create_github_release.sh "$VERSION" ``` -Run the CLI via the flake app: +This will make a local commit on top of `main` with `version` set to `$VERSION` in `codex-rs/Cargo.toml` (note that on `main`, we leave the version as `version = "0.0.0"`). -```bash -# Use either one of the commands according to which implementation you want to work with -nix run .#codex-cli # For running codex-cli -nix run .#codex-rs # For running codex-rs -``` +This will push the commit using the tag `rust-v${VERSION}`, which in turn kicks off [the release workflow](.github/workflows/rust-release.yml). This will create a new GitHub Release named `$VERSION`. -Use direnv with flakes +If everything looks good in the generated GitHub Release, uncheck the **pre-release** box so it is the latest release. -If you have direnv installed, you can use the following `.envrc` to automatically enter the Nix shell when you `cd` into the project directory: - -```bash -cd codex-rs -echo "use flake ../flake.nix#codex-cli" >> .envrc && direnv allow -cd codex-cli -echo "use flake ../flake.nix#codex-rs" >> .envrc && direnv allow -``` +Create a PR to update [`Formula/c/codex.rb`](https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb) on Homebrew. --- diff --git a/codex-cli/README.md b/codex-cli/README.md new file mode 100644 index 00000000000..e988b384ab2 --- /dev/null +++ b/codex-cli/README.md @@ -0,0 +1,736 @@ +

    OpenAI Codex CLI

    +

    Lightweight coding agent that runs in your terminal

    + +

    npm i -g @openai/codex

    + +> [!IMPORTANT] +> This is the documentation for the _legacy_ TypeScript implementation of the Codex CLI. It has been superseded by the _Rust_ implementation. See the [README in the root of the Codex repository](https://github.com/openai/codex/blob/main/README.md) for details. + +![Codex demo GIF using: codex "explain this codebase to me"](../.github/demo.gif) + +--- + +
    +Table of contents + + + +- [Experimental technology disclaimer](#experimental-technology-disclaimer) +- [Quickstart](#quickstart) +- [Why Codex?](#why-codex) +- [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) +- [Non-interactive / CI mode](#non-interactive--ci-mode) +- [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) +- [FAQ](#faq) +- [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) + - [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) + - [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) +- [License](#license) + + + +
    + +--- + +## 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: + +- Bug reports +- Feature requests +- Pull requests +- Good vibes + +Help us improve by filing issues or submitting PRs (see the section below for how to contribute)! + +## Quickstart + +Install globally: + +```shell +npm install -g @openai/codex +``` + +Next, set your OpenAI API key as an environment variable: + +```shell +export OPENAI_API_KEY="your-api-key-here" +``` + +> **Note:** This command sets the key only for your current terminal session. You can add the `export` line to your shell's configuration file (e.g., `~/.zshrc`) but we recommend setting for the session. **Tip:** You can also place your API key into a `.env` file at the root of your project: +> +> ```env +> OPENAI_API_KEY=your-api-key-here +> ``` +> +> The CLI will automatically load variables from `.env` (via `dotenv/config`). + +
    +Use --provider to use other models + +> Codex also allows you to use other providers that support the OpenAI Chat Completions API. You can set the provider in the config file or use the `--provider` flag. The possible options for `--provider` are: +> +> - openai (default) +> - openrouter +> - azure +> - gemini +> - ollama +> - mistral +> - deepseek +> - xai +> - groq +> - arceeai +> - any other provider that is compatible with the OpenAI API +> +> If you use a provider other than OpenAI, you will need to set the API key for the provider in the config file or in the environment variable as: +> +> ```shell +> export _API_KEY="your-api-key-here" +> ``` +> +> If you use a provider not listed above, you must also set the base URL for the provider: +> +> ```shell +> export _BASE_URL="https://your-provider-api-base-url" +> ``` + +
    +
    + +Run interactively: + +```shell +codex +``` + +Or, run with a prompt as input (and optionally in `Full Auto` mode): + +```shell +codex "explain this codebase to me" +``` + +```shell +codex --approval-mode full-auto "create the fanciest todo-list app" +``` + +That's it - Codex will scaffold a file, run it inside a sandbox, install any +missing dependencies, and show you the live result. Approve the changes and +they'll be committed to your working directory. + +--- + +## Why Codex? + +Codex CLI is built for developers who already **live in the terminal** and want +ChatGPT-level reasoning **plus** the power to actually run code, manipulate +files, and iterate - all under version control. In short, it's _chat-driven +development_ that understands and executes your repo. + +- **Zero setup** - bring your OpenAI API key and it just works! +- **Full auto-approval, while safe + secure** by running network-disabled and directory-sandboxed +- **Multimodal** - pass in screenshots or diagrams to implement features ✨ + +And it's **fully open-source** so you can see and contribute to how it develops! + +--- + +## 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): + +| Mode | What the agent may do without asking | Still requires approval | +| ------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| **Suggest**
    (default) |
  • Read any file in the repo |
  • **All** file writes/patches
  • **Any** arbitrary shell commands (aside from reading files) | +| **Auto Edit** |
  • Read **and** apply-patch writes to files |
  • **All** shell commands | +| **Full Auto** |
  • Read/write files
  • Execute shell commands (network disabled, writes limited to your workdir) | - | + +In **Full Auto** every command is run **network-disabled** and confined to the +current working directory (plus temporary files) for defense-in-depth. Codex +will also show a warning/confirmation if you start in **auto-edit** or +**full-auto** while the directory is _not_ tracked by Git, so you always have a +safety net. + +Coming soon: you'll be able to whitelist specific commands to auto-execute with +the network enabled, once we're confident in additional safeguards. + +### Platform sandboxing details + +The hardening mechanism Codex uses depends on your OS: + +- **macOS 12+** - commands are wrapped with **Apple Seatbelt** (`sandbox-exec`). + + - Everything is placed in a read-only jail except for a small set of + writable roots (`$PWD`, `$TMPDIR`, `~/.codex`, etc.). + - Outbound network is _fully blocked_ by default - even if a child process + tries to `curl` somewhere it will fail. + +- **Linux** - there is no sandboxing by default. + We recommend using Docker for sandboxing, where Codex launches itself inside a **minimal + container image** and mounts your repo _read/write_ at the same path. A + custom `iptables`/`ipset` firewall script denies all egress except the + OpenAI API. This gives you deterministic, reproducible runs without needing + root on the host. You can use the [`run_in_container.sh`](../codex-cli/scripts/run_in_container.sh) script to set up the sandbox. + +--- + +## System requirements + +| Requirement | Details | +| --------------------------- | --------------------------------------------------------------- | +| Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** | +| Node.js | **22 or newer** (LTS recommended) | +| Git (optional, recommended) | 2.23+ for built-in PR helpers | +| RAM | 4-GB minimum (8-GB recommended) | + +> Never run `sudo npm install -g`; fix npm permissions instead. + +--- + +## CLI reference + +| Command | Purpose | Example | +| ------------------------------------ | ----------------------------------- | ------------------------------------ | +| `codex` | Interactive REPL | `codex` | +| `codex "..."` | Initial prompt for interactive REPL | `codex "fix lint errors"` | +| `codex -q "..."` | Non-interactive "quiet mode" | `codex -q --json "explain utils.ts"` | +| `codex completion ` | Print shell completion script | `codex completion bash` | + +Key flags: `--model/-m`, `--approval-mode/-a`, `--quiet/-q`, and `--notify`. + +--- + +## Memory & project docs + +You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for `AGENTS.md` files in the following places, and merges them top-down: + +1. `~/.codex/AGENTS.md` - personal global guidance +2. `AGENTS.md` at repo root - shared project notes +3. `AGENTS.md` in the current working directory - sub-folder/feature specifics + +Disable loading of these files with `--no-project-doc` or the environment variable `CODEX_DISABLE_PROJECT_DOC=1`. + +--- + +## Non-interactive / CI mode + +Run Codex head-less in pipelines. Example GitHub Action step: + +```yaml +- name: Update changelog via Codex + run: | + npm install -g @openai/codex + export OPENAI_API_KEY="${{ secrets.OPENAI_KEY }}" + codex -a auto-edit --quiet "update CHANGELOG for next release" +``` + +Set `CODEX_QUIET_MODE=1` to silence interactive UI noise. + +## Tracing / verbose logging + +Setting the environment variable `DEBUG=true` prints full API request and response details: + +```shell +DEBUG=true codex +``` + +--- + +## Recipes + +Below are a few bite-size examples you can copy-paste. Replace the text in quotes with your own task. See the [prompting guide](https://github.com/openai/codex/blob/main/codex-cli/examples/prompting_guide.md) for more tips and usage patterns. + +| ✨ | What you type | What happens | +| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| 1 | `codex "Refactor the Dashboard component to React Hooks"` | Codex rewrites the class component, runs `npm test`, and shows the diff. | +| 2 | `codex "Generate SQL migrations for adding a users table"` | Infers your ORM, creates migration files, and runs them in a sandboxed DB. | +| 3 | `codex "Write unit tests for utils/date.ts"` | Generates tests, executes them, and iterates until they pass. | +| 4 | `codex "Bulk-rename *.jpeg -> *.jpg with git mv"` | Safely renames files and updates imports/usages. | +| 5 | `codex "Explain what this regex does: ^(?=.*[A-Z]).{8,}$"` | Outputs a step-by-step human explanation. | +| 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. | +| 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. | + +--- + +## Installation + +
    +From npm (Recommended) + +```bash +npm install -g @openai/codex +# or +yarn global add @openai/codex +# or +bun install -g @openai/codex +# or +pnpm add -g @openai/codex +``` + +
    + +
    +Build from source + +```bash +# Clone the repository and navigate to the CLI package +git clone https://github.com/openai/codex.git +cd codex/codex-cli + +# Enable corepack +corepack enable + +# Install dependencies and build +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 + +# Run the locally-built CLI directly +node ./dist/cli.js + +# Or link the command globally for convenience +pnpm link +``` + +
    + +--- + +## Configuration guide + +Codex configuration files can be placed in the `~/.codex/` directory, supporting both YAML and JSON formats. + +### Basic configuration parameters + +| Parameter | Type | Default | Description | Available Options | +| ------------------- | ------- | ---------- | -------------------------------- | ---------------------------------------------------------------------------------------------- | +| `model` | string | `o4-mini` | AI model to use | Any model name supporting OpenAI API | +| `approvalMode` | string | `suggest` | AI assistant's permission mode | `suggest` (suggestions only)
    `auto-edit` (automatic edits)
    `full-auto` (fully automatic) | +| `fullAutoErrorMode` | string | `ask-user` | Error handling in full-auto mode | `ask-user` (prompt for user input)
    `ignore-and-continue` (ignore and proceed) | +| `notify` | boolean | `true` | Enable desktop notifications | `true`/`false` | + +### Custom AI provider configuration + +In the `providers` object, you can configure multiple AI service providers. Each provider requires the following parameters: + +| Parameter | Type | Description | Example | +| --------- | ------ | --------------------------------------- | ----------------------------- | +| `name` | string | Display name of the provider | `"OpenAI"` | +| `baseURL` | string | API service URL | `"https://api.openai.com/v1"` | +| `envKey` | string | Environment variable name (for API key) | `"OPENAI_API_KEY"` | + +### History configuration + +In the `history` object, you can configure conversation history settings: + +| Parameter | Type | Description | Example Value | +| ------------------- | ------- | ------------------------------------------------------ | ------------- | +| `maxSize` | number | Maximum number of history entries to save | `1000` | +| `saveHistory` | boolean | Whether to save history | `true` | +| `sensitivePatterns` | array | Patterns of sensitive information to filter in history | `[]` | + +### Configuration examples + +1. YAML format (save as `~/.codex/config.yaml`): + +```yaml +model: o4-mini +approvalMode: suggest +fullAutoErrorMode: ask-user +notify: true +``` + +2. JSON format (save as `~/.codex/config.json`): + +```json +{ + "model": "o4-mini", + "approvalMode": "suggest", + "fullAutoErrorMode": "ask-user", + "notify": true +} +``` + +### Full configuration example + +Below is a comprehensive example of `config.json` with multiple custom providers: + +```json +{ + "model": "o4-mini", + "provider": "openai", + "providers": { + "openai": { + "name": "OpenAI", + "baseURL": "https://api.openai.com/v1", + "envKey": "OPENAI_API_KEY" + }, + "azure": { + "name": "AzureOpenAI", + "baseURL": "https://YOUR_PROJECT_NAME.openai.azure.com/openai", + "envKey": "AZURE_OPENAI_API_KEY" + }, + "openrouter": { + "name": "OpenRouter", + "baseURL": "https://openrouter.ai/api/v1", + "envKey": "OPENROUTER_API_KEY" + }, + "gemini": { + "name": "Gemini", + "baseURL": "https://generativelanguage.googleapis.com/v1beta/openai", + "envKey": "GEMINI_API_KEY" + }, + "ollama": { + "name": "Ollama", + "baseURL": "http://localhost:11434/v1", + "envKey": "OLLAMA_API_KEY" + }, + "mistral": { + "name": "Mistral", + "baseURL": "https://api.mistral.ai/v1", + "envKey": "MISTRAL_API_KEY" + }, + "deepseek": { + "name": "DeepSeek", + "baseURL": "https://api.deepseek.com", + "envKey": "DEEPSEEK_API_KEY" + }, + "xai": { + "name": "xAI", + "baseURL": "https://api.x.ai/v1", + "envKey": "XAI_API_KEY" + }, + "groq": { + "name": "Groq", + "baseURL": "https://api.groq.com/openai/v1", + "envKey": "GROQ_API_KEY" + }, + "arceeai": { + "name": "ArceeAI", + "baseURL": "https://conductor.arcee.ai/v1", + "envKey": "ARCEEAI_API_KEY" + } + }, + "history": { + "maxSize": 1000, + "saveHistory": true, + "sensitivePatterns": [] + } +} +``` + +### Custom instructions + +You can create a `~/.codex/AGENTS.md` file to define custom guidance for the agent: + +```markdown +- Always respond with emojis +- Only use git commands when explicitly requested +``` + +### Environment variables setup + +For each AI provider, you need to set the corresponding API key in your environment variables. For example: + +```bash +# OpenAI +export OPENAI_API_KEY="your-api-key-here" + +# Azure OpenAI +export AZURE_OPENAI_API_KEY="your-azure-api-key-here" +export AZURE_OPENAI_API_VERSION="2025-04-01-preview" (Optional) + +# OpenRouter +export OPENROUTER_API_KEY="your-openrouter-key-here" + +# Similarly for other providers +``` + +--- + +## FAQ + +
    +OpenAI released a model called Codex in 2021 - is this related? + +In 2021, OpenAI released Codex, an AI system designed to generate code from natural language prompts. That original Codex model was deprecated as of March 2023 and is separate from the CLI tool. + +
    + +
    +Which models are supported? + +Any model available with [Responses API](https://platform.openai.com/docs/api-reference/responses). The default is `o4-mini`, but pass `--model gpt-4.1` or set `model: gpt-4.1` in your config file to override. + +
    +
    +Why does o3 or o4-mini not work for me? + +It's possible that your [API account needs to be verified](https://help.openai.com/en/articles/10910291-api-organization-verification) in order to start streaming responses and seeing chain of thought summaries from the API. If you're still running into issues, please let us know! + +
    + +
    +How do I stop Codex from editing my files? + +Codex runs model-generated commands in a sandbox. If a proposed command or file change doesn't look right, you can simply type **n** to deny the command or give the model feedback. + +
    +
    +Does it work on Windows? + +Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.microsoft.com/en-us/windows/wsl/install) - Codex has been tested on macOS and Linux with Node 22. + +
    + +--- + +## 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: + +``` +OpenAI rejected the request. Error details: Status: 400, Code: unsupported_parameter, Type: invalid_request_error, Message: 400 Previous response cannot be used for this organization due to Zero Data Retention. +``` + +You may need to upgrade to a more recent version with: `npm i -g @openai/codex@latest` + +--- + +## 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. + +- Grants are awarded up to **$25,000** API credits. +- Applications are reviewed **on a rolling basis**. + +**Interested? [Apply here](https://openai.com/form/codex-open-source-fund/).** + +--- + +## Contributing + +This project is under active development and the code will likely change pretty significantly. We'll update this message once that's complete! + +More broadly we welcome contributions - whether you are opening your very first pull request or you're a seasoned maintainer. At the same time we care about reliability and long-term maintainability, so the bar for merging code is intentionally **high**. The guidelines below spell out what "high-quality" means in practice and should make the whole process transparent and friendly. + +### Development workflow + +- Create a _topic branch_ from `main` - e.g. `feat/interactive-prompt`. +- Keep your changes focused. Multiple unrelated fixes should be opened as separate PRs. +- Use `pnpm test:watch` during development for super-fast feedback. +- 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 + +This project uses [Husky](https://typicode.github.io/husky/) to enforce code quality checks: + +- **Pre-commit hook**: Automatically runs lint-staged to format and lint files before committing +- **Pre-push hook**: Runs tests and type checking before pushing to the remote + +These hooks help maintain code quality and prevent pushing code with failing tests. For more details, see [HUSKY.md](./HUSKY.md). + +```bash +pnpm test && pnpm run lint && pnpm run typecheck +``` + +- If you have **not** yet signed the Contributor License Agreement (CLA), add a PR comment containing the exact text + + ```text + I have read the CLA Document and I hereby sign the CLA + ``` + + The CLA-Assistant bot will turn the PR status green once all authors have signed. + +```bash +# Watch mode (tests rerun on change) +pnpm test:watch + +# Type-check without emitting files +pnpm typecheck + +# Automatically fix lint + prettier issues +pnpm lint:fix +pnpm format:fix +``` + +### Debugging + +To debug the CLI with a visual debugger, do the following in the `codex-cli` folder: + +- Run `pnpm run build` to build the CLI, which will generate `cli.js.map` alongside `cli.js` in the `dist` folder. +- Run the CLI with `node --inspect-brk ./dist/cli.js` The program then waits until a debugger is attached before proceeding. Options: + - In VS Code, choose **Debug: Attach to Node Process** from the command palette and choose the option in the dropdown with debug port `9229` (likely the first option) + - Go to in Chrome and find **localhost:9229** and click **trace** + +### Writing high-impact code changes + +1. **Start with an issue.** Open a new one or comment on an existing discussion so we can agree on the solution before code is written. +2. **Add or update tests.** Every new feature or bug-fix should come with test coverage that fails before your change and passes afterwards. 100% coverage is not required, but aim for meaningful assertions. +3. **Document behaviour.** If your change affects user-facing behaviour, update the README, inline help (`codex --help`), or relevant example projects. +4. **Keep commits atomic.** Each commit should compile and the tests should pass. This makes reviews and potential rollbacks easier. + +### Opening a pull request + +- Fill in the PR template (or include similar information) - **What? Why? How?** +- Run **all** checks locally (`npm test && npm run lint && npm run typecheck`). CI failures that could have been caught locally slow down the process. +- Make sure your branch is up-to-date with `main` and that you have resolved merge conflicts. +- Mark the PR as **Ready for review** only when you believe it is in a merge-able state. + +### Review process + +1. One maintainer will be assigned as a primary reviewer. +2. We may ask for changes - please do not take this personally. We value the work, we just also value consistency and long-term maintainability. +3. When there is consensus that the PR meets the bar, a maintainer will squash-and-merge. + +### Community values + +- **Be kind and inclusive.** Treat others with respect; we follow the [Contributor Covenant](https://www.contributor-covenant.org/). +- **Assume good intent.** Written communication is hard - err on the side of generosity. +- **Teach & learn.** If you spot something confusing, open an issue or PR with improvements. + +### Getting help + +If you run into problems setting up the project, would like feedback on an idea, or just want to say _hi_ - please open a Discussion or jump into the relevant issue. We are happy to help. + +Together we can make Codex CLI an incredible tool. **Happy hacking!** :rocket: + +### Contributor license agreement (CLA) + +All contributors **must** accept the CLA. The process is lightweight: + +1. Open your pull request. +2. Paste the following comment (or reply `recheck` if you've signed before): + + ```text + I have read the CLA Document and I hereby sign the CLA + ``` + +3. The CLA-Assistant bot records your signature in the repo and marks the status check as passed. + +No special Git commands, email attachments, or commit footers required. + +#### Quick fixes + +| Scenario | Command | +| ----------------- | ------------------------------------------------ | +| Amend last commit | `git commit --amend -s --no-edit && git push -f` | + +The **DCO check** blocks merges until every commit in the PR carries the footer (with squash this is just the one). + +### Releasing `codex` + +To publish a new version of the CLI you first need to stage the npm package. A +helper script in `codex-cli/scripts/` does all the heavy lifting. Inside the +`codex-cli` folder run: + +```bash +# Classic, JS implementation that includes small, native binaries for Linux sandboxing. +pnpm stage-release + +# Optionally specify the temp directory to reuse between runs. +RELEASE_DIR=$(mktemp -d) +pnpm stage-release --tmp "$RELEASE_DIR" + +# "Fat" package that additionally bundles the native Rust CLI binaries for +# Linux. End-users can then opt-in at runtime by setting CODEX_RUST=1. +pnpm stage-release --native +``` + +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`). + +Enter a Nix development shell: + +```bash +# Use either one of the commands according to which implementation you want to work with +nix develop .#codex-cli # For entering codex-cli specific shell +nix develop .#codex-rs # For entering codex-rs specific shell +``` + +This shell includes Node.js, installs dependencies, builds the CLI, and provides a `codex` command alias. + +Build and run the CLI directly: + +```bash +# Use either one of the commands according to which implementation you want to work with +nix build .#codex-cli # For building codex-cli +nix build .#codex-rs # For building codex-rs +./result/bin/codex --help +``` + +Run the CLI via the flake app: + +```bash +# Use either one of the commands according to which implementation you want to work with +nix run .#codex-cli # For running codex-cli +nix run .#codex-rs # For running codex-rs +``` + +Use direnv with flakes + +If you have direnv installed, you can use the following `.envrc` to automatically enter the Nix shell when you `cd` into the project directory: + +```bash +cd codex-rs +echo "use flake ../flake.nix#codex-cli" >> .envrc && direnv allow +cd codex-cli +echo "use flake ../flake.nix#codex-rs" >> .envrc && direnv allow +``` + +--- + +## 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. + +--- + +## License + +This repository is licensed under the [Apache-2.0 License](LICENSE). diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index c7e5d9ff318..0442a6c3770 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -45,6 +45,7 @@ import { createInputItem } from "./utils/input-utils"; import { initLogger } from "./utils/logger/log"; import { isModelSupportedForResponses } from "./utils/model-utils.js"; import { parseToolCall } from "./utils/parsers"; +import { providers } from "./utils/providers"; import { onExit, setInkRenderer } from "./utils/terminal"; import chalk from "chalk"; import { spawnSync } from "child_process"; @@ -327,26 +328,44 @@ try { // ignore errors } -if (cli.flags.login) { - apiKey = await fetchApiKey(client.issuer, client.client_id); - try { - const home = os.homedir(); - const authDir = path.join(home, ".codex"); - const authFile = path.join(authDir, "auth.json"); - if (fs.existsSync(authFile)) { - const data = JSON.parse(fs.readFileSync(authFile, "utf-8")); - savedTokens = data.tokens; +// Get provider-specific API key if not OpenAI +if (provider.toLowerCase() !== "openai") { + const providerInfo = providers[provider.toLowerCase()]; + if (providerInfo) { + const providerApiKey = process.env[providerInfo.envKey]; + if (providerApiKey) { + apiKey = providerApiKey; } - } catch { - /* ignore */ } -} else if (!apiKey) { - apiKey = await fetchApiKey(client.issuer, client.client_id); } + +// Only proceed with OpenAI auth flow if: +// 1. Provider is OpenAI and no API key is set, or +// 2. Login flag is explicitly set +if (provider.toLowerCase() === "openai" && !apiKey) { + if (cli.flags.login) { + apiKey = await fetchApiKey(client.issuer, client.client_id); + try { + const home = os.homedir(); + const authDir = path.join(home, ".codex"); + const authFile = path.join(authDir, "auth.json"); + if (fs.existsSync(authFile)) { + const data = JSON.parse(fs.readFileSync(authFile, "utf-8")); + savedTokens = data.tokens; + } + } catch { + /* ignore */ + } + } else { + apiKey = await fetchApiKey(client.issuer, client.client_id); + } +} + // Ensure the API key is available as an environment variable for legacy code process.env["OPENAI_API_KEY"] = apiKey; -if (cli.flags.free) { +// Only attempt credit redemption for OpenAI provider +if (cli.flags.free && provider.toLowerCase() === "openai") { // eslint-disable-next-line no-console console.log(`${chalk.bold("codex --free")} attempting to redeem credits...`); if (!savedTokens?.refresh_token) { @@ -379,13 +398,18 @@ if (!apiKey && !NO_API_KEY_REQUIRED.has(provider.toLowerCase())) { ? `You can create a key here: ${chalk.bold( chalk.underline("https://platform.openai.com/account/api-keys"), )}\n` - : provider.toLowerCase() === "gemini" + : provider.toLowerCase() === "azure" ? `You can create a ${chalk.bold( - `${provider.toUpperCase()}_API_KEY`, - )} ` + `in the ${chalk.bold(`Google AI Studio`)}.\n` - : `You can create a ${chalk.bold( - `${provider.toUpperCase()}_API_KEY`, - )} ` + `in the ${chalk.bold(`${provider}`)} dashboard.\n` + `${provider.toUpperCase()}_OPENAI_API_KEY`, + )} ` + + `in Azure AI Foundry portal at ${chalk.bold(chalk.underline("https://ai.azure.com"))}.\n` + : provider.toLowerCase() === "gemini" + ? `You can create a ${chalk.bold( + `${provider.toUpperCase()}_API_KEY`, + )} ` + `in the ${chalk.bold(`Google AI Studio`)}.\n` + : `You can create a ${chalk.bold( + `${provider.toUpperCase()}_API_KEY`, + )} ` + `in the ${chalk.bold(`${provider}`)} dashboard.\n` }`, ); process.exit(1); diff --git a/codex-cli/src/utils/agent/agent-loop.ts b/codex-cli/src/utils/agent/agent-loop.ts index cc57239b40f..8a5adbeb23f 100644 --- a/codex-cli/src/utils/agent/agent-loop.ts +++ b/codex-cli/src/utils/agent/agent-loop.ts @@ -800,7 +800,8 @@ export class AgentLoop { const responseCall = !this.config.provider || - this.config.provider?.toLowerCase() === "openai" + this.config.provider?.toLowerCase() === "openai" || + this.config.provider?.toLowerCase() === "azure" ? (params: ResponseCreateParams) => this.oai.responses.create(params) : (params: ResponseCreateParams) => @@ -1188,7 +1189,8 @@ export class AgentLoop { const responseCall = !this.config.provider || - this.config.provider?.toLowerCase() === "openai" + this.config.provider?.toLowerCase() === "openai" || + this.config.provider?.toLowerCase() === "azure" ? (params: ResponseCreateParams) => this.oai.responses.create(params) : (params: ResponseCreateParams) => diff --git a/codex-cli/src/utils/config.ts b/codex-cli/src/utils/config.ts index 51761bf6d4d..3fafdb44e8f 100644 --- a/codex-cli/src/utils/config.ts +++ b/codex-cli/src/utils/config.ts @@ -69,7 +69,7 @@ export const OPENAI_BASE_URL = process.env["OPENAI_BASE_URL"] || ""; export let OPENAI_API_KEY = process.env["OPENAI_API_KEY"] || ""; export const AZURE_OPENAI_API_VERSION = - process.env["AZURE_OPENAI_API_VERSION"] || "2025-03-01-preview"; + process.env["AZURE_OPENAI_API_VERSION"] || "2025-04-01-preview"; export const DEFAULT_REASONING_EFFORT = "high"; export const OPENAI_ORGANIZATION = process.env["OPENAI_ORGANIZATION"] || ""; diff --git a/codex-cli/tests/agent-azure-responses-endpoint.test.ts b/codex-cli/tests/agent-azure-responses-endpoint.test.ts new file mode 100644 index 00000000000..aecf587150c --- /dev/null +++ b/codex-cli/tests/agent-azure-responses-endpoint.test.ts @@ -0,0 +1,107 @@ +/** + * tests/agent-azure-responses-endpoint.test.ts + * + * Verifies that AgentLoop calls the `/responses` endpoint when provider is set to Azure. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Fake stream that yields a completed response event +class FakeStream { + async *[Symbol.asyncIterator]() { + yield { + type: "response.completed", + response: { id: "azure_resp", status: "completed", output: [] }, + } as any; + } +} + +let lastCreateParams: any = null; + +vi.mock("openai", () => { + class FakeDefaultClient { + public responses = { + create: async (params: any) => { + lastCreateParams = params; + return new FakeStream(); + }, + }; + } + class FakeAzureClient { + public responses = { + create: async (params: any) => { + lastCreateParams = params; + return new FakeStream(); + }, + }; + } + class APIConnectionTimeoutError extends Error {} + return { + __esModule: true, + default: FakeDefaultClient, + AzureOpenAI: FakeAzureClient, + APIConnectionTimeoutError, + }; +}); + +// Stub approvals to bypass command approval logic +vi.mock("../src/approvals.js", () => ({ + __esModule: true, + alwaysApprovedCommands: new Set(), + canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }), + isSafeCommand: () => null, +})); + +// Stub format-command to avoid formatting side effects +vi.mock("../src/format-command.js", () => ({ + __esModule: true, + formatCommandForDisplay: (cmd: Array) => cmd.join(" "), +})); + +// Stub internal logging to keep output clean +vi.mock("../src/utils/agent/log.js", () => ({ + __esModule: true, + log: () => {}, + isLoggingEnabled: () => false, +})); + +import { AgentLoop } from "../src/utils/agent/agent-loop.js"; + +describe("AgentLoop Azure provider responses endpoint", () => { + beforeEach(() => { + lastCreateParams = null; + }); + + it("calls the /responses endpoint when provider is azure", async () => { + const cfg: any = { + model: "test-model", + provider: "azure", + instructions: "", + disableResponseStorage: false, + notify: false, + }; + const loop = new AgentLoop({ + additionalWritableRoots: [], + model: cfg.model, + config: cfg, + instructions: cfg.instructions, + approvalPolicy: { mode: "suggest" } as any, + onItem: () => {}, + onLoading: () => {}, + getCommandConfirmation: async () => ({ review: "yes" }) as any, + onLastResponseId: () => {}, + }); + + await loop.run([ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hello" }], + }, + ]); + + expect(lastCreateParams).not.toBeNull(); + expect(lastCreateParams.model).toBe(cfg.model); + expect(Array.isArray(lastCreateParams.input)).toBe(true); + }); +}); diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 66b4fa3e00d..035f37e5527 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -691,13 +691,25 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-file-search" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap", + "ignore", + "nucleo-matcher", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ "anyhow", "clap", - "codex-common", "codex-core", "landlock", "libc", @@ -759,6 +771,7 @@ dependencies = [ "codex-ansi-escape", "codex-common", "codex-core", + "codex-file-search", "codex-linux-sandbox", "codex-login", "color-eyre", @@ -1602,6 +1615,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + [[package]] name = "h2" version = "0.4.9" @@ -1986,6 +2012,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.9", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.6" @@ -2578,6 +2620,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4363,6 +4415,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6991a6223a7..eba43e548be 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -8,6 +8,7 @@ members = [ "core", "exec", "execpolicy", + "file-search", "linux-sandbox", "login", "mcp-client", @@ -36,3 +37,6 @@ lto = "fat" # Because we bundle some of these executables with the TypeScript CLI, we # remove everything to make the binary as small as possible. strip = "symbols" + +# See https://github.com/openai/codex/issues/1411 for details. +codegen-units = 1 diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index deacca5f280..a21cd4e73ef 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use codex_common::CliConfigOverrides; -use codex_common::SandboxPermissionOption; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::exec::StdioPolicy; @@ -20,13 +19,11 @@ pub async fn run_command_under_seatbelt( ) -> anyhow::Result<()> { let SeatbeltCommand { full_auto, - sandbox, config_overrides, command, } = command; run_command_under_sandbox( full_auto, - sandbox, command, config_overrides, codex_linux_sandbox_exe, @@ -41,13 +38,11 @@ pub async fn run_command_under_landlock( ) -> anyhow::Result<()> { let LandlockCommand { full_auto, - sandbox, config_overrides, command, } = command; run_command_under_sandbox( full_auto, - sandbox, command, config_overrides, codex_linux_sandbox_exe, @@ -63,13 +58,12 @@ enum SandboxType { async fn run_command_under_sandbox( full_auto: bool, - sandbox: SandboxPermissionOption, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, sandbox_type: SandboxType, ) -> anyhow::Result<()> { - let sandbox_policy = create_sandbox_policy(full_auto, sandbox); + let sandbox_policy = create_sandbox_policy(full_auto); let cwd = std::env::current_dir()?; let config = Config::load_with_cli_overrides( config_overrides @@ -110,13 +104,10 @@ async fn run_command_under_sandbox( handle_exit_status(status); } -pub fn create_sandbox_policy(full_auto: bool, sandbox: SandboxPermissionOption) -> SandboxPolicy { +pub fn create_sandbox_policy(full_auto: bool) -> SandboxPolicy { if full_auto { - SandboxPolicy::new_full_auto_policy() + SandboxPolicy::new_workspace_write_policy() } else { - match sandbox.permissions.map(Into::into) { - Some(sandbox_policy) => sandbox_policy, - None => SandboxPolicy::new_read_only_policy(), - } + SandboxPolicy::new_read_only_policy() } } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index fa78d18ab43..c6d80c0adfa 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -5,7 +5,6 @@ pub mod proto; use clap::Parser; use codex_common::CliConfigOverrides; -use codex_common::SandboxPermissionOption; #[derive(Debug, Parser)] pub struct SeatbeltCommand { @@ -13,9 +12,6 @@ pub struct SeatbeltCommand { #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, - #[clap(flatten)] - pub sandbox: SandboxPermissionOption, - #[clap(skip)] pub config_overrides: CliConfigOverrides, @@ -30,9 +26,6 @@ pub struct LandlockCommand { #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, - #[clap(flatten)] - pub sandbox: SandboxPermissionOption, - #[clap(skip)] pub config_overrides: CliConfigOverrides, diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index b4b658dabf1..eff7a6c0b47 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -16,3 +16,4 @@ serde = { version = "1", optional = true } # Separate feature so that `clap` is not a mandatory dependency. cli = ["clap", "toml", "serde"] elapsed = [] +sandbox_summary = [] diff --git a/codex-rs/common/src/approval_mode_cli_arg.rs b/codex-rs/common/src/approval_mode_cli_arg.rs index 199541148a0..a74ceb2b813 100644 --- a/codex-rs/common/src/approval_mode_cli_arg.rs +++ b/codex-rs/common/src/approval_mode_cli_arg.rs @@ -1,27 +1,23 @@ //! Standard type to use with the `--approval-mode` CLI option. //! Available when the `cli` feature is enabled for the crate. -use clap::ArgAction; -use clap::Parser; use clap::ValueEnum; -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")] pub enum ApprovalModeCliArg { + /// Only run "trusted" commands (e.g. ls, cat, sed) without asking for user + /// approval. Will escalate to the user if the model proposes a command that + /// is not in the "trusted" set. + Untrusted, + /// Run all commands without asking for user approval. /// Only asks for approval if a command fails to execute, in which case it /// will escalate to the user to ask for un-sandboxed execution. OnFailure, - /// Only run "known safe" commands (e.g. ls, cat, sed) without - /// asking for user approval. Will escalate to the user if the model - /// proposes a command that is not allow-listed. - UnlessAllowListed, - /// Never ask for user approval /// Execution failures are immediately returned to the model. Never, @@ -30,44 +26,9 @@ pub enum ApprovalModeCliArg { impl From for AskForApproval { fn from(value: ApprovalModeCliArg) -> Self { match value { + ApprovalModeCliArg::Untrusted => AskForApproval::UnlessTrusted, ApprovalModeCliArg::OnFailure => AskForApproval::OnFailure, - ApprovalModeCliArg::UnlessAllowListed => AskForApproval::UnlessAllowListed, ApprovalModeCliArg::Never => AskForApproval::Never, } } } - -#[derive(Parser, Debug)] -pub struct SandboxPermissionOption { - /// Specify this flag multiple times to specify the full set of permissions - /// to grant to Codex. - /// - /// ```shell - /// codex -s disk-full-read-access \ - /// -s disk-write-cwd \ - /// -s disk-write-platform-user-temp-folder \ - /// -s disk-write-platform-global-temp-folder - /// ``` - /// - /// Note disk-write-folder takes a value: - /// - /// ```shell - /// -s disk-write-folder=$HOME/.pyenv/shims - /// ``` - /// - /// These permissions are quite broad and should be used with caution: - /// - /// ```shell - /// -s disk-full-write-access - /// -s network-full-access - /// ``` - #[arg(long = "sandbox-permission", short = 's', action = ArgAction::Append, value_parser = parse_sandbox_permission)] - pub permissions: Option>, -} - -/// Custom value-parser so we can keep the CLI surface small *and* -/// still handle the parameterised `disk-write-folder` case. -fn parse_sandbox_permission(raw: &str) -> std::io::Result { - let base_path = std::env::current_dir()?; - parse_sandbox_permission_with_base_path(raw, base_path) -} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index c2283640cb3..18ed49e5a7d 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -6,11 +6,14 @@ pub mod elapsed; #[cfg(feature = "cli")] pub use approval_mode_cli_arg::ApprovalModeCliArg; -#[cfg(feature = "cli")] -pub use approval_mode_cli_arg::SandboxPermissionOption; #[cfg(any(feature = "cli", test))] mod config_override; #[cfg(feature = "cli")] pub use config_override::CliConfigOverrides; + +mod sandbox_summary; + +#[cfg(feature = "sandbox_summary")] +pub use sandbox_summary::summarize_sandbox_policy; diff --git a/codex-rs/common/src/sandbox_summary.rs b/codex-rs/common/src/sandbox_summary.rs new file mode 100644 index 00000000000..3d33d928365 --- /dev/null +++ b/codex-rs/common/src/sandbox_summary.rs @@ -0,0 +1,28 @@ +use codex_core::protocol::SandboxPolicy; + +pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + } => { + let mut summary = "workspace-write".to_string(); + if !writable_roots.is_empty() { + summary.push_str(&format!( + " [{}]", + writable_roots + .iter() + .map(|p| p.to_string_lossy()) + .collect::>() + .join(", ") + )); + } + if *network_access { + summary.push_str(" (network access enabled)"); + } + summary + } + } +} diff --git a/codex-rs/config.md b/codex-rs/config.md index ffa735ff212..f7e72581ab5 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -20,59 +20,73 @@ The model that Codex should use. model = "o3" # overrides the default of "codex-mini-latest" ``` -## model_provider +## model_providers -Codex comes bundled with a number of "model providers" predefined. This config value is a string that indicates which provider to use. You can also define your own providers via `model_providers`. +This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the corresponding provider. -For example, if you are running ollama with Mistral locally, then you would need to add the following to your config: +For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you could add the following configuration: ```toml -model = "mistral" -model_provider = "ollama" +# Recall that in TOML, root keys must be listed before tables. +model = "gpt-4o" +model_provider = "openai-chat-completions" + +[model_providers.openai-chat-completions] +# Name of the provider that will be displayed in the Codex UI. +name = "OpenAI using Chat Completions" +# The path `/chat/completions` will be amended to this URL to make the POST +# request for the chat completions. +base_url = "https://api.openai.com/v1" +# If `env_key` is set, identifies an environment variable that must be set when +# using Codex with this provider. The value of the environment variable must be +# non-empty and will be used in the `Bearer TOKEN` HTTP header for the POST request. +env_key = "OPENAI_API_KEY" +# Valid values for wire_api are "chat" and "responses". Defaults to "chat" if omitted. +wire_api = "chat" +# If necessary, extra query params that need to be added to the URL. +# See the Azure example below. +query_params = {} ``` -because the following definition for `ollama` is included in Codex: +Note this makes it possible to use Codex CLI with non-OpenAI models, so long as they use a wire API that is compatible with the OpenAI chat completions API. For example, you could define the following provider to use Codex CLI with Ollama running locally: ```toml [model_providers.ollama] name = "Ollama" base_url = "http://localhost:11434/v1" -wire_api = "chat" ``` -This option defaults to `"openai"` and the corresponding provider is defined as follows: +Or a third-party provider (using a distinct environment variable for the API key): ```toml -[model_providers.openai] -name = "OpenAI" -base_url = "https://api.openai.com/v1" -env_key = "OPENAI_API_KEY" -wire_api = "responses" +[model_providers.mistral] +name = "Mistral" +base_url = "https://api.mistral.ai/v1" +env_key = "MISTRAL_API_KEY" ``` -## model_providers +Note that Azure requires `api-version` to be passed as a query parameter, so be sure to specify it as part of `query_params` when defining the Azure provider: -This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the correspodning provider. +```toml +[model_providers.azure] +name = "Azure" +# Make sure you set the appropriate subdomain for this URL. +base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" +env_key = "AZURE_OPENAI_API_KEY" # Or "OPENAI_API_KEY", whichever you use. +query_params = { api-version = "2025-04-01-preview" } +``` -For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you +## model_provider -```toml -# Recall that in TOML, root keys must be listed before tables. -model = "gpt-4o" -model_provider = "openai-chat-completions" +Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`. -[model_providers.openai-chat-completions] -# Name of the provider that will be displayed in the Codex UI. -name = "OpenAI using Chat Completions" -# The path `/chat/completions` will be amended to this URL to make the POST -# request for the chat completions. -base_url = "https://api.openai.com/v1" -# If `env_key` is set, identifies an environment variable that must be set when -# using Codex with this provider. The value of the environment variable must be -# non-empty and will be used in the `Bearer TOKEN` HTTP header for the POST request. -env_key = "OPENAI_API_KEY" -# valid values for wire_api are "chat" and "responses". -wire_api = "chat" +Note that if you override `model_provider`, then you likely want to override +`model`, as well. For example, if you are running ollama with Mistral locally, +then you would need to add the following to your config in addition to the new entry in the `model_providers` map: + +```toml +model = "mistral" +model_provider = "ollama" ``` ## approval_policy @@ -80,8 +94,13 @@ wire_api = "chat" 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" +# Codex has hardcoded logic that defines a set of "trusted" commands. +# Setting the approval_policy to `untrusted` means that Codex will prompt the +# user before running a command not in the "trusted" set. +# +# See https://github.com/openai/codex/issues/1260 for the plan to enable +# end-users to define their own trusted commands. +approval_policy = "untrusted" ``` ```toml @@ -106,7 +125,6 @@ Here is an example of a `config.toml` that defines multiple profiles: ```toml model = "o3" approval_policy = "unless-allow-listed" -sandbox_permissions = ["disk-full-read-access"] disable_response_storage = false # Setting `profile` is equivalent to specifying `--profile o3` on the command @@ -170,31 +188,42 @@ To disable reasoning summaries, set `model_reasoning_summary` to `"none"` in you model_reasoning_summary = "none" # disable reasoning summaries ``` -## sandbox_permissions +## sandbox + +The `sandbox` configuration determines the _sandbox policy_ that Codex uses to execute untrusted commands. The `mode` determines the "base policy." Currently, only `workspace-write` supports additional configuration options, but this may change in the future. -List of permissions to grant to the sandbox that Codex uses to execute untrusted commands: +The default policy is `read-only`, which means commands can read any file on disk, but attempts to write a file or access the network will be blocked. ```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", -] +[sandbox] +mode = "read-only" ``` -To add additional writable folders, use `disk-write-folder`, which takes a parameter (this can be specified multiple times): +A more relaxed policy is `workspace-write`. When specified, the current working directory for the Codex task will be writable (as well as `$TMPDIR` on macOS). Note that the CLI defaults to using `cwd` where it was spawned, though this can be overridden using `--cwd/-C`. ```toml -sandbox_permissions = [ - # ... - "disk-write-folder=/Users/mbolin/.pyenv/shims", +[sandbox] +mode = "workspace-write" + +# By default, only the cwd for the Codex session will be writable (and $TMPDIR on macOS), +# but you can specify additional writable folders in this array. +writable_roots = [ + "/tmp", ] +network_access = false # Like read-only, this also defaults to false and can be omitted. +``` + +To disable sandboxing altogether, specify `danger-full-access` like so: + +```toml +[sandbox] +mode = "danger-full-access" ``` +This is reasonable to use if Codex is running in an environment that provides its own sandboxing (such as a Docker container) such that further sandboxing is unnecessary. + +Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. + ## 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). @@ -392,6 +421,16 @@ Setting `hide_agent_reasoning` to `true` suppresses these events in **both** the hide_agent_reasoning = true # defaults to false ``` +## model_context_window + +The size of the context window for the model, in tokens. + +In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. + +## model_max_output_tokens + +This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. + ## project_doc_max_bytes Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB. diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index f381c72e513..ce2ab0539b0 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -114,8 +114,7 @@ pub(crate) async fn stream_chat_completions( "tools": tools_json, }); - let base_url = provider.base_url.trim_end_matches('/'); - let url = format!("{}/chat/completions", base_url); + let url = provider.get_full_url(); debug!( "POST to {url}: {}", @@ -215,6 +214,7 @@ where let _ = tx_event .send(Ok(ResponseEvent::Completed { response_id: String::new(), + token_usage: None, })) .await; return; @@ -232,6 +232,7 @@ where let _ = tx_event .send(Ok(ResponseEvent::Completed { response_id: String::new(), + token_usage: None, })) .await; return; @@ -317,6 +318,7 @@ where let _ = tx_event .send(Ok(ResponseEvent::Completed { response_id: String::new(), + token_usage: None, })) .await; @@ -394,7 +396,10 @@ where // Not an assistant message – forward immediately. return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))); } - Poll::Ready(Some(Ok(ResponseEvent::Completed { response_id }))) => { + Poll::Ready(Some(Ok(ResponseEvent::Completed { + response_id, + token_usage, + }))) => { if !this.cumulative.is_empty() { let aggregated_item = crate::models::ResponseItem::Message { role: "assistant".to_string(), @@ -404,7 +409,10 @@ where }; // Buffer Completed so it is returned *after* the aggregated message. - this.pending_completed = Some(ResponseEvent::Completed { response_id }); + this.pending_completed = Some(ResponseEvent::Completed { + response_id, + token_usage, + }); return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone( aggregated_item, @@ -412,8 +420,16 @@ where } // Nothing aggregated – forward Completed directly. - return Poll::Ready(Some(Ok(ResponseEvent::Completed { response_id }))); - } // No other `Ok` variants exist at the moment, continue polling. + return Poll::Ready(Some(Ok(ResponseEvent::Completed { + response_id, + token_usage, + }))); + } + Poll::Ready(Some(Ok(ResponseEvent::Created))) => { + // These events are exclusive to the Responses API and + // will never appear in a Chat Completions stream. + continue; + } } } } @@ -427,7 +443,7 @@ pub(crate) trait AggregateStreamExt: Stream> + Size /// /// ```ignore /// OutputItemDone() - /// Completed { .. } + /// Completed /// ``` /// /// No other `OutputItemDone` events will be seen by the caller. diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index aff838887a2..91a84bf380c 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -35,6 +35,7 @@ use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::models::ResponseItem; use crate::openai_tools::create_tools_json_for_responses_api; +use crate::protocol::TokenUsage; use crate::util::backoff; #[derive(Clone)] @@ -122,9 +123,7 @@ impl ModelClient { stream: true, }; - let base_url = self.provider.base_url.clone(); - let base_url = base_url.trim_end_matches('/'); - let url = format!("{}/responses", base_url); + let url = self.provider.get_full_url(); trace!("POST to {url}: {}", serde_json::to_string(&payload)?); let mut attempt = 0; @@ -167,7 +166,7 @@ impl ModelClient { // negligible. if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) { // Surface the error body to callers. Use `unwrap_or_default` per Clippy. - let body = (res.text().await).unwrap_or_default(); + let body = res.text().await.unwrap_or_default(); return Err(CodexErr::UnexpectedStatus(status, body)); } @@ -207,9 +206,44 @@ struct SseEvent { item: Option, } +#[derive(Debug, Deserialize)] +struct ResponseCreated {} + #[derive(Debug, Deserialize)] struct ResponseCompleted { id: String, + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct ResponseCompletedUsage { + input_tokens: u64, + input_tokens_details: Option, + output_tokens: u64, + output_tokens_details: Option, + total_tokens: u64, +} + +impl From for TokenUsage { + fn from(val: ResponseCompletedUsage) -> Self { + TokenUsage { + input_tokens: val.input_tokens, + cached_input_tokens: val.input_tokens_details.map(|d| d.cached_tokens), + output_tokens: val.output_tokens, + reasoning_output_tokens: val.output_tokens_details.map(|d| d.reasoning_tokens), + total_tokens: val.total_tokens, + } + } +} + +#[derive(Debug, Deserialize)] +struct ResponseCompletedInputTokensDetails { + cached_tokens: u64, +} + +#[derive(Debug, Deserialize)] +struct ResponseCompletedOutputTokensDetails { + reasoning_tokens: u64, } async fn process_sse(stream: S, tx_event: mpsc::Sender>) @@ -221,7 +255,7 @@ where // If the stream stays completely silent for an extended period treat it as disconnected. let idle_timeout = *OPENAI_STREAM_IDLE_TIMEOUT_MS; // The response id returned from the "complete" message. - let mut response_id = None; + let mut response_completed: Option = None; loop { let sse = match timeout(idle_timeout, stream.next()).await { @@ -233,9 +267,15 @@ where return; } Ok(None) => { - match response_id { - Some(response_id) => { - let event = ResponseEvent::Completed { response_id }; + match response_completed { + Some(ResponseCompleted { + id: response_id, + usage, + }) => { + let event = ResponseEvent::Completed { + response_id, + token_usage: usage.map(Into::into), + }; let _ = tx_event.send(Ok(event)).await; } None => { @@ -296,12 +336,17 @@ where return; } } + "response.created" => { + if event.response.is_some() { + let _ = tx_event.send(Ok(ResponseEvent::Created {})).await; + } + } // Final response completed – includes array of output items & id "response.completed" => { if let Some(resp_val) = event.response { match serde_json::from_value::(resp_val) { Ok(r) => { - response_id = Some(r.id); + response_completed = Some(r); } Err(e) => { debug!("failed to parse ResponseCompleted: {e}"); @@ -311,7 +356,6 @@ where }; } "response.content_part.done" - | "response.created" | "response.function_call_arguments.delta" | "response.in_progress" | "response.output_item.added" diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index a2633475dfe..b08880a0df4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -2,6 +2,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::error::Result; use crate::models::ResponseItem; +use crate::protocol::TokenUsage; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use futures::Stream; use serde::Serialize; @@ -50,8 +51,12 @@ impl Prompt { #[derive(Debug)] pub enum ResponseEvent { + Created, OutputItemDone(ResponseItem), - Completed { response_id: String }, + Completed { + response_id: String, + token_usage: Option, + }, } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2837dd032e5..ec6e0bd185b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,6 +1,7 @@ // Poisoned mutex should fail the program #![allow(clippy::unwrap_used)] +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; @@ -188,7 +189,7 @@ pub(crate) struct Session { /// Optional rollout recorder for persisting the conversation transcript so /// sessions can be replayed or inspected later. - rollout: Mutex>, + rollout: Mutex>, state: Mutex, codex_linux_sandbox_exe: Option, } @@ -206,6 +207,9 @@ impl Session { struct State { approved_commands: HashSet>, current_task: Option, + /// Call IDs that have been sent from the Responses API but have not been sent back yet. + /// You CANNOT send a Responses API follow-up message unless you have sent back the output for all pending calls or else it will 400. + pending_call_ids: HashSet, previous_response_id: Option, pending_approvals: HashMap>, pending_input: Vec, @@ -312,7 +316,7 @@ impl Session { /// Append the given items to the session's rollout transcript (if enabled) /// and persist them to disk. async fn record_rollout_items(&self, items: &[ResponseItem]) { - // Clone the recorder outside of the mutex so we don’t hold the lock + // Clone the recorder outside of the mutex so we don't hold the lock // across an await point (MutexGuard is not Send). let recorder = { let guard = self.rollout.lock().unwrap(); @@ -411,6 +415,8 @@ impl Session { pub fn abort(&self) { info!("Aborting existing session"); let mut state = self.state.lock().unwrap(); + // Don't clear pending_call_ids because we need to keep track of them to ensure we don't 400 on the next turn. + // We will generate a synthetic aborted response for each pending call id. state.pending_approvals.clear(); state.pending_input.clear(); if let Some(task) = state.current_task.take() { @@ -431,7 +437,7 @@ impl Session { } let Ok(json) = serde_json::to_string(¬ification) else { - tracing::error!("failed to serialise notification payload"); + error!("failed to serialise notification payload"); return; }; @@ -443,7 +449,7 @@ impl Session { // 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]); + warn!("failed to spawn notifier '{}': {e}", notify_command[0]); } } } @@ -647,7 +653,7 @@ async fn submission_loop( match RolloutRecorder::new(&config, session_id, instructions.clone()).await { Ok(r) => Some(r), Err(e) => { - tracing::warn!("failed to initialise rollout recorder: {e}"); + warn!("failed to initialise rollout recorder: {e}"); None } }; @@ -742,7 +748,7 @@ async fn submission_loop( tokio::spawn(async move { if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await { - tracing::warn!("failed to append to message history: {e}"); + warn!("failed to append to message history: {e}"); } }); } @@ -772,7 +778,7 @@ async fn submission_loop( }; if let Err(e) = tx_event.send(event).await { - tracing::warn!("failed to send GetHistoryEntryResponse event: {e}"); + warn!("failed to send GetHistoryEntryResponse event: {e}"); } }); } @@ -1052,6 +1058,7 @@ async fn run_turn( /// events map to a `ResponseItem`. A `ResponseItem` may need to be /// "handled" such that it produces a `ResponseInputItem` that needs to be /// sent back to the model on the next turn. +#[derive(Debug)] struct ProcessedResponseItem { item: ResponseItem, response: Option, @@ -1062,7 +1069,57 @@ async fn try_run_turn( sub_id: &str, prompt: &Prompt, ) -> CodexResult> { - let mut stream = sess.client.clone().stream(prompt).await?; + // call_ids that are part of this response. + let completed_call_ids = prompt + .input + .iter() + .filter_map(|ri| match ri { + ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id), + ResponseItem::LocalShellCall { + call_id: Some(call_id), + .. + } => Some(call_id), + _ => None, + }) + .collect::>(); + + // call_ids that were pending but are not part of this response. + // This usually happens because the user interrupted the model before we responded to one of its tool calls + // and then the user sent a follow-up message. + let missing_calls = { + sess.state + .lock() + .unwrap() + .pending_call_ids + .iter() + .filter_map(|call_id| { + if completed_call_ids.contains(&call_id) { + None + } else { + Some(call_id.clone()) + } + }) + .map(|call_id| ResponseItem::FunctionCallOutput { + call_id: call_id.clone(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + success: Some(false), + }, + }) + .collect::>() + }; + let prompt: Cow = if missing_calls.is_empty() { + Cow::Borrowed(prompt) + } else { + // Add the synthetic aborted missing calls to the beginning of the input to ensure all call ids have responses. + let input = [missing_calls, prompt.input.clone()].concat(); + Cow::Owned(Prompt { + input, + ..prompt.clone() + }) + }; + + let mut stream = sess.client.clone().stream(&prompt).await?; // Buffer all the incoming messages from the stream first, then execute them. // If we execute a function call in the middle of handling the stream, it can time out. @@ -1074,11 +1131,43 @@ async fn try_run_turn( let mut output = Vec::new(); for event in input { match event { + ResponseEvent::Created => { + let mut state = sess.state.lock().unwrap(); + // We successfully created a new response and ensured that all pending calls were included so we can clear the pending call ids. + state.pending_call_ids.clear(); + } ResponseEvent::OutputItemDone(item) => { + let call_id = match &item { + ResponseItem::LocalShellCall { + call_id: Some(call_id), + .. + } => Some(call_id), + ResponseItem::FunctionCall { call_id, .. } => Some(call_id), + _ => None, + }; + if let Some(call_id) = call_id { + // We just got a new call id so we need to make sure to respond to it in the next turn. + let mut state = sess.state.lock().unwrap(); + state.pending_call_ids.insert(call_id.clone()); + } let response = handle_response_item(sess, sub_id, item.clone()).await?; + output.push(ProcessedResponseItem { item, response }); } - ResponseEvent::Completed { response_id } => { + ResponseEvent::Completed { + response_id, + token_usage, + } => { + if let Some(token_usage) = token_usage { + sess.tx_event + .send(Event { + id: sub_id.to_string(), + msg: EventMsg::TokenCount(token_usage), + }) + .await + .ok(); + } + let mut state = sess.state.lock().unwrap(); state.previous_response_id = Some(response_id); break; @@ -1125,7 +1214,7 @@ async fn handle_response_item( arguments, call_id, } => { - tracing::info!("FunctionCall: {arguments}"); + info!("FunctionCall: {arguments}"); Some(handle_function_call(sess, sub_id.to_string(), name, arguments, call_id).await) } ResponseItem::LocalShellCall { @@ -1207,7 +1296,7 @@ async fn handle_function_call( // Unknown function: reply with structured failure so the model can adapt. ResponseInputItem::FunctionCallOutput { call_id, - output: crate::models::FunctionCallOutputPayload { + output: FunctionCallOutputPayload { content: format!("unsupported call: {}", name), success: None, }, @@ -1239,7 +1328,7 @@ fn parse_container_exec_arguments( // allow model to re-sample let output = ResponseInputItem::FunctionCallOutput { call_id: call_id.to_string(), - output: crate::models::FunctionCallOutputPayload { + output: FunctionCallOutputPayload { content: format!("failed to parse function arguments: {e}"), success: None, }, @@ -1307,7 +1396,7 @@ async fn handle_container_exec_with_params( ReviewDecision::Denied | ReviewDecision::Abort => { return ResponseInputItem::FunctionCallOutput { call_id, - output: crate::models::FunctionCallOutputPayload { + output: FunctionCallOutputPayload { content: "exec command rejected by user".to_string(), success: None, }, @@ -1323,7 +1412,7 @@ async fn handle_container_exec_with_params( SafetyCheck::Reject { reason } => { return ResponseInputItem::FunctionCallOutput { call_id, - output: crate::models::FunctionCallOutputPayload { + output: FunctionCallOutputPayload { content: format!("exec command rejected: {reason}"), success: None, }, @@ -1371,7 +1460,7 @@ async fn handle_container_exec_with_params( } } Err(CodexErr::Sandbox(error)) => { - handle_sanbox_error(error, sandbox_type, params, sess, sub_id, call_id).await + handle_sandbox_error(error, sandbox_type, params, sess, sub_id, call_id).await } Err(e) => { // Handle non-sandbox errors @@ -1386,7 +1475,7 @@ async fn handle_container_exec_with_params( } } -async fn handle_sanbox_error( +async fn handle_sandbox_error( error: SandboxErr, sandbox_type: SandboxType, params: ExecParams, @@ -1408,7 +1497,14 @@ async fn handle_sanbox_error( }; } - // Ask the user to retry without sandbox + // Note that when `error` is `SandboxErr::Denied`, it could be a false + // positive. That is, it may have exited with a non-zero exit code, not + // because the sandbox denied it, but because that is its expected behavior, + // i.e., a grep command that did not match anything. Ideally we would + // include additional metadata on the command to indicate whether non-zero + // exit codes merit a retry. + + // For now, we categorically ask the user to retry without sandbox. sess.notify_background_event(&sub_id, format!("Execution failed: {error}")) .await; @@ -1850,7 +1946,7 @@ fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result Vec { +fn get_writable_roots(cwd: &Path) -> Vec { let mut writable_roots = Vec::new(); if cfg!(target_os = "macos") { // On macOS, $TMPDIR is private to the user. @@ -1878,7 +1974,7 @@ fn get_writable_roots(cwd: &Path) -> Vec { } /// Exec output is a pre-serialized JSON payload -fn format_exec_output(output: &str, exit_code: i32, duration: std::time::Duration) -> String { +fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String { #[derive(Serialize)] struct ExecMetadata { exit_code: i32, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 74798129ba1..240c6eaf298 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -10,8 +10,8 @@ use crate::config_types::UriBasedFileOpener; use crate::flags::OPENAI_DEFAULT_MODEL; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::built_in_model_providers; +use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; -use crate::protocol::SandboxPermission; use crate::protocol::SandboxPolicy; use dirs::home_dir; use serde::Deserialize; @@ -31,6 +31,12 @@ pub struct Config { /// Optional override of model selection. pub model: String, + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Maximum number of output tokens. + pub model_max_output_tokens: Option, + /// Key into the model_providers map that specifies which provider to use. pub model_provider_id: String, @@ -235,17 +241,20 @@ pub struct ConfigToml { /// Provider to use from the model_providers map. pub model_provider: Option, + /// Size of the context window for the model, in tokens. + pub model_context_window: Option, + + /// Maximum number of output tokens. + pub model_max_output_tokens: Option, + /// Default approval policy for executing commands. pub approval_policy: Option, #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, - // The `default` attribute ensures that the field is treated as `None` when - // the key is omitted from the TOML. Without it, Serde treats the field as - // required because we supply a custom deserializer. - #[serde(default, deserialize_with = "deserialize_sandbox_permissions")] - pub sandbox_permissions: Option>, + /// If omitted, Codex defaults to the restrictive `read-only` policy. + pub sandbox: Option, /// Disable server-side response storage (sends the full conversation /// context with every request). Currently necessary for OpenAI customers @@ -296,32 +305,6 @@ pub struct ConfigToml { pub model_reasoning_summary: Option, } -fn deserialize_sandbox_permissions<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: serde::Deserializer<'de>, -{ - let permissions: Option> = Option::deserialize(deserializer)?; - - match permissions { - Some(raw_permissions) => { - let base_path = find_codex_home().map_err(serde::de::Error::custom)?; - - let converted = raw_permissions - .into_iter() - .map(|raw| { - parse_sandbox_permission_with_base_path(&raw, base_path.clone()) - .map_err(serde::de::Error::custom) - }) - .collect::, D::Error>>()?; - - Ok(Some(converted)) - } - None => Ok(None), - } -} - /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -369,20 +352,10 @@ impl Config { None => ConfigProfile::default(), }; - let sandbox_policy = match sandbox_policy { - Some(sandbox_policy) => sandbox_policy, - None => { - // Derive a SandboxPolicy from the permissions in the config. - match cfg.sandbox_permissions { - // Note this means the user can explicitly set permissions - // to the empty list in the config file, granting it no - // permissions whatsoever. - Some(permissions) => SandboxPolicy::from(permissions), - // Default to read only rather than completely locked down. - None => SandboxPolicy::new_read_only_policy(), - } - } - }; + let sandbox_policy = sandbox_policy.unwrap_or_else(|| { + cfg.sandbox + .unwrap_or_else(SandboxPolicy::new_read_only_policy) + }); let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. @@ -427,11 +400,23 @@ impl Config { let history = cfg.history.unwrap_or_default(); + let model = model + .or(config_profile.model) + .or(cfg.model) + .unwrap_or_else(default_model); + let openai_model_info = get_model_info(&model); + let model_context_window = cfg + .model_context_window + .or_else(|| openai_model_info.as_ref().map(|info| info.context_window)); + let model_max_output_tokens = cfg.model_max_output_tokens.or_else(|| { + openai_model_info + .as_ref() + .map(|info| info.max_output_tokens) + }); let config = Self { - model: model - .or(config_profile.model) - .or(cfg.model) - .unwrap_or_else(default_model), + model, + model_context_window, + model_max_output_tokens, model_provider_id, model_provider, cwd: resolved_cwd, @@ -520,50 +505,6 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { Ok(p) } -pub fn parse_sandbox_permission_with_base_path( - raw: &str, - base_path: PathBuf, -) -> std::io::Result { - 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= 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 { #![allow(clippy::expect_used, clippy::unwrap_used)] @@ -573,51 +514,14 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; - /// Verify that the `sandbox_permissions` field on `ConfigToml` correctly - /// differentiates between a value that is completely absent in the - /// provided TOML (i.e. `None`) and one that is explicitly specified as an - /// empty array (i.e. `Some(vec![])`). This ensures that downstream logic - /// that treats these two cases differently (default read-only policy vs a - /// fully locked-down sandbox) continues to function. - #[test] - fn test_sandbox_permissions_none_vs_empty_vec() { - // Case 1: `sandbox_permissions` key is *absent* from the TOML source. - let toml_source_without_key = ""; - let cfg_without_key: ConfigToml = toml::from_str(toml_source_without_key) - .expect("TOML deserialization without key should succeed"); - assert!(cfg_without_key.sandbox_permissions.is_none()); - - // Case 2: `sandbox_permissions` is present but set to an *empty array*. - let toml_source_with_empty = "sandbox_permissions = []"; - let cfg_with_empty: ConfigToml = toml::from_str(toml_source_with_empty) - .expect("TOML deserialization with empty array should succeed"); - assert_eq!(Some(vec![]), cfg_with_empty.sandbox_permissions); - - // Case 3: `sandbox_permissions` contains a non-empty list of valid values. - let toml_source_with_values = r#" - sandbox_permissions = ["disk-full-read-access", "network-full-access"] - "#; - let cfg_with_values: ConfigToml = toml::from_str(toml_source_with_values) - .expect("TOML deserialization with valid permissions should succeed"); - - assert_eq!( - Some(vec![ - SandboxPermission::DiskFullReadAccess, - SandboxPermission::NetworkFullAccess - ]), - cfg_with_values.sandbox_permissions - ); - } - #[test] fn test_toml_parsing() { let history_with_persistence = r#" [history] persistence = "save-all" "#; - let history_with_persistence_cfg: ConfigToml = - toml::from_str::(history_with_persistence) - .expect("TOML deserialization should succeed"); + let history_with_persistence_cfg = toml::from_str::(history_with_persistence) + .expect("TOML deserialization should succeed"); assert_eq!( Some(History { persistence: HistoryPersistence::SaveAll, @@ -631,9 +535,8 @@ persistence = "save-all" persistence = "none" "#; - let history_no_persistence_cfg: ConfigToml = - toml::from_str::(history_no_persistence) - .expect("TOML deserialization should succeed"); + let history_no_persistence_cfg = toml::from_str::(history_no_persistence) + .expect("TOML deserialization should succeed"); assert_eq!( Some(History { persistence: HistoryPersistence::None, @@ -643,20 +546,47 @@ persistence = "none" ); } - /// Deserializing a TOML string containing an *invalid* permission should - /// fail with a helpful error rather than silently defaulting or - /// succeeding. #[test] - fn test_sandbox_permissions_illegal_value() { - let toml_bad = r#"sandbox_permissions = ["not-a-real-permission"]"#; + fn test_sandbox_config_parsing() { + let sandbox_full_access = r#" +[sandbox] +mode = "danger-full-access" +network_access = false # This should be ignored. +"#; + let sandbox_full_access_cfg = toml::from_str::(sandbox_full_access) + .expect("TOML deserialization should succeed"); + assert_eq!( + Some(SandboxPolicy::DangerFullAccess), + sandbox_full_access_cfg.sandbox + ); - let err = toml::from_str::(toml_bad) - .expect_err("Deserialization should fail for invalid permission"); + let sandbox_read_only = r#" +[sandbox] +mode = "read-only" +network_access = true # This should be ignored. +"#; - // Make sure the error message contains the invalid value so users have - // useful feedback. - let msg = err.to_string(); - assert!(msg.contains("not-a-real-permission")); + let sandbox_read_only_cfg = toml::from_str::(sandbox_read_only) + .expect("TOML deserialization should succeed"); + assert_eq!(Some(SandboxPolicy::ReadOnly), sandbox_read_only_cfg.sandbox); + + let sandbox_workspace_write = r#" +[sandbox] +mode = "workspace-write" +writable_roots = [ + "/tmp", +] +"#; + + let sandbox_workspace_write_cfg = toml::from_str::(sandbox_workspace_write) + .expect("TOML deserialization should succeed"); + assert_eq!( + Some(SandboxPolicy::WorkspaceWrite { + writable_roots: vec![PathBuf::from("/tmp")], + network_access: false + }), + sandbox_workspace_write_cfg.sandbox + ); } struct PrecedenceTestFixture { @@ -681,8 +611,7 @@ persistence = "none" fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" -approval_policy = "unless-allow-listed" -sandbox_permissions = ["disk-full-read-access"] +approval_policy = "untrusted" disable_response_storage = false # Can be used to determine which profile to use if not specified by @@ -729,6 +658,7 @@ disable_response_storage = true env_key: Some("OPENAI_API_KEY".to_string()), wire_api: crate::WireApi::Chat, env_key_instructions: None, + query_params: None, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); @@ -783,6 +713,8 @@ disable_response_storage = true assert_eq!( Config { model: "o3".to_string(), + model_context_window: Some(200_000), + model_max_output_tokens: Some(100_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, @@ -825,9 +757,11 @@ disable_response_storage = true )?; let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), + model_context_window: Some(16_385), + model_max_output_tokens: Some(4_096), model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), - approval_policy: AskForApproval::UnlessAllowListed, + approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), disable_response_storage: false, @@ -882,6 +816,8 @@ disable_response_storage = true )?; let expected_zdr_profile_config = Config { model: "o3".to_string(), + model_context_window: Some(200_000), + model_max_output_tokens: Some(100_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index bf724048c80..3b37cb538d6 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -225,41 +225,20 @@ fn create_linux_sandbox_command_args( sandbox_policy: &SandboxPolicy, cwd: &Path, ) -> Vec { - let mut linux_cmd: Vec = vec![]; - - // Translate individual permissions. - // Use high-level helper methods to infer flags when we cannot see the - // exact permission list. - if sandbox_policy.has_full_disk_read_access() { - linux_cmd.extend(["-s", "disk-full-read-access"].map(String::from)); - } - - if sandbox_policy.has_full_disk_write_access() { - linux_cmd.extend(["-s", "disk-full-write-access"].map(String::from)); - } else { - // Derive granular writable paths (includes cwd if `DiskWriteCwd` is - // present). - for root in sandbox_policy.get_writable_roots_with_cwd(cwd) { - // Check if this path corresponds exactly to cwd to map to - // `disk-write-cwd`, otherwise use the generic folder rule. - if root == cwd { - linux_cmd.extend(["-s", "disk-write-cwd"].map(String::from)); - } else { - linux_cmd.extend([ - "-s".to_string(), - format!("disk-write-folder={}", root.to_string_lossy()), - ]); - } - } - } - - if sandbox_policy.has_full_network_access() { - linux_cmd.extend(["-s", "network-full-access"].map(String::from)); - } - - // Separator so that command arguments starting with `-` are not parsed as - // options of the helper itself. - linux_cmd.push("--".to_string()); + #[expect(clippy::expect_used)] + let sandbox_policy_cwd = cwd.to_str().expect("cwd must be valid UTF-8").to_string(); + + #[expect(clippy::expect_used)] + let sandbox_policy_json = + serde_json::to_string(sandbox_policy).expect("Failed to serialize SandboxPolicy to JSON"); + + let mut linux_cmd: Vec = vec![ + sandbox_policy_cwd, + sandbox_policy_json, + // Separator so that command arguments starting with `-` are not parsed as + // options of the helper itself. + "--".to_string(), + ]; // Append the original tool command. linux_cmd.extend(command); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 16cf1905882..6812260c979 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -28,6 +28,7 @@ pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::WireApi; mod models; pub mod openai_api_key; +mod openai_model_info; mod openai_tools; mod project_doc; pub mod protocol; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 44b406c9854..b8326ace656 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -23,9 +23,10 @@ use crate::openai_api_key::get_openai_api_key; #[serde(rename_all = "lowercase")] pub enum WireApi { /// The experimental “Responses” API exposed by OpenAI at `/v1/responses`. - #[default] Responses, + /// Regular Chat Completions compatible with `/v1/chat/completions`. + #[default] Chat, } @@ -44,7 +45,32 @@ pub struct ModelProviderInfo { pub env_key_instructions: Option, /// Which wire protocol this provider expects. + #[serde(default)] pub wire_api: WireApi, + + /// Optional query parameters to append to the base URL. + pub query_params: Option>, +} + +impl ModelProviderInfo { + pub(crate) fn get_full_url(&self) -> String { + let query_string = self + .query_params + .as_ref() + .map_or_else(String::new, |params| { + let full_params = params + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&"); + format!("?{full_params}") + }); + let base_url = &self.base_url; + match self.wire_api { + WireApi::Responses => format!("{base_url}/responses{query_string}"), + WireApi::Chat => format!("{base_url}/chat/completions{query_string}"), + } + } } impl ModelProviderInfo { @@ -83,6 +109,10 @@ impl ModelProviderInfo { pub fn built_in_model_providers() -> HashMap { use ModelProviderInfo as P; + // We do not want to be in the business of adjucating which third-party + // providers are bundled with Codex CLI, so we only include the OpenAI + // provider by default. Users are encouraged to add to `model_providers` + // in config.toml to add their own providers. [ ( "openai", @@ -92,76 +122,7 @@ pub fn built_in_model_providers() -> HashMap { env_key: Some("OPENAI_API_KEY".into()), env_key_instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".into()), wire_api: WireApi::Responses, - }, - ), - ( - "openrouter", - P { - name: "OpenRouter".into(), - base_url: "https://openrouter.ai/api/v1".into(), - env_key: Some("OPENROUTER_API_KEY".into()), - env_key_instructions: None, - wire_api: WireApi::Chat, - }, - ), - ( - "gemini", - P { - name: "Gemini".into(), - base_url: "https://generativelanguage.googleapis.com/v1beta/openai".into(), - env_key: Some("GEMINI_API_KEY".into()), - env_key_instructions: None, - wire_api: WireApi::Chat, - }, - ), - ( - "ollama", - P { - name: "Ollama".into(), - base_url: "http://localhost:11434/v1".into(), - env_key: None, - env_key_instructions: None, - wire_api: WireApi::Chat, - }, - ), - ( - "mistral", - P { - name: "Mistral".into(), - base_url: "https://api.mistral.ai/v1".into(), - env_key: Some("MISTRAL_API_KEY".into()), - env_key_instructions: None, - wire_api: WireApi::Chat, - }, - ), - ( - "deepseek", - P { - name: "DeepSeek".into(), - base_url: "https://api.deepseek.com".into(), - env_key: Some("DEEPSEEK_API_KEY".into()), - env_key_instructions: None, - wire_api: WireApi::Chat, - }, - ), - ( - "xai", - P { - name: "xAI".into(), - base_url: "https://api.x.ai/v1".into(), - env_key: Some("XAI_API_KEY".into()), - env_key_instructions: None, - wire_api: WireApi::Chat, - }, - ), - ( - "groq", - P { - name: "Groq".into(), - base_url: "https://api.groq.com/openai/v1".into(), - env_key: Some("GROQ_API_KEY".into()), - env_key_instructions: None, - wire_api: WireApi::Chat, + query_params: None, }, ), ] @@ -169,3 +130,51 @@ pub fn built_in_model_providers() -> HashMap { .map(|(k, v)| (k.to_string(), v)) .collect() } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn test_deserialize_ollama_model_provider_toml() { + let azure_provider_toml = r#" +name = "Ollama" +base_url = "http://localhost:11434/v1" + "#; + let expected_provider = ModelProviderInfo { + name: "Ollama".into(), + base_url: "http://localhost:11434/v1".into(), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: None, + }; + + let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); + assert_eq!(expected_provider, provider); + } + + #[test] + fn test_deserialize_azure_model_provider_toml() { + let azure_provider_toml = r#" +name = "Azure" +base_url = "https://xxxxx.openai.azure.com/openai" +env_key = "AZURE_OPENAI_API_KEY" +query_params = { api-version = "2025-04-01-preview" } + "#; + let expected_provider = ModelProviderInfo { + name: "Azure".into(), + base_url: "https://xxxxx.openai.azure.com/openai".into(), + env_key: Some("AZURE_OPENAI_API_KEY".into()), + env_key_instructions: None, + wire_api: WireApi::Chat, + query_params: Some(maplit::hashmap! { + "api-version".to_string() => "2025-04-01-preview".to_string(), + }), + }; + + let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); + assert_eq!(expected_provider, provider); + } +} diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs new file mode 100644 index 00000000000..9ffd831a91c --- /dev/null +++ b/codex-rs/core/src/openai_model_info.rs @@ -0,0 +1,71 @@ +/// Metadata about a model, particularly OpenAI models. +/// We may want to consider including details like the pricing for +/// input tokens, output tokens, etc., though users will need to be able to +/// override this in config.toml, as this information can get out of date. +/// Though this would help present more accurate pricing information in the UI. +#[derive(Debug)] +pub(crate) struct ModelInfo { + /// Size of the context window in tokens. + pub(crate) context_window: u64, + + /// Maximum number of output tokens that can be generated for the model. + pub(crate) max_output_tokens: u64, +} + +/// Note details such as what a model like gpt-4o is aliased to may be out of +/// date. +pub(crate) fn get_model_info(name: &str) -> Option { + match name { + // https://platform.openai.com/docs/models/o3 + "o3" => Some(ModelInfo { + context_window: 200_000, + max_output_tokens: 100_000, + }), + + // https://platform.openai.com/docs/models/o4-mini + "o4-mini" => Some(ModelInfo { + context_window: 200_000, + max_output_tokens: 100_000, + }), + + // https://platform.openai.com/docs/models/codex-mini-latest + "codex-mini-latest" => Some(ModelInfo { + context_window: 200_000, + max_output_tokens: 100_000, + }), + + // As of Jun 25, 2025, gpt-4.1 defaults to gpt-4.1-2025-04-14. + // https://platform.openai.com/docs/models/gpt-4.1 + "gpt-4.1" | "gpt-4.1-2025-04-14" => Some(ModelInfo { + context_window: 1_047_576, + max_output_tokens: 32_768, + }), + + // As of Jun 25, 2025, gpt-4o defaults to gpt-4o-2024-08-06. + // https://platform.openai.com/docs/models/gpt-4o + "gpt-4o" | "gpt-4o-2024-08-06" => Some(ModelInfo { + context_window: 128_000, + max_output_tokens: 16_384, + }), + + // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-05-13 + "gpt-4o-2024-05-13" => Some(ModelInfo { + context_window: 128_000, + max_output_tokens: 4_096, + }), + + // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-11-20 + "gpt-4o-2024-11-20" => Some(ModelInfo { + context_window: 128_000, + max_output_tokens: 16_384, + }), + + // https://platform.openai.com/docs/models/gpt-3.5-turbo + "gpt-3.5-turbo" => Some(ModelInfo { + context_window: 16_385, + max_output_tokens: 4_096, + }), + + _ => None, + } +} diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 737acc77324..fa25a2fe383 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use std::str::FromStr; use mcp_types::CallToolResult; use serde::Deserialize; @@ -109,21 +110,17 @@ pub enum Op { GetHistoryEntryRequest { offset: usize, log_id: u64 }, } -/// Determines how liberally commands are auto‑approved by the system. +/// Determines the conditions under which the user is consulted to approve +/// running the command proposed by Codex. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AskForApproval { - /// Under this policy, only “known safe” commands—as determined by + /// Under this policy, only "known safe" commands—as determined by /// `is_safe_command()`—that **only read files** are auto‑approved. /// Everything else will ask the user to approve. #[default] - UnlessAllowListed, - - /// In addition to everything allowed by **`Suggest`**, commands that - /// *write* to files **within the user’s approved list of writable paths** - /// are also auto‑approved. - /// TODO(ragona): fix - AutoEdit, + #[serde(rename = "untrusted")] + UnlessTrusted, /// *All* commands are auto‑approved, but they are expected to run inside a /// sandbox where network access is disabled and writes are confined to a @@ -136,157 +133,106 @@ pub enum AskForApproval { Never, } -/// Determines execution restrictions for model shell commands -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] -pub struct SandboxPolicy { - permissions: Vec, +/// Determines execution restrictions for model shell commands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "kebab-case")] +pub enum SandboxPolicy { + /// No restrictions whatsoever. Use with caution. + #[serde(rename = "danger-full-access")] + DangerFullAccess, + + /// Read-only access to the entire file-system. + #[serde(rename = "read-only")] + ReadOnly, + + /// Same as `ReadOnly` but additionally grants write access to the current + /// working directory ("workspace"). + #[serde(rename = "workspace-write")] + WorkspaceWrite { + /// Additional folders (beyond cwd and possibly TMPDIR) that should be + /// writable from within the sandbox. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + writable_roots: Vec, + + /// When set to `true`, outbound network access is allowed. `false` by + /// default. + #[serde(default)] + network_access: bool, + }, } -impl From> for SandboxPolicy { - fn from(permissions: Vec) -> Self { - Self { permissions } +impl FromStr for SandboxPolicy { + type Err = serde_json::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) } } impl SandboxPolicy { + /// Returns a policy with read-only disk access and no network. pub fn new_read_only_policy() -> Self { - Self { - permissions: vec![SandboxPermission::DiskFullReadAccess], - } + SandboxPolicy::ReadOnly } - pub fn new_read_only_policy_with_writable_roots(writable_roots: &[PathBuf]) -> Self { - let mut permissions = Self::new_read_only_policy().permissions; - permissions.extend(writable_roots.iter().map(|folder| { - SandboxPermission::DiskWriteFolder { - folder: folder.clone(), - } - })); - Self { permissions } - } - - pub fn new_full_auto_policy() -> Self { - Self { - permissions: vec![ - SandboxPermission::DiskFullReadAccess, - SandboxPermission::DiskWritePlatformUserTempFolder, - SandboxPermission::DiskWriteCwd, - ], + /// Returns a policy that can read the entire disk, but can only write to + /// the current working directory and the per-user tmp dir on macOS. It does + /// not allow network access. + pub fn new_workspace_write_policy() -> Self { + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, } } + /// Always returns `true` for now, as we do not yet support restricting read + /// access. pub fn has_full_disk_read_access(&self) -> bool { - self.permissions - .iter() - .any(|perm| matches!(perm, SandboxPermission::DiskFullReadAccess)) + true } pub fn has_full_disk_write_access(&self) -> bool { - self.permissions - .iter() - .any(|perm| matches!(perm, SandboxPermission::DiskFullWriteAccess)) + match self { + SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ReadOnly => false, + SandboxPolicy::WorkspaceWrite { .. } => false, + } } pub fn has_full_network_access(&self) -> bool { - self.permissions - .iter() - .any(|perm| matches!(perm, SandboxPermission::NetworkFullAccess)) + match self { + SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ReadOnly => false, + SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, + } } + /// Returns the list of writable roots that should be passed down to the + /// Landlock rules installer, tailored to the current working directory. pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec { - let mut writable_roots = Vec::::new(); - for perm in &self.permissions { - use SandboxPermission::*; - match perm { - DiskWritePlatformUserTempFolder => { - if cfg!(target_os = "macos") { - if let Some(tempdir) = std::env::var_os("TMPDIR") { - // Likely something that starts with /var/folders/... - let tmpdir_path = PathBuf::from(&tempdir); - if tmpdir_path.is_absolute() { - writable_roots.push(tmpdir_path.clone()); - match tmpdir_path.canonicalize() { - Ok(canonicalized) => { - // Likely something that starts with /private/var/folders/... - if canonicalized != tmpdir_path { - writable_roots.push(canonicalized); - } - } - Err(e) => { - tracing::error!("Failed to canonicalize TMPDIR: {e}"); - } - } - } else { - tracing::error!("TMPDIR is not an absolute path: {tempdir:?}"); - } - } - } - - // For Linux, should this be XDG_RUNTIME_DIR, /run/user/, or something else? - } - DiskWritePlatformGlobalTempFolder => { - if cfg!(unix) { - writable_roots.push(PathBuf::from("/tmp")); + match self { + SandboxPolicy::DangerFullAccess => Vec::new(), + SandboxPolicy::ReadOnly => Vec::new(), + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + let mut roots = writable_roots.clone(); + roots.push(cwd.to_path_buf()); + + // Also include the per-user tmp dir on macOS. + // Note this is added dynamically rather than storing it in + // writable_roots because writable_roots contains only static + // values deserialized from the config file. + if cfg!(target_os = "macos") { + if let Some(tmpdir) = std::env::var_os("TMPDIR") { + roots.push(PathBuf::from(tmpdir)); } } - DiskWriteCwd => { - writable_roots.push(cwd.to_path_buf()); - } - DiskWriteFolder { folder } => { - writable_roots.push(folder.clone()); - } - DiskFullReadAccess | NetworkFullAccess => {} - DiskFullWriteAccess => { - // Currently, we expect callers to only invoke this method - // after verifying has_full_disk_write_access() is false. - } + + roots } } - writable_roots - } - - pub fn is_unrestricted(&self) -> bool { - self.has_full_disk_read_access() - && self.has_full_disk_write_access() - && self.has_full_network_access() } } -/// Permissions that should be granted to the sandbox in which the agent -/// operates. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum SandboxPermission { - /// Is allowed to read all files on disk. - DiskFullReadAccess, - - /// Is allowed to write to the operating system's temp dir that - /// is restricted to the user the agent is running as. For - /// example, on macOS, this is generally something under - /// `/var/folders` as opposed to `/tmp`. - DiskWritePlatformUserTempFolder, - - /// Is allowed to write to the operating system's shared temp - /// dir. On UNIX, this is generally `/tmp`. - DiskWritePlatformGlobalTempFolder, - - /// Is allowed to write to the current working directory (in practice, this - /// is the `cwd` where `codex` was spawned). - DiskWriteCwd, - - /// Is allowed to the specified folder. `PathBuf` must be an - /// absolute path, though it is up to the caller to canonicalize - /// it if the path contains symlinks. - DiskWriteFolder { folder: PathBuf }, - - /// Is allowed to write to any file on disk. - DiskFullWriteAccess, - - /// Can make arbitrary network requests. - NetworkFullAccess, -} - /// User input #[non_exhaustive] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] @@ -329,6 +275,10 @@ pub enum EventMsg { /// Agent has completed all actions TaskComplete(TaskCompleteEvent), + /// Token count event, sent periodically to report the number of tokens + /// used in the current session. + TokenCount(TokenUsage), + /// Agent text output message AgentMessage(AgentMessageEvent), @@ -376,6 +326,15 @@ pub struct TaskCompleteEvent { pub last_agent_message: Option, } +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct TokenUsage { + pub input_tokens: u64, + pub cached_input_tokens: Option, + pub output_tokens: u64, + pub reasoning_output_tokens: Option, + pub total_tokens: u64, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageEvent { pub message: String, diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 8417bf0c5d1..6a3ff29901f 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -31,12 +31,12 @@ pub fn assess_patch_safety( } match policy { - AskForApproval::OnFailure | AskForApproval::AutoEdit | AskForApproval::Never => { + AskForApproval::OnFailure | AskForApproval::Never => { // Continue to see if this can be auto-approved. } // TODO(ragona): I'm not sure this is actually correct? I believe in this case // we want to continue to the writable paths check before asking the user. - AskForApproval::UnlessAllowListed => { + AskForApproval::UnlessTrusted => { return SafetyCheck::AskUser; } } @@ -63,40 +63,71 @@ pub fn assess_patch_safety( } } +/// For a command to be run _without_ a sandbox, one of the following must be +/// true: +/// +/// - the user has explicitly approved the command +/// - the command is on the "known safe" list +/// - `DangerFullAccess` was specified and `UnlessTrusted` was not pub fn assess_command_safety( command: &[String], approval_policy: AskForApproval, sandbox_policy: &SandboxPolicy, approved: &HashSet>, ) -> SafetyCheck { - let approve_without_sandbox = || SafetyCheck::AutoApprove { - sandbox_type: SandboxType::None, - }; + use AskForApproval::*; + use SandboxPolicy::*; - // Previously approved or allow-listed commands - // All approval modes allow these commands to continue without sandboxing + // A command is "trusted" because either: + // - it belongs to a set of commands we consider "safe" by default, or + // - the user has explicitly approved the command for this session + // + // Currently, whether a command is "trusted" is a simple boolean, but we + // should include more metadata on this command test to indicate whether it + // should be run inside a sandbox or not. (This could be something the user + // defines as part of `execpolicy`.) + // + // For example, when `is_known_safe_command(command)` returns `true`, it + // would probably be fine to run the command in a sandbox, but when + // `approved.contains(command)` is `true`, the user may have approved it for + // the session _because_ they know it needs to run outside a sandbox. if is_known_safe_command(command) || approved.contains(command) { - // TODO(ragona): I think we should consider running even these inside the sandbox, but it's - // a change in behavior so I'm keeping it at parity with upstream for now. - return approve_without_sandbox(); + return SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, + }; } - // Command was not known-safe or allow-listed - if sandbox_policy.is_unrestricted() { - approve_without_sandbox() - } else { - match get_platform_sandbox() { - // We have a sandbox, so we can approve the command in all modes - Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type }, - None => { - // We do not have a sandbox, so we need to consider the approval policy - match approval_policy { - // Never is our "non-interactive" mode; it must automatically reject - AskForApproval::Never => SafetyCheck::Reject { - reason: "auto-rejected by user approval settings".to_string(), - }, - // Otherwise, we ask the user for approval - _ => SafetyCheck::AskUser, + match (approval_policy, sandbox_policy) { + (UnlessTrusted, _) => { + // Even though the user may have opted into DangerFullAccess, + // they also requested that we ask for approval for untrusted + // commands. + SafetyCheck::AskUser + } + (OnFailure, DangerFullAccess) | (Never, DangerFullAccess) => SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, + }, + (Never, ReadOnly) + | (Never, WorkspaceWrite { .. }) + | (OnFailure, ReadOnly) + | (OnFailure, WorkspaceWrite { .. }) => { + match get_platform_sandbox() { + Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type }, + None => { + if matches!(approval_policy, OnFailure) { + // Since the command is not trusted, even though the + // user has requested to only ask for approval on + // failure, we will ask the user because no sandbox is + // available. + SafetyCheck::AskUser + } else { + // We are in non-interactive mode and lack approval, so + // all we can do is reject the command. + SafetyCheck::Reject { + reason: "auto-rejected because command is not on trusted list" + .to_string(), + } + } } } } diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index b9c89f350ef..e072e9c342f 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -107,6 +107,7 @@ async fn keeps_previous_response_id_between_tasks() { env_key: Some("PATH".into()), env_key_instructions: None, wire_api: codex_core::WireApi::Responses, + query_params: None, }; // Init session diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 02c03681d0f..c1ef10c3376 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -96,6 +96,7 @@ async fn retries_on_early_close() { env_key: Some("PATH".into()), env_key_instructions: None, wire_api: codex_core::WireApi::Responses, + query_params: None, }; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index c3bde69719b..8c0c3737a2e 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -19,7 +19,11 @@ anyhow = "1" chrono = "0.4.40" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core" } -codex-common = { path = "../common", features = ["cli", "elapsed"] } +codex-common = { path = "../common", features = [ + "cli", + "elapsed", + "sandbox_summary", +] } codex-linux-sandbox = { path = "../linux-sandbox" } mcp-types = { path = "../mcp-types" } owo-colors = "4.2.0" diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 413fd23cb75..d9d577ebe6a 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,7 +1,6 @@ use clap::Parser; use clap::ValueEnum; use codex_common::CliConfigOverrides; -use codex_common::SandboxPermissionOption; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -19,12 +18,18 @@ pub struct Cli { #[arg(long = "profile", short = 'p')] pub config_profile: Option, - /// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR) + /// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, -c sandbox.mode=workspace-write). #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, - #[clap(flatten)] - pub sandbox: SandboxPermissionOption, + /// Skip all confirmation prompts and execute commands without sandboxing. + /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. + #[arg( + long = "dangerously-bypass-approvals-and-sandbox", + default_value_t = false, + conflicts_with = "full_auto" + )] + pub dangerously_bypass_approvals_and_sandbox: bool, /// Tell the agent to use the specified directory as its working root. #[clap(long = "cd", short = 'C', value_name = "DIR")] diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 4cbbd25f0b4..5320c572b90 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,4 +1,5 @@ use codex_common::elapsed::format_elapsed; +use codex_common::summarize_sandbox_policy; use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; @@ -15,6 +16,7 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::TokenUsage; use owo_colors::OwoColorize; use owo_colors::Style; use shlex::try_join; @@ -134,7 +136,7 @@ impl EventProcessor { ("model", config.model.clone()), ("provider", config.model_provider_id.clone()), ("approval", format!("{:?}", config.approval_policy)), - ("sandbox", format!("{:?}", config.sandbox_policy)), + ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), ]; if config.model_provider.wire_api == WireApi::Responses && model_supports_reasoning_summaries(&config.model) @@ -179,6 +181,9 @@ impl EventProcessor { EventMsg::TaskStarted | EventMsg::TaskComplete(_) => { // Ignore. } + EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { + ts_println!(self, "tokens used: {total_tokens}"); + } EventMsg::AgentMessage(AgentMessageEvent { message }) => { ts_println!( self, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 925e25d6702..8603a753d98 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -31,7 +31,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model, config_profile, full_auto, - sandbox, + dangerously_bypass_approvals_and_sandbox, cwd, skip_git_repo_check, color, @@ -85,9 +85,11 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any }; let sandbox_policy = if full_auto { - Some(SandboxPolicy::new_full_auto_policy()) + Some(SandboxPolicy::new_workspace_write_policy()) + } else if dangerously_bypass_approvals_and_sandbox { + Some(SandboxPolicy::DangerFullAccess) } else { - sandbox.permissions.clone().map(Into::into) + None }; // Load configuration and determine approval policy diff --git a/codex-rs/file-search/Cargo.toml b/codex-rs/file-search/Cargo.toml new file mode 100644 index 00000000000..bb5b80b2cf4 --- /dev/null +++ b/codex-rs/file-search/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codex-file-search" +version = { workspace = true } +edition = "2024" + +[[bin]] +name = "codex-file-search" +path = "src/main.rs" + +[lib] +name = "codex_file_search" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +ignore = "0.4.23" +nucleo-matcher = "0.3.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1.0.110" +tokio = { version = "1", features = ["full"] } diff --git a/codex-rs/file-search/README.md b/codex-rs/file-search/README.md new file mode 100644 index 00000000000..c47d494a18f --- /dev/null +++ b/codex-rs/file-search/README.md @@ -0,0 +1,5 @@ +# codex_file_search + +Fast fuzzy file search tool for Codex. + +Uses under the hood (which is what `ripgrep` uses) to traverse a directory (while honoring `.gitignore`, etc.) to produce the list of files to search and then uses to fuzzy-match the user supplied `PATTERN` against the corpus. diff --git a/codex-rs/file-search/src/cli.rs b/codex-rs/file-search/src/cli.rs new file mode 100644 index 00000000000..e3394f92da6 --- /dev/null +++ b/codex-rs/file-search/src/cli.rs @@ -0,0 +1,42 @@ +use std::num::NonZero; +use std::path::PathBuf; + +use clap::ArgAction; +use clap::Parser; + +/// Fuzzy matches filenames under a directory. +#[derive(Parser)] +#[command(version)] +pub struct Cli { + /// Whether to output results in JSON format. + #[clap(long, default_value = "false")] + pub json: bool, + + /// Maximum number of results to return. + #[clap(long, short = 'l', default_value = "64")] + pub limit: NonZero, + + /// Directory to search. + #[clap(long, short = 'C')] + pub cwd: Option, + + /// Include matching file indices in the output. + #[arg(long, default_value = "false")] + pub compute_indices: bool, + + // While it is common to default to the number of logical CPUs when creating + // a thread pool, empirically, the I/O of the filetree traversal offers + // limited parallelism and is the bottleneck, so using a smaller number of + // threads is more efficient. (Empirically, using more than 2 threads doesn't seem to provide much benefit.) + // + /// Number of worker threads to use. + #[clap(long, default_value = "2")] + pub threads: NonZero, + + /// Exclude patterns + #[arg(short, long, action = ArgAction::Append)] + pub exclude: Vec, + + /// Search pattern. + pub pattern: Option, +} diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs new file mode 100644 index 00000000000..2365c176696 --- /dev/null +++ b/codex-rs/file-search/src/lib.rs @@ -0,0 +1,400 @@ +use ignore::WalkBuilder; +use ignore::overrides::OverrideBuilder; +use nucleo_matcher::Matcher; +use nucleo_matcher::Utf32Str; +use nucleo_matcher::pattern::AtomKind; +use nucleo_matcher::pattern::CaseMatching; +use nucleo_matcher::pattern::Normalization; +use nucleo_matcher::pattern::Pattern; +use serde::Serialize; +use std::cell::UnsafeCell; +use std::cmp::Reverse; +use std::collections::BinaryHeap; +use std::num::NonZero; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use tokio::process::Command; + +mod cli; + +pub use cli::Cli; + +/// A single match result returned from the search. +/// +/// * `score` – Relevance score returned by `nucleo_matcher`. +/// * `path` – Path to the matched file (relative to the search directory). +/// * `indices` – Optional list of character indices that matched the query. +/// These are only filled when the caller of [`run`] sets +/// `compute_indices` to `true`. The indices vector follows the +/// guidance from `nucleo_matcher::Pattern::indices`: they are +/// unique and sorted in ascending order so that callers can use +/// them directly for highlighting. +#[derive(Debug, Clone, Serialize)] +pub struct FileMatch { + pub score: u32, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub indices: Option>, // Sorted & deduplicated when present +} + +pub struct FileSearchResults { + pub matches: Vec, + pub total_match_count: usize, +} + +pub trait Reporter { + fn report_match(&self, file_match: &FileMatch); + fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize); + fn warn_no_search_pattern(&self, search_directory: &Path); +} + +pub async fn run_main( + Cli { + pattern, + limit, + cwd, + compute_indices, + json: _, + exclude, + threads, + }: Cli, + reporter: T, +) -> anyhow::Result<()> { + let search_directory = match cwd { + Some(dir) => dir, + None => std::env::current_dir()?, + }; + let pattern_text = match pattern { + Some(pattern) => pattern, + None => { + reporter.warn_no_search_pattern(&search_directory); + #[cfg(unix)] + Command::new("ls") + .arg("-al") + .current_dir(search_directory) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await?; + #[cfg(windows)] + { + Command::new("cmd") + .arg("/c") + .arg(search_directory) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await?; + } + return Ok(()); + } + }; + + let cancel_flag = Arc::new(AtomicBool::new(false)); + let FileSearchResults { + total_match_count, + matches, + } = run( + &pattern_text, + limit, + &search_directory, + exclude, + threads, + cancel_flag, + compute_indices, + )?; + let match_count = matches.len(); + let matches_truncated = total_match_count > match_count; + + for file_match in matches { + reporter.report_match(&file_match); + } + if matches_truncated { + reporter.warn_matches_truncated(total_match_count, match_count); + } + + Ok(()) +} + +/// The worker threads will periodically check `cancel_flag` to see if they +/// should stop processing files. +pub fn run( + pattern_text: &str, + limit: NonZero, + search_directory: &Path, + exclude: Vec, + threads: NonZero, + cancel_flag: Arc, + compute_indices: bool, +) -> anyhow::Result { + let pattern = create_pattern(pattern_text); + // Create one BestMatchesList per worker thread so that each worker can + // operate independently. The results across threads will be merged when + // the traversal is complete. + let WorkerCount { + num_walk_builder_threads, + num_best_matches_lists, + } = create_worker_count(threads); + let best_matchers_per_worker: Vec> = (0..num_best_matches_lists) + .map(|_| { + UnsafeCell::new(BestMatchesList::new( + limit.get(), + pattern.clone(), + Matcher::new(nucleo_matcher::Config::DEFAULT), + )) + }) + .collect(); + + // Use the same tree-walker library that ripgrep uses. We use it directly so + // that we can leverage the parallelism it provides. + let mut walk_builder = WalkBuilder::new(search_directory); + walk_builder.threads(num_walk_builder_threads); + if !exclude.is_empty() { + let mut override_builder = OverrideBuilder::new(search_directory); + for exclude in exclude { + // The `!` prefix is used to indicate an exclude pattern. + let exclude_pattern = format!("!{}", exclude); + override_builder.add(&exclude_pattern)?; + } + let override_matcher = override_builder.build()?; + walk_builder.overrides(override_matcher); + } + let walker = walk_builder.build_parallel(); + + // Each worker created by `WalkParallel::run()` will have its own + // `BestMatchesList` to update. + let index_counter = AtomicUsize::new(0); + walker.run(|| { + let index = index_counter.fetch_add(1, Ordering::Relaxed); + let best_list_ptr = best_matchers_per_worker[index].get(); + let best_list = unsafe { &mut *best_list_ptr }; + + // Each worker keeps a local counter so we only read the atomic flag + // every N entries which is cheaper than checking on every file. + const CHECK_INTERVAL: usize = 1024; + let mut processed = 0; + + let cancel = cancel_flag.clone(); + + Box::new(move |entry| { + if let Some(path) = get_file_path(&entry, search_directory) { + best_list.insert(path); + } + + processed += 1; + if processed % CHECK_INTERVAL == 0 && cancel.load(Ordering::Relaxed) { + ignore::WalkState::Quit + } else { + ignore::WalkState::Continue + } + }) + }); + + fn get_file_path<'a>( + entry_result: &'a Result, + search_directory: &std::path::Path, + ) -> Option<&'a str> { + let entry = match entry_result { + Ok(e) => e, + Err(_) => return None, + }; + if entry.file_type().is_some_and(|ft| ft.is_dir()) { + return None; + } + let path = entry.path(); + match path.strip_prefix(search_directory) { + Ok(rel_path) => rel_path.to_str(), + Err(_) => None, + } + } + + // If the cancel flag is set, we return early with an empty result. + if cancel_flag.load(Ordering::Relaxed) { + return Ok(FileSearchResults { + matches: Vec::new(), + total_match_count: 0, + }); + } + + // Merge results across best_matchers_per_worker. + let mut global_heap: BinaryHeap> = BinaryHeap::new(); + let mut total_match_count = 0; + for best_list_cell in best_matchers_per_worker.iter() { + let best_list = unsafe { &*best_list_cell.get() }; + total_match_count += best_list.num_matches; + for &Reverse((score, ref line)) in best_list.binary_heap.iter() { + if global_heap.len() < limit.get() { + global_heap.push(Reverse((score, line.clone()))); + } else if let Some(min_element) = global_heap.peek() { + if score > min_element.0.0 { + global_heap.pop(); + global_heap.push(Reverse((score, line.clone()))); + } + } + } + } + + let mut raw_matches: Vec<(u32, String)> = global_heap.into_iter().map(|r| r.0).collect(); + sort_matches(&mut raw_matches); + + // Transform into `FileMatch`, optionally computing indices. + let mut matcher = if compute_indices { + Some(Matcher::new(nucleo_matcher::Config::DEFAULT)) + } else { + None + }; + + let matches: Vec = raw_matches + .into_iter() + .map(|(score, path)| { + let indices = if compute_indices { + let mut buf = Vec::::new(); + let haystack: Utf32Str<'_> = Utf32Str::new(&path, &mut buf); + let mut idx_vec: Vec = Vec::new(); + if let Some(ref mut m) = matcher { + // Ignore the score returned from indices – we already have `score`. + pattern.indices(haystack, m, &mut idx_vec); + } + idx_vec.sort_unstable(); + idx_vec.dedup(); + Some(idx_vec) + } else { + None + }; + + FileMatch { + score, + path, + indices, + } + }) + .collect(); + + Ok(FileSearchResults { + matches, + total_match_count, + }) +} + +/// Sort matches in-place by descending score, then ascending path. +fn sort_matches(matches: &mut [(u32, String)]) { + matches.sort_by(|a, b| match b.0.cmp(&a.0) { + std::cmp::Ordering::Equal => a.1.cmp(&b.1), + other => other, + }); +} + +/// Maintains the `max_count` best matches for a given pattern. +struct BestMatchesList { + max_count: usize, + num_matches: usize, + pattern: Pattern, + matcher: Matcher, + binary_heap: BinaryHeap>, + + /// Internal buffer for converting strings to UTF-32. + utf32buf: Vec, +} + +impl BestMatchesList { + fn new(max_count: usize, pattern: Pattern, matcher: Matcher) -> Self { + Self { + max_count, + num_matches: 0, + pattern, + matcher, + binary_heap: BinaryHeap::new(), + utf32buf: Vec::::new(), + } + } + + fn insert(&mut self, line: &str) { + let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut self.utf32buf); + if let Some(score) = self.pattern.score(haystack, &mut self.matcher) { + // In the tests below, we verify that score() returns None for a + // non-match, so we can categorically increment the count here. + self.num_matches += 1; + + if self.binary_heap.len() < self.max_count { + self.binary_heap.push(Reverse((score, line.to_string()))); + } else if let Some(min_element) = self.binary_heap.peek() { + if score > min_element.0.0 { + self.binary_heap.pop(); + self.binary_heap.push(Reverse((score, line.to_string()))); + } + } + } + } +} + +struct WorkerCount { + num_walk_builder_threads: usize, + num_best_matches_lists: usize, +} + +fn create_worker_count(num_workers: NonZero) -> WorkerCount { + // It appears that the number of times the function passed to + // `WalkParallel::run()` is called is: the number of threads specified to + // the builder PLUS ONE. + // + // In `WalkParallel::visit()`, the builder function gets called once here: + // https://github.com/BurntSushi/ripgrep/blob/79cbe89deb1151e703f4d91b19af9cdcc128b765/crates/ignore/src/walk.rs#L1233 + // + // And then once for every worker here: + // https://github.com/BurntSushi/ripgrep/blob/79cbe89deb1151e703f4d91b19af9cdcc128b765/crates/ignore/src/walk.rs#L1288 + let num_walk_builder_threads = num_workers.get(); + let num_best_matches_lists = num_walk_builder_threads + 1; + + WorkerCount { + num_walk_builder_threads, + num_best_matches_lists, + } +} + +fn create_pattern(pattern: &str) -> Pattern { + Pattern::new( + pattern, + CaseMatching::Smart, + Normalization::Smart, + AtomKind::Fuzzy, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_score_is_none_for_non_match() { + let mut utf32buf = Vec::::new(); + let line = "hello"; + let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT); + let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf); + let pattern = create_pattern("zzz"); + let score = pattern.score(haystack, &mut matcher); + assert_eq!(score, None); + } + + #[test] + fn tie_breakers_sort_by_path_when_scores_equal() { + let mut matches = vec![ + (100, "b_path".to_string()), + (100, "a_path".to_string()), + (90, "zzz".to_string()), + ]; + + sort_matches(&mut matches); + + // Highest score first; ties broken alphabetically. + let expected = vec![ + (100, "a_path".to_string()), + (100, "b_path".to_string()), + (90, "zzz".to_string()), + ]; + + assert_eq!(matches, expected); + } +} diff --git a/codex-rs/file-search/src/main.rs b/codex-rs/file-search/src/main.rs new file mode 100644 index 00000000000..6635dc03865 --- /dev/null +++ b/codex-rs/file-search/src/main.rs @@ -0,0 +1,78 @@ +use std::io::IsTerminal; +use std::path::Path; + +use clap::Parser; +use codex_file_search::Cli; +use codex_file_search::FileMatch; +use codex_file_search::Reporter; +use codex_file_search::run_main; +use serde_json::json; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let reporter = StdioReporter { + write_output_as_json: cli.json, + show_indices: cli.compute_indices && std::io::stdout().is_terminal(), + }; + run_main(cli, reporter).await?; + Ok(()) +} + +struct StdioReporter { + write_output_as_json: bool, + show_indices: bool, +} + +impl Reporter for StdioReporter { + fn report_match(&self, file_match: &FileMatch) { + if self.write_output_as_json { + println!("{}", serde_json::to_string(&file_match).unwrap()); + } else if self.show_indices { + let indices = file_match + .indices + .as_ref() + .expect("--compute-indices was specified"); + // `indices` is guaranteed to be sorted in ascending order. Instead + // of calling `contains` for every character (which would be O(N^2) + // in the worst-case), walk through the `indices` vector once while + // iterating over the characters. + let mut indices_iter = indices.iter().peekable(); + + for (i, c) in file_match.path.chars().enumerate() { + match indices_iter.peek() { + Some(next) if **next == i as u32 => { + // ANSI escape code for bold: \x1b[1m ... \x1b[0m + print!("\x1b[1m{}\x1b[0m", c); + // advance the iterator since we've consumed this index + indices_iter.next(); + } + _ => { + print!("{}", c); + } + } + } + println!(); + } else { + println!("{}", file_match.path); + } + } + + fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize) { + if self.write_output_as_json { + let value = json!({"matches_truncated": true}); + println!("{}", serde_json::to_string(&value).unwrap()); + } else { + eprintln!( + "Warning: showing {shown_match_count} out of {total_match_count} results. Provide a more specific pattern or increase the --limit.", + ); + } + } + + fn warn_no_search_pattern(&self, search_directory: &Path) { + eprintln!( + "No search pattern specified. Showing the contents of the current directory ({}):", + search_directory.to_string_lossy() + ); + } +} diff --git a/codex-rs/justfile b/codex-rs/justfile index c09465a4828..83a390ec565 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -16,6 +16,10 @@ exec *args: tui *args: cargo run --bin codex -- tui "$@" +# Run the CLI version of the file-search crate. +file-search *args: + cargo run --bin codex-file-search -- "$@" + # format code fmt: cargo fmt -- --config imports_granularity=Item diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 8d1e3a1cc1f..c8cd1078c0d 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -15,15 +15,9 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = "1" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core" } -codex-common = { path = "../common", features = ["cli"] } - -# Used for error handling in the helper that unifies runtime dispatch across -# binaries. -anyhow = "1" -# Required to construct a Tokio runtime for async execution of the caller's -# entry-point. tokio = { version = "1", features = ["rt-multi-thread"] } [dev-dependencies] diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index a8c73aa75db..ac6ac445e4a 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -1,13 +1,16 @@ use clap::Parser; -use codex_common::SandboxPermissionOption; use std::ffi::CString; +use std::path::PathBuf; use crate::landlock::apply_sandbox_policy_to_current_thread; #[derive(Debug, Parser)] pub struct LandlockCommand { - #[clap(flatten)] - pub sandbox: SandboxPermissionOption, + /// It is possible that the cwd used in the context of the sandbox policy + /// is different from the cwd of the process to spawn. + pub sandbox_policy_cwd: PathBuf, + + pub sandbox_policy: codex_core::protocol::SandboxPolicy, /// Full command args to run under landlock. #[arg(trailing_var_arg = true)] @@ -15,21 +18,13 @@ pub struct LandlockCommand { } pub fn run_main() -> ! { - let LandlockCommand { sandbox, command } = LandlockCommand::parse(); - - let sandbox_policy = match sandbox.permissions.map(Into::into) { - Some(sandbox_policy) => sandbox_policy, - None => codex_core::protocol::SandboxPolicy::new_read_only_policy(), - }; - - let cwd = match std::env::current_dir() { - Ok(cwd) => cwd, - Err(e) => { - panic!("failed to getcwd(): {e:?}"); - } - }; + let LandlockCommand { + sandbox_policy_cwd, + sandbox_policy, + command, + } = LandlockCommand::parse(); - if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &cwd) { + if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd) { panic!("error running landlock: {e:?}"); } diff --git a/codex-rs/linux-sandbox/tests/landlock.rs b/codex-rs/linux-sandbox/tests/landlock.rs index 17bdd9d8016..b495c3465c4 100644 --- a/codex-rs/linux-sandbox/tests/landlock.rs +++ b/codex-rs/linux-sandbox/tests/landlock.rs @@ -46,7 +46,10 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { env: create_env_from_core_vars(), }; - let sandbox_policy = SandboxPolicy::new_read_only_policy_with_writable_roots(writable_roots); + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.to_vec(), + network_access: false, + }; let sandbox_program = env!("CARGO_BIN_EXE_codex-linux-sandbox"); let codex_linux_sandbox_exe = Some(PathBuf::from(sandbox_program)); let ctrl_c = Arc::new(Notify::new()); diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 03e72344493..86541a0b9aa 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -1,7 +1,6 @@ //! Configuration object accepted by the `codex` MCP tool-call. use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use mcp_types::Tool; use mcp_types::ToolInputSchema; use schemars::JsonSchema; @@ -19,7 +18,7 @@ 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") + /// Optional override for the model name (e.g. "o3", "o4-mini"). #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, @@ -37,27 +36,18 @@ pub(crate) struct CodexToolCallParam { #[serde(default, skip_serializing_if = "Option::is_none")] pub approval_policy: Option, - /// 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>, - /// Individual config settings that will override what is in /// CODEX_HOME/config.toml. #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option>, } -// 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. - +// Custom enum mirroring `AskForApproval`, but constrained to the subset we +// expose via the tool-call schema. #[derive(Debug, Clone, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub(crate) enum CodexToolCallApprovalPolicy { - AutoEdit, - UnlessAllowListed, + Untrusted, OnFailure, Never, } @@ -65,58 +55,19 @@ pub(crate) enum CodexToolCallApprovalPolicy { impl From for AskForApproval { fn from(value: CodexToolCallApprovalPolicy) -> Self { match value { - CodexToolCallApprovalPolicy::AutoEdit => AskForApproval::AutoEdit, - CodexToolCallApprovalPolicy::UnlessAllowListed => AskForApproval::UnlessAllowListed, + CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted, 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 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 - } - } - } -} - +/// Builds a `Tool` definition (JSON schema etc.) for the Codex tool-call. 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 + s.option_add_null_type = false; }) .into_generator() .into_root_schema_for::(); @@ -129,12 +80,12 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool { serde_json::from_value::(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(), + "Run a Codex session. Accepts configuration parameters matching the Codex Config struct.".to_string(), ), annotations: None, } @@ -142,7 +93,7 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool { impl CodexToolCallParam { /// Returns the initial user prompt to start the Codex conversation and the - /// Config. + /// effective Config object generated from the supplied parameters. pub fn into_config( self, codex_linux_sandbox_exe: Option, @@ -153,20 +104,18 @@ impl CodexToolCallParam { profile, cwd, approval_policy, - sandbox_permissions, config: cli_overrides, } = self; - let sandbox_policy = sandbox_permissions.map(|perms| { - SandboxPolicy::from(perms.into_iter().map(Into::into).collect::>()) - }); - // Build ConfigOverrides recognised by codex-core. + // Build the `ConfigOverrides` recognised by codex-core. let overrides = codex_core::config::ConfigOverrides { model, config_profile: profile, cwd: cwd.map(PathBuf::from), approval_policy: approval_policy.map(Into::into), - sandbox_policy, + // Note we may want to expose a field on CodexToolCallParam to + // facilitate configuring the sandbox policy. + sandbox_policy: None, model_provider: None, codex_linux_sandbox_exe, }; @@ -213,8 +162,7 @@ mod tests { "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", + "untrusted", "on-failure", "never" ], @@ -230,7 +178,7 @@ mod tests { "type": "string" }, "model": { - "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\")", + "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").", "type": "string" }, "profile": { @@ -241,21 +189,6 @@ mod tests { "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" diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 67c990b00c4..796a119e5cf 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -162,6 +162,7 @@ pub async fn run_codex_tool_session( } EventMsg::Error(_) | EventMsg::TaskStarted + | EventMsg::TokenCount(_) | EventMsg::AgentReasoning(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) diff --git a/codex-rs/scripts/create_github_release.sh b/codex-rs/scripts/create_github_release.sh index 87e498e2bf6..84dcb95fa07 100755 --- a/codex-rs/scripts/create_github_release.sh +++ b/codex-rs/scripts/create_github_release.sh @@ -2,6 +2,13 @@ set -euo pipefail +# By default, this script uses a version based on the current date and time. +# If you want to specify a version, pass it as the first argument. Example: +# +# ./scripts/create_github_release.sh 0.1.0-alpha.4 +# +# The value will be used to update the `version` field in `Cargo.toml`. + # Change to the root of the Cargo workspace. cd "$(dirname "${BASH_SOURCE[0]}")/.." @@ -15,7 +22,11 @@ fi 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)") +if [ $# -ge 1 ]; then + VERSION="$1" +else + VERSION=$(printf '0.0.%d' "$(date +%y%m%d%H%M)") +fi TAG="rust-v$VERSION" git checkout -b "$TAG" perl -i -pe "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml @@ -23,4 +34,5 @@ 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" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2d7840e661d..20b01561867 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -20,7 +20,12 @@ base64 = "0.22.1" clap = { version = "4", features = ["derive"] } codex-ansi-escape = { path = "../ansi-escape" } codex-core = { path = "../core" } -codex-common = { path = "../common", features = ["cli", "elapsed"] } +codex-common = { path = "../common", features = [ + "cli", + "elapsed", + "sandbox_summary", +] } +codex-file-search = { path = "../file-search" } codex-linux-sandbox = { path = "../linux-sandbox" } codex-login = { path = "../login" } color-eyre = "0.6.3" diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ff61b5c9417..4b8b9b78120 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,6 +1,8 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; +use crate::file_search::FileSearchManager; +use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; use crate::login_screen::LoginScreen; @@ -10,7 +12,6 @@ use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; use codex_core::protocol::Event; -use codex_core::protocol::Op; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -43,6 +44,8 @@ pub(crate) struct App<'a> { /// Config is stored here so we can recreate ChatWidgets as needed. config: Config, + file_search: FileSearchManager, + /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, @@ -156,11 +159,13 @@ impl<'a> App<'a> { ) }; + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); Self { app_event_tx, app_event_rx, app_state, config, + file_search, chat_args, } } @@ -192,10 +197,11 @@ impl<'a> App<'a> { modifiers: crossterm::event::KeyModifiers::CONTROL, .. } => { - // Forward interrupt to ChatWidget when active. match &mut self.app_state { AppState::Chat { widget } => { - widget.submit_op(Op::Interrupt); + if widget.on_ctrl_c() { + self.app_event_tx.send(AppEvent::ExitRequest); + } } AppState::Login { .. } | AppState::GitWarning { .. } => { // No-op. @@ -250,7 +256,36 @@ impl<'a> App<'a> { SlashCommand::Quit => { break; } + SlashCommand::Diff => { + let (is_git_repo, diff_text) = match get_git_diff() { + Ok(v) => v, + Err(e) => { + let msg = format!("Failed to compute diff: {e}"); + if let AppState::Chat { widget } = &mut self.app_state { + widget.add_diff_output(msg); + } + continue; + } + }; + + if let AppState::Chat { widget } = &mut self.app_state { + let text = if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + }; + widget.add_diff_output(text); + } + } }, + AppEvent::StartFileSearch(query) => { + self.file_search.on_user_query(query); + } + AppEvent::FileSearchResult { query, matches } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.apply_file_search_result(query, matches); + } + } } } terminal.clear()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8fc55752b6e..dd89b853319 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,4 +1,5 @@ use codex_core::protocol::Event; +use codex_file_search::FileMatch; use crossterm::event::KeyEvent; use crate::slash_command::SlashCommand; @@ -28,4 +29,17 @@ pub(crate) enum AppEvent { /// Dispatch a recognized slash command from the UI (composer) to the app /// layer so it can be handled centrally. DispatchCommand(SlashCommand), + + /// Kick off an asynchronous file search for the given query (text after + /// the `@`). Previous searches may be cancelled by the app layer so there + /// is at most one in-flight search. + StartFileSearch(String), + + /// Result of a completed asynchronous file search. The `query` echoes the + /// original search term so the UI can decide whether the results are + /// still relevant. + FileSearchResult { + query: String, + matches: Vec, + }, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1218f76ec76..59d6e4579d2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,4 @@ +use codex_core::protocol::TokenUsage; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Alignment; @@ -15,15 +16,19 @@ use tui_textarea::TextArea; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; +use super::file_search_popup::FileSearchPopup; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use codex_file_search::FileMatch; /// Minimum number of visible text rows inside the textarea. const MIN_TEXTAREA_ROWS: usize = 1; /// Rows consumed by the border. const BORDER_LINES: u16 = 2; +const BASE_PLACEHOLDER_TEXT: &str = "send a message"; + /// Result returned when the user interacts with the text area. pub enum InputResult { Submitted(String), @@ -32,27 +37,75 @@ pub enum InputResult { pub(crate) struct ChatComposer<'a> { textarea: TextArea<'a>, - command_popup: Option, + active_popup: ActivePopup, app_event_tx: AppEventSender, history: ChatComposerHistory, + ctrl_c_quit_hint: bool, + dismissed_file_popup_token: Option, + current_file_query: Option, +} + +/// Popup state – at most one can be visible at any time. +enum ActivePopup { + None, + Command(CommandPopup), + File(FileSearchPopup), } impl ChatComposer<'_> { pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self { let mut textarea = TextArea::default(); - textarea.set_placeholder_text("send a message"); + textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT); textarea.set_cursor_line_style(ratatui::style::Style::default()); let mut this = Self { textarea, - command_popup: None, + active_popup: ActivePopup::None, app_event_tx, history: ChatComposerHistory::new(), + ctrl_c_quit_hint: false, + dismissed_file_popup_token: None, + current_file_query: None, }; this.update_border(has_input_focus); this } + /// Update the cached *context-left* percentage and refresh the placeholder + /// text. The UI relies on the placeholder to convey the remaining + /// context when the composer is empty. + pub(crate) fn set_token_usage( + &mut self, + token_usage: TokenUsage, + model_context_window: Option, + ) { + let placeholder = match (token_usage.total_tokens, model_context_window) { + (total_tokens, Some(context_window)) => { + let percent_remaining: u8 = if context_window > 0 { + // Calculate the percentage of context left. + let percent = 100.0 - (total_tokens as f32 / context_window as f32 * 100.0); + percent.clamp(0.0, 100.0) as u8 + } else { + // If we don't have a context window, we cannot compute the + // percentage. + 100 + }; + if percent_remaining > 25 { + format!("{BASE_PLACEHOLDER_TEXT} — {percent_remaining}% context left") + } else { + format!( + "{BASE_PLACEHOLDER_TEXT} — {percent_remaining}% context left (consider /compact)" + ) + } + } + (total_tokens, None) => { + format!("{BASE_PLACEHOLDER_TEXT} — {total_tokens} tokens used") + } + }; + + self.textarea.set_placeholder_text(placeholder); + } + /// Record the history metadata advertised by `SessionConfiguredEvent` so /// that the composer can navigate cross-session history. pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { @@ -76,24 +129,51 @@ impl ChatComposer<'_> { self.update_border(has_focus); } + /// Integrate results from an asynchronous file search. + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + // Only apply if user is still editing a token starting with `query`. + let current_opt = Self::current_at_token(&self.textarea); + let Some(current_token) = current_opt else { + return; + }; + + if !current_token.starts_with(&query) { + return; + } + + if let ActivePopup::File(popup) = &mut self.active_popup { + popup.set_matches(&query, matches); + } + } + + pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { + self.ctrl_c_quit_hint = show; + self.update_border(has_focus); + } + /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - let result = match self.command_popup { - Some(_) => self.handle_key_event_with_popup(key_event), - None => self.handle_key_event_without_popup(key_event), + let result = match &mut self.active_popup { + ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), + ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. self.sync_command_popup(); + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + } else { + self.sync_file_search_popup(); + } result } /// Handle key event when the slash-command popup is visible. - fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - let Some(popup) = self.command_popup.as_mut() else { - tracing::error!("handle_key_event_with_popup called without an active popup"); - return (InputResult::None, false); + fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let ActivePopup::Command(popup) = &mut self.active_popup else { + unreachable!(); }; match key_event.into() { @@ -141,7 +221,7 @@ impl ChatComposer<'_> { self.textarea.cut(); // Hide popup since the command has been dispatched. - self.command_popup = None; + self.active_popup = ActivePopup::None; return (InputResult::None, true); } // Fallback to default newline handling if no command selected. @@ -151,6 +231,149 @@ impl ChatComposer<'_> { } } + /// Handle key events when file search popup is visible. + fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let ActivePopup::File(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event.into() { + Input { key: Key::Up, .. } => { + popup.move_up(); + (InputResult::None, true) + } + Input { key: Key::Down, .. } => { + popup.move_down(); + (InputResult::None, true) + } + Input { key: Key::Esc, .. } => { + // Hide popup without modifying text, remember token to avoid immediate reopen. + if let Some(tok) = Self::current_at_token(&self.textarea) { + self.dismissed_file_popup_token = Some(tok.to_string()); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + Input { key: Key::Tab, .. } + | Input { + key: Key::Enter, + ctrl: false, + alt: false, + shift: false, + } => { + if let Some(sel) = popup.selected_match() { + let sel_path = sel.to_string(); + // Drop popup borrow before using self mutably again. + self.insert_selected_path(&sel_path); + self.active_popup = ActivePopup::None; + return (InputResult::None, true); + } + (InputResult::None, false) + } + input => self.handle_input_basic(input), + } + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + /// + /// Behavior: + /// - The cursor may be anywhere *inside* the token (including on the + /// leading `@`). It does **not** need to be at the end of the line. + /// - A token is delimited by ASCII whitespace (space, tab, newline). + /// - If the token under the cursor starts with `@` and contains at least + /// one additional character, that token (without `@`) is returned. + fn current_at_token(textarea: &tui_textarea::TextArea) -> Option { + let (row, col) = textarea.cursor(); + + // Guard against out-of-bounds rows. + let line = textarea.lines().get(row)?.as_str(); + + // Clamp the cursor column to the line length to avoid slicing panics + // when the cursor is at the end of the line. + let col = col.min(line.len()); + + // Split the line at the cursor position so we can search for word + // boundaries on both sides. + let before_cursor = &line[..col]; + let after_cursor = &line[col..]; + + // Find start index (first character **after** the previous whitespace). + let start_idx = before_cursor + .rfind(|c: char| c.is_whitespace()) + .map(|idx| idx + 1) + .unwrap_or(0); + + // Find end index (first whitespace **after** the cursor position). + let end_rel_idx = after_cursor + .find(|c: char| c.is_whitespace()) + .unwrap_or(after_cursor.len()); + let end_idx = col + end_rel_idx; + + if start_idx >= end_idx { + return None; + } + + let token = &line[start_idx..end_idx]; + + if token.starts_with('@') && token.len() > 1 { + Some(token[1..].to_string()) + } else { + None + } + } + + /// Replace the active `@token` (the one under the cursor) with `path`. + /// + /// The algorithm mirrors `current_at_token` so replacement works no matter + /// where the cursor is within the token and regardless of how many + /// `@tokens` exist in the line. + fn insert_selected_path(&mut self, path: &str) { + let (row, col) = self.textarea.cursor(); + + // Materialize the textarea lines so we can mutate them easily. + let mut lines: Vec = self.textarea.lines().to_vec(); + + if let Some(line) = lines.get_mut(row) { + let col = col.min(line.len()); + + let before_cursor = &line[..col]; + let after_cursor = &line[col..]; + + // Determine token boundaries. + let start_idx = before_cursor + .rfind(|c: char| c.is_whitespace()) + .map(|idx| idx + 1) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .find(|c: char| c.is_whitespace()) + .unwrap_or(after_cursor.len()); + let end_idx = col + end_rel_idx; + + // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. + let mut new_line = + String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1); + new_line.push_str(&line[..start_idx]); + new_line.push_str(path); + new_line.push(' '); + new_line.push_str(&line[end_idx..]); + + *line = new_line; + + // Re-populate the textarea. + let new_text = lines.join("\n"); + self.textarea.select_all(); + self.textarea.cut(); + let _ = self.textarea.insert_str(new_text); + + // Note: tui-textarea currently exposes only relative cursor + // movements. Leaving the cursor position unchanged is acceptable + // as subsequent typing will move the cursor naturally. + } + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let input: Input = key_event.into(); @@ -235,25 +458,67 @@ impl ChatComposer<'_> { .map(|s| s.as_str()) .unwrap_or(""); - if first_line.starts_with('/') { - // Create popup lazily when the user starts a slash command. - let popup = self.command_popup.get_or_insert_with(CommandPopup::new); + let input_starts_with_slash = first_line.starts_with('/'); + match &mut self.active_popup { + ActivePopup::Command(popup) => { + if input_starts_with_slash { + popup.on_composer_text_change(first_line.to_string()); + } else { + self.active_popup = ActivePopup::None; + } + } + _ => { + if input_starts_with_slash { + let mut command_popup = CommandPopup::new(); + command_popup.on_composer_text_change(first_line.to_string()); + self.active_popup = ActivePopup::Command(command_popup); + } + } + } + } + + /// Synchronize `self.file_search_popup` with the current text in the textarea. + /// Note this is only called when self.active_popup is NOT Command. + fn sync_file_search_popup(&mut self) { + // Determine if there is an @token underneath the cursor. + let query = match Self::current_at_token(&self.textarea) { + Some(token) => token, + None => { + self.active_popup = ActivePopup::None; + self.dismissed_file_popup_token = None; + return; + } + }; + + // If user dismissed popup for this exact query, don't reopen until text changes. + if self.dismissed_file_popup_token.as_ref() == Some(&query) { + return; + } - // Forward *only* the first line since `CommandPopup` only needs - // the command token. - popup.on_composer_text_change(first_line.to_string()); - } else if self.command_popup.is_some() { - // Remove popup when '/' is no longer the first character. - self.command_popup = None; + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + + match &mut self.active_popup { + ActivePopup::File(popup) => { + popup.set_query(&query); + } + _ => { + let mut popup = FileSearchPopup::new(); + popup.set_query(&query); + self.active_popup = ActivePopup::File(popup); + } } + + self.current_file_query = Some(query); + self.dismissed_file_popup_token = None; } pub fn calculate_required_height(&self, area: &Rect) -> u16 { let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); - let num_popup_rows = if let Some(popup) = &self.command_popup { - popup.calculate_required_height(area) - } else { - 0 + let num_popup_rows = match &self.active_popup { + ActivePopup::Command(popup) => popup.calculate_required_height(area), + ActivePopup::File(popup) => popup.calculate_required_height(area), + ActivePopup::None => 0, }; rows as u16 + BORDER_LINES + num_popup_rows @@ -266,10 +531,17 @@ impl ChatComposer<'_> { } let bs = if has_focus { - BlockState { - right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline") - .alignment(Alignment::Right), - border_style: Style::default(), + if self.ctrl_c_quit_hint { + BlockState { + right_title: Line::from("Ctrl+C to quit").alignment(Alignment::Right), + border_style: Style::default(), + } + } else { + BlockState { + right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline") + .alignment(Alignment::Right), + border_style: Style::default(), + } } } else { BlockState { @@ -287,36 +559,62 @@ impl ChatComposer<'_> { ); } - pub(crate) fn is_command_popup_visible(&self) -> bool { - self.command_popup.is_some() + pub(crate) fn is_popup_visible(&self) -> bool { + match self.active_popup { + ActivePopup::Command(_) | ActivePopup::File(_) => true, + ActivePopup::None => false, + } } } impl WidgetRef for &ChatComposer<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if let Some(popup) = &self.command_popup { - let popup_height = popup.calculate_required_height(&area); - - // Split the provided rect so that the popup is rendered at the - // *top* and the textarea occupies the remaining space below. - let popup_rect = Rect { - x: area.x, - y: area.y, - width: area.width, - height: popup_height.min(area.height), - }; - - let textarea_rect = Rect { - x: area.x, - y: area.y + popup_rect.height, - width: area.width, - height: area.height.saturating_sub(popup_rect.height), - }; - - popup.render(popup_rect, buf); - self.textarea.render(textarea_rect, buf); - } else { - self.textarea.render(area, buf); + match &self.active_popup { + ActivePopup::Command(popup) => { + let popup_height = popup.calculate_required_height(&area); + + // Split the provided rect so that the popup is rendered at the + // *top* and the textarea occupies the remaining space below. + let popup_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: popup_height.min(area.height), + }; + + let textarea_rect = Rect { + x: area.x, + y: area.y + popup_rect.height, + width: area.width, + height: area.height.saturating_sub(popup_rect.height), + }; + + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } + ActivePopup::File(popup) => { + let popup_height = popup.calculate_required_height(&area); + + let popup_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: popup_height.min(area.height), + }; + + let textarea_rect = Rect { + x: area.x, + y: area.y + popup_rect.height, + width: area.width, + height: area.height.saturating_sub(popup_height), + }; + + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } + ActivePopup::None => { + self.textarea.render(area, buf); + } } } } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 0dcb98865cd..fd865047ef7 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -25,7 +23,7 @@ use ratatui::style::Modifier; pub(crate) struct CommandPopup { command_filter: String, - all_commands: HashMap<&'static str, SlashCommand>, + all_commands: Vec<(&'static str, SlashCommand)>, selected_idx: Option, } @@ -84,23 +82,20 @@ impl CommandPopup { /// Return the list of commands that match the current filter. Matching is /// performed using a *prefix* comparison on the command name. fn filtered_commands(&self) -> Vec<&SlashCommand> { - let mut cmds: Vec<&SlashCommand> = self - .all_commands - .values() - .filter(|cmd| { - if self.command_filter.is_empty() { - true - } else { - cmd.command() + self.all_commands + .iter() + .filter_map(|(_name, cmd)| { + if self.command_filter.is_empty() + || cmd + .command() .starts_with(&self.command_filter.to_ascii_lowercase()) + { + Some(cmd) + } else { + None } }) - .collect(); - - // Sort the commands alphabetically so the order is stable and - // predictable. - cmds.sort_by(|a, b| a.command().cmp(b.command())); - cmds + .collect::>() } /// Move the selection cursor one step up. diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs new file mode 100644 index 00000000000..34eb59e4b2e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,188 @@ +use codex_file_search::FileMatch; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Constraint; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Cell; +use ratatui::widgets::Row; +use ratatui::widgets::Table; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; + +/// Maximum number of suggestions shown in the popup. +const MAX_RESULTS: usize = 8; + +/// Visual state for the file-search popup. +pub(crate) struct FileSearchPopup { + /// Query corresponding to the `matches` currently shown. + display_query: String, + /// Latest query typed by the user. May differ from `display_query` when + /// a search is still in-flight. + pending_query: String, + /// When `true` we are still waiting for results for `pending_query`. + waiting: bool, + /// Cached matches; paths relative to the search dir. + matches: Vec, + /// Currently selected index inside `matches` (if any). + selected_idx: Option, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + display_query: String::new(), + pending_query: String::new(), + waiting: true, + matches: Vec::new(), + selected_idx: None, + } + } + + /// Update the query and reset state to *waiting*. + pub(crate) fn set_query(&mut self, query: &str) { + if query == self.pending_query { + return; + } + + // Determine if current matches are still relevant. + let keep_existing = query.starts_with(&self.display_query); + + self.pending_query.clear(); + self.pending_query.push_str(query); + + self.waiting = true; // waiting for new results + + if !keep_existing { + self.matches.clear(); + self.selected_idx = None; + } + } + + /// Replace matches when a `FileSearchResult` arrives. + /// Replace matches. Only applied when `query` matches `pending_query`. + pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { + if query != self.pending_query { + return; // stale + } + + self.display_query = query.to_string(); + self.matches = matches; + self.waiting = false; + self.selected_idx = if self.matches.is_empty() { + None + } else { + Some(0) + }; + } + + /// Move selection cursor up. + pub(crate) fn move_up(&mut self) { + if let Some(idx) = self.selected_idx { + if idx > 0 { + self.selected_idx = Some(idx - 1); + } + } + } + + /// Move selection cursor down. + pub(crate) fn move_down(&mut self) { + if let Some(idx) = self.selected_idx { + if idx + 1 < self.matches.len() { + self.selected_idx = Some(idx + 1); + } + } else if !self.matches.is_empty() { + self.selected_idx = Some(0); + } + } + + pub(crate) fn selected_match(&self) -> Option<&str> { + self.selected_idx + .and_then(|idx| self.matches.get(idx)) + .map(|file_match| file_match.path.as_str()) + } + + /// Preferred height (rows) including border. + pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + // Row count depends on whether we already have matches. If no matches + // yet (e.g. initial search or query with no results) reserve a single + // row so the popup is still visible. When matches are present we show + // up to MAX_RESULTS regardless of the waiting flag so the list + // remains stable while a newer search is in-flight. + let rows = if self.matches.is_empty() { + 1 + } else { + self.matches.len().clamp(1, MAX_RESULTS) + } as u16; + rows + 2 // border + } +} + +impl WidgetRef for &FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Prepare rows. + let rows: Vec = if self.matches.is_empty() { + vec![Row::new(vec![Cell::from(" no matches ")])] + } else { + self.matches + .iter() + .take(MAX_RESULTS) + .enumerate() + .map(|(i, file_match)| { + let FileMatch { path, indices, .. } = file_match; + let path = path.as_str(); + #[allow(clippy::expect_used)] + let indices = indices.as_ref().expect("indices should be present"); + + // Build spans with bold on matching indices. + let mut idx_iter = indices.iter().peekable(); + let mut spans: Vec = Vec::with_capacity(path.len()); + + for (char_idx, ch) in path.chars().enumerate() { + let mut style = Style::default(); + if idx_iter + .peek() + .is_some_and(|next| **next == char_idx as u32) + { + idx_iter.next(); + style = style.add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(ch.to_string(), style)); + } + + // Create cell from the spans. + let mut cell = Cell::from(Line::from(spans)); + + // If selected, also paint yellow. + if Some(i) == self.selected_idx { + cell = cell.style(Style::default().fg(Color::Yellow)); + } + + Row::new(vec![cell]) + }) + .collect() + }; + + let mut title = format!(" @{} ", self.pending_query); + if self.waiting { + title.push_str(" (searching …)"); + } + + let table = Table::new(rows, vec![Constraint::Percentage(100)]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(title), + ) + .widths([Constraint::Percentage(100)]); + + table.render(area, buf); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c654581ccd4..96f5c702858 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,21 +1,23 @@ //! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; use bottom_pane_view::ConditionalUpdate; +use codex_core::protocol::TokenUsage; +use codex_file_search::FileMatch; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::WidgetRef; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::user_approval_widget::ApprovalRequest; - mod approval_modal_view; mod bottom_pane_view; mod chat_composer; mod chat_composer_history; mod command_popup; +mod file_search_popup; mod status_indicator_view; pub(crate) use chat_composer::ChatComposer; @@ -36,6 +38,7 @@ pub(crate) struct BottomPane<'a> { app_event_tx: AppEventSender, has_input_focus: bool, is_task_running: bool, + ctrl_c_quit_hint: bool, } pub(crate) struct BottomPaneParams { @@ -51,6 +54,7 @@ impl BottomPane<'_> { app_event_tx: params.app_event_tx, has_input_focus: params.has_input_focus, is_task_running: false, + ctrl_c_quit_hint: false, } } @@ -99,6 +103,26 @@ impl BottomPane<'_> { self.composer.set_input_focus(has_focus); } + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { + self.ctrl_c_quit_hint = true; + self.composer + .set_ctrl_c_quit_hint(true, self.has_input_focus); + self.request_redraw(); + } + + pub(crate) fn clear_ctrl_c_quit_hint(&mut self) { + if self.ctrl_c_quit_hint { + self.ctrl_c_quit_hint = false; + self.composer + .set_ctrl_c_quit_hint(false, self.has_input_focus); + self.request_redraw(); + } + } + + pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool { + self.ctrl_c_quit_hint + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; @@ -129,6 +153,22 @@ impl BottomPane<'_> { } } + pub(crate) fn is_task_running(&self) -> bool { + self.is_task_running + } + + /// Update the *context-window remaining* indicator in the composer. This + /// is forwarded directly to the underlying `ChatComposer`. + pub(crate) fn set_token_usage( + &mut self, + token_usage: TokenUsage, + model_context_window: Option, + ) { + self.composer + .set_token_usage(token_usage, model_context_window); + self.request_redraw(); + } + /// Called when the agent requests user approval. pub fn push_approval_request(&mut self, request: ApprovalRequest) { let request = if let Some(view) = self.active_view.as_mut() { @@ -162,9 +202,9 @@ impl BottomPane<'_> { self.app_event_tx.send(AppEvent::Redraw) } - /// Returns true when the slash-command popup inside the composer is visible. - pub(crate) fn is_command_popup_visible(&self) -> bool { - self.active_view.is_none() && self.composer.is_command_popup_visible() + /// Returns true when a popup inside the composer is visible. + pub(crate) fn is_popup_visible(&self) -> bool { + self.active_view.is_none() && self.composer.is_popup_visible() } // --- History helpers --- @@ -187,6 +227,11 @@ impl BottomPane<'_> { self.request_redraw(); } } + + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + self.composer.on_file_search_result(query, matches); + self.request_redraw(); + } } impl WidgetRef for &BottomPane<'_> { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd5197c73bd..0b623132b56 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -18,6 +18,7 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TokenUsage; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; @@ -37,6 +38,7 @@ use crate::bottom_pane::InputResult; use crate::conversation_history_widget::ConversationHistoryWidget; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; +use codex_file_search::FileMatch; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, @@ -46,6 +48,7 @@ pub(crate) struct ChatWidget<'a> { input_focus: InputFocus, config: Config, initial_user_message: Option, + token_usage: TokenUsage, } #[derive(Clone, Copy, Eq, PartialEq)] @@ -131,15 +134,17 @@ impl ChatWidget<'_> { initial_prompt.unwrap_or_default(), initial_images, ), + token_usage: TokenUsage::default(), } } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + self.bottom_pane.clear_ctrl_c_quit_hint(); // Special-case : normally toggles focus between history and bottom panes. // However, when the slash-command popup is visible we forward the key // to the bottom pane so it can handle auto-completion. if matches!(key_event.code, crossterm::event::KeyCode::Tab) - && !self.bottom_pane.is_command_popup_visible() + && !self.bottom_pane.is_popup_visible() { self.input_focus = match self.input_focus { InputFocus::HistoryPane => InputFocus::BottomPane, @@ -241,6 +246,7 @@ impl ChatWidget<'_> { } } EventMsg::TaskStarted => { + self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.request_redraw(); } @@ -250,6 +256,11 @@ impl ChatWidget<'_> { self.bottom_pane.set_task_running(false); self.request_redraw(); } + EventMsg::TokenCount(token_usage) => { + self.token_usage = add_token_usage(&self.token_usage, &token_usage); + self.bottom_pane + .set_token_usage(self.token_usage.clone(), self.config.model_context_window); + } EventMsg::Error(ErrorEvent { message }) => { self.conversation_history.add_error(message); self.bottom_pane.set_task_running(false); @@ -376,6 +387,11 @@ impl ChatWidget<'_> { self.app_event_tx.send(AppEvent::Redraw); } + pub(crate) fn add_diff_output(&mut self, diff_output: String) { + self.conversation_history.add_diff_output(diff_output); + self.request_redraw(); + } + pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) { // If the user is trying to scroll exactly one line, we let them, but // otherwise we assume they are trying to scroll in larger increments. @@ -389,6 +405,27 @@ impl ChatWidget<'_> { self.request_redraw(); } + /// Forward file-search results to the bottom pane. + pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { + self.bottom_pane.on_file_search_result(query, matches); + } + + /// Handle Ctrl-C key press. + /// Returns true if the key press was handled, false if it was not. + /// If the key press was not handled, the caller should handle it (likely by exiting the process). + pub(crate) fn on_ctrl_c(&mut self) -> bool { + if self.bottom_pane.is_task_running() { + self.bottom_pane.clear_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + false + } else if self.bottom_pane.ctrl_c_quit_hint_visible() { + true + } else { + self.bottom_pane.show_ctrl_c_quit_hint(); + false + } + } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { if let Err(e) = self.codex_op_tx.send(op) { @@ -410,3 +447,31 @@ impl WidgetRef for &ChatWidget<'_> { (&self.bottom_pane).render(chunks[1], buf); } } + +fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenUsage { + let cached_input_tokens = match ( + current_usage.cached_input_tokens, + new_usage.cached_input_tokens, + ) { + (Some(current), Some(new)) => Some(current + new), + (Some(current), None) => Some(current), + (None, Some(new)) => Some(new), + (None, None) => None, + }; + let reasoning_output_tokens = match ( + current_usage.reasoning_output_tokens, + new_usage.reasoning_output_tokens, + ) { + (Some(current), Some(new)) => Some(current + new), + (Some(current), None) => Some(current), + (None, Some(new)) => Some(new), + (None, None) => None, + }; + TokenUsage { + input_tokens: current_usage.input_tokens + new_usage.input_tokens, + cached_input_tokens, + output_tokens: current_usage.output_tokens + new_usage.output_tokens, + reasoning_output_tokens, + total_tokens: current_usage.total_tokens + new_usage.total_tokens, + } +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 4abd684144a..cb6bb923186 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,7 +1,6 @@ use clap::Parser; use codex_common::ApprovalModeCliArg; use codex_common::CliConfigOverrides; -use codex_common::SandboxPermissionOption; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -26,12 +25,18 @@ pub struct Cli { #[arg(long = "ask-for-approval", short = 'a')] pub approval_policy: Option, - /// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, network-disabled sandbox that can write to cwd and TMPDIR) + /// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, -c sandbox.mode=workspace-write). #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, - #[clap(flatten)] - pub sandbox: SandboxPermissionOption, + /// Skip all confirmation prompts and execute commands without sandboxing. + /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. + #[arg( + long = "dangerously-bypass-approvals-and-sandbox", + default_value_t = false, + conflicts_with_all = ["approval_policy", "full_auto"] + )] + pub dangerously_bypass_approvals_and_sandbox: bool, /// Tell the agent to use the specified directory as its working root. #[clap(long = "cd", short = 'C', value_name = "DIR")] diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 714ac074a78..c0e5031d70f 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -206,6 +206,10 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_background_event(message)); } + pub fn add_diff_output(&mut self, diff_output: String) { + self.add_to_history(HistoryCell::new_diff_output(diff_output)); + } + pub fn add_error(&mut self, message: String) { self.add_to_history(HistoryCell::new_error_event(message)); } diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs new file mode 100644 index 00000000000..9fb010a095f --- /dev/null +++ b/codex-rs/tui/src/file_search.rs @@ -0,0 +1,201 @@ +//! Helper that owns the debounce/cancellation logic for `@` file searches. +//! +//! `ChatComposer` publishes *every* change of the `@token` as +//! `AppEvent::StartFileSearch(query)`. +//! This struct receives those events and decides when to actually spawn the +//! expensive search (handled in the main `App` thread). It tries to ensure: +//! +//! - Even when the user types long text quickly, they will start seeing results +//! after a short delay using an early version of what they typed. +//! - At most one search is in-flight at any time. +//! +//! It works as follows: +//! +//! 1. First query starts a debounce timer. +//! 2. While the timer is pending, the latest query from the user is stored. +//! 3. When the timer fires, it is cleared, and a search is done for the most +//! recent query. +//! 4. If there is a in-flight search that is not a prefix of the latest thing +//! the user typed, it is cancelled. + +use codex_file_search as file_search; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +#[allow(clippy::unwrap_used)] +const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(8).unwrap(); + +#[allow(clippy::unwrap_used)] +const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); + +/// How long to wait after a keystroke before firing the first search when none +/// is currently running. Keeps early queries more meaningful. +const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); + +const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// State machine for file-search orchestration. +pub(crate) struct FileSearchManager { + /// Unified state guarded by one mutex. + state: Arc>, + + search_dir: PathBuf, + app_tx: AppEventSender, +} + +struct SearchState { + /// Latest query typed by user (updated every keystroke). + latest_query: String, + + /// true if a search is currently scheduled. + is_search_scheduled: bool, + + /// If there is an active search, this will be the query being searched. + active_search: Option, +} + +struct ActiveSearch { + query: String, + cancellation_token: Arc, +} + +impl FileSearchManager { + pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { + Self { + state: Arc::new(Mutex::new(SearchState { + latest_query: String::new(), + is_search_scheduled: false, + active_search: None, + })), + search_dir, + app_tx: tx, + } + } + + /// Call whenever the user edits the `@` token. + pub fn on_user_query(&self, query: String) { + { + #[allow(clippy::unwrap_used)] + let mut st = self.state.lock().unwrap(); + if query == st.latest_query { + // No change, nothing to do. + return; + } + + // Update latest query. + st.latest_query.clear(); + st.latest_query.push_str(&query); + + // If there is an in-flight search that is definitely obsolete, + // cancel it now. + if let Some(active_search) = &st.active_search { + if !query.starts_with(&active_search.query) { + active_search + .cancellation_token + .store(true, Ordering::Relaxed); + st.active_search = None; + } + } + + // Schedule a search to run after debounce. + if !st.is_search_scheduled { + st.is_search_scheduled = true; + } else { + return; + } + } + + // If we are here, we set `st.is_search_scheduled = true` before + // dropping the lock. This means we are the only thread that can spawn a + // debounce timer. + let state = self.state.clone(); + let search_dir = self.search_dir.clone(); + let tx_clone = self.app_tx.clone(); + thread::spawn(move || { + // Always do a minimum debounce, but then poll until the + // `active_search` is cleared. + thread::sleep(FILE_SEARCH_DEBOUNCE); + loop { + #[allow(clippy::unwrap_used)] + if state.lock().unwrap().active_search.is_none() { + break; + } + thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL); + } + + // The debounce timer has expired, so start a search using the + // latest query. + let cancellation_token = Arc::new(AtomicBool::new(false)); + let token = cancellation_token.clone(); + let query = { + #[allow(clippy::unwrap_used)] + let mut st = state.lock().unwrap(); + let query = st.latest_query.clone(); + st.is_search_scheduled = false; + st.active_search = Some(ActiveSearch { + query: query.clone(), + cancellation_token: token, + }); + query + }; + + FileSearchManager::spawn_file_search( + query, + search_dir, + tx_clone, + cancellation_token, + state, + ); + }); + } + + fn spawn_file_search( + query: String, + search_dir: PathBuf, + tx: AppEventSender, + cancellation_token: Arc, + search_state: Arc>, + ) { + let compute_indices = true; + std::thread::spawn(move || { + let matches = file_search::run( + &query, + MAX_FILE_SEARCH_RESULTS, + &search_dir, + Vec::new(), + NUM_FILE_SEARCH_THREADS, + cancellation_token.clone(), + compute_indices, + ) + .map(|res| res.matches) + .unwrap_or_default(); + + let is_cancelled = cancellation_token.load(Ordering::Relaxed); + if !is_cancelled { + tx.send(AppEvent::FileSearchResult { query, matches }); + } + + // Reset the active search state. Do a pointer comparison to verify + // that we are clearing the ActiveSearch that corresponds to the + // cancellation token we were given. + { + #[allow(clippy::unwrap_used)] + let mut st = search_state.lock().unwrap(); + if let Some(active_search) = &st.active_search { + if Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) { + st.active_search = None; + } + } + } + }); + } +} diff --git a/codex-rs/tui/src/get_git_diff.rs b/codex-rs/tui/src/get_git_diff.rs new file mode 100644 index 00000000000..ff89fdcf1e3 --- /dev/null +++ b/codex-rs/tui/src/get_git_diff.rs @@ -0,0 +1,114 @@ +//! Utility to compute the current Git diff for the working directory. +//! +//! The implementation mirrors the behaviour of the TypeScript version in +//! `codex-cli`: it returns the diff for tracked changes as well as any +//! untracked files. When the current directory is not inside a Git +//! repository, the function returns `Ok((false, String::new()))`. + +use std::io; +use std::path::Path; +use std::process::Command; +use std::process::Stdio; + +/// Return value of [`get_git_diff`]. +/// +/// * `bool` – Whether the current working directory is inside a Git repo. +/// * `String` – The concatenated diff (may be empty). +pub(crate) fn get_git_diff() -> io::Result<(bool, String)> { + // First check if we are inside a Git repository. + if !inside_git_repo()? { + return Ok((false, String::new())); + } + + // 1. Diff for tracked files. + let tracked_diff = run_git_capture_diff(&["diff", "--color"])?; + + // 2. Determine untracked files. + let untracked_output = run_git_capture_stdout(&["ls-files", "--others", "--exclude-standard"])?; + + let mut untracked_diff = String::new(); + let null_device: &Path = if cfg!(windows) { + Path::new("NUL") + } else { + Path::new("/dev/null") + }; + + for file in untracked_output + .split('\n') + .map(str::trim) + .filter(|s| !s.is_empty()) + { + // Use `git diff --no-index` to generate a diff against the null device. + let args = [ + "diff", + "--color", + "--no-index", + "--", + null_device.to_str().unwrap_or("/dev/null"), + file, + ]; + + match run_git_capture_diff(&args) { + Ok(diff) => untracked_diff.push_str(&diff), + // If the file disappeared between ls-files and diff we ignore the error. + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + } + + Ok((true, format!("{}{}", tracked_diff, untracked_diff))) +} + +/// Helper that executes `git` with the given `args` and returns `stdout` as a +/// UTF-8 string. Any non-zero exit status is considered an *error*. +fn run_git_capture_stdout(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output()?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Like [`run_git_capture_stdout`] but treats exit status 1 as success and +/// returns stdout. Git returns 1 for diffs when differences are present. +fn run_git_capture_diff(args: &[&str]) -> io::Result { + let output = Command::new("git") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output()?; + + if output.status.success() || output.status.code() == Some(1) { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(io::Error::other(format!( + "git {:?} failed with status {}", + args, output.status + ))) + } +} + +/// Determine if the current directory is inside a Git repository. +fn inside_git_repo() -> io::Result { + let status = Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + match status { + Ok(s) if s.success() => Ok(true), + Ok(_) => Ok(false), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false), // git not installed + Err(e) => Err(e), + } +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 481576b5b3b..d424ee310b5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -6,6 +6,7 @@ use crate::text_formatting::format_and_truncate_tool_result; use base64::Engine; use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; +use codex_common::summarize_sandbox_policy; use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; @@ -103,6 +104,9 @@ pub(crate) enum HistoryCell { /// Background event. BackgroundEvent { view: TextBlock }, + /// Output from the `/diff` command. + GitDiffOutput { view: TextBlock }, + /// Error event from the backend. ErrorEvent { view: TextBlock }, @@ -152,7 +156,7 @@ impl HistoryCell { ("model", config.model.clone()), ("provider", config.model_provider_id.clone()), ("approval", format!("{:?}", config.approval_policy)), - ("sandbox", format!("{:?}", config.sandbox_policy)), + ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), ]; if config.model_provider.wire_api == WireApi::Responses && model_supports_reasoning_summaries(&config.model) @@ -452,13 +456,29 @@ impl HistoryCell { pub(crate) fn new_background_event(message: String) -> Self { let mut lines: Vec> = Vec::new(); lines.push(Line::from("event".dim())); - lines.extend(message.lines().map(|l| Line::from(l.to_string()).dim())); + lines.extend(message.lines().map(|line| ansi_escape_line(line).dim())); lines.push(Line::from("")); HistoryCell::BackgroundEvent { view: TextBlock::new(lines), } } + pub(crate) fn new_diff_output(message: String) -> Self { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("/diff".magenta())); + + if message.trim().is_empty() { + lines.push(Line::from("No changes detected.".italic())); + } else { + lines.extend(message.lines().map(ansi_escape_line)); + } + + lines.push(Line::from("")); + HistoryCell::GitDiffOutput { + view: TextBlock::new(lines), + } + } + pub(crate) fn new_error_event(message: String) -> Self { let lines: Vec> = vec![ vec!["ERROR: ".red().bold(), message.into()].into(), @@ -548,6 +568,7 @@ impl CellWidget for HistoryCell { | HistoryCell::AgentMessage { view } | HistoryCell::AgentReasoning { view } | HistoryCell::BackgroundEvent { view } + | HistoryCell::GitDiffOutput { view } | HistoryCell::ErrorEvent { view } | HistoryCell::SessionInfo { view } | HistoryCell::CompletedExecCommand { view } @@ -569,6 +590,7 @@ impl CellWidget for HistoryCell { | HistoryCell::AgentMessage { view } | HistoryCell::AgentReasoning { view } | HistoryCell::BackgroundEvent { view } + | HistoryCell::GitDiffOutput { view } | HistoryCell::ErrorEvent { view } | HistoryCell::SessionInfo { view } | HistoryCell::CompletedExecCommand { view } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5f3e2d69b52..317cd57fcbb 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -29,6 +29,8 @@ mod citation_regex; mod cli; mod conversation_history_widget; mod exec_command; +mod file_search; +mod get_git_diff; mod git_warning_screen; mod history_cell; mod log_layer; @@ -48,11 +50,16 @@ pub use cli::Cli; pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io::Result<()> { let (sandbox_policy, approval_policy) = if cli.full_auto { ( - Some(SandboxPolicy::new_full_auto_policy()), + Some(SandboxPolicy::new_workspace_write_policy()), Some(AskForApproval::OnFailure), ) + } else if cli.dangerously_bypass_approvals_and_sandbox { + ( + Some(SandboxPolicy::DangerFullAccess), + Some(AskForApproval::Never), + ) } else { - let sandbox_policy = cli.sandbox.permissions.clone().map(Into::into); + let sandbox_policy = None; (sandbox_policy, cli.approval_policy.map(Into::into)) }; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bfc02ceb132..bb72ce561c0 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -1,7 +1,5 @@ -use std::collections::HashMap; - use strum::IntoEnumIterator; -use strum_macros::AsRefStr; // derive macro +use strum_macros::AsRefStr; use strum_macros::EnumIter; use strum_macros::EnumString; use strum_macros::IntoStaticStr; @@ -12,9 +10,12 @@ use strum_macros::IntoStaticStr; )] #[strum(serialize_all = "kebab-case")] pub enum SlashCommand { + // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so + // more frequently used commands should be listed first. New, - ToggleMouseMode, + Diff, Quit, + ToggleMouseMode, } impl SlashCommand { @@ -26,6 +27,9 @@ impl SlashCommand { "Toggle mouse mode (enable for scrolling, disable for text selection)" } SlashCommand::Quit => "Exit the application.", + SlashCommand::Diff => { + "Show git diff of the working directory (including untracked files)" + } } } @@ -36,7 +40,7 @@ impl SlashCommand { } } -/// Return all built-in commands in a HashMap keyed by their command string. -pub fn built_in_slash_commands() -> HashMap<&'static str, SlashCommand> { +/// Return all built-in commands in a Vec paired with their command string. +pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { SlashCommand::iter().map(|c| (c.command(), c)).collect() }