mirror of
https://github.com/openai/codex.git
synced 2026-02-07 17:33:41 +00:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc6497aa68 | ||
|
|
92957c47fb | ||
|
|
8c1902b562 | ||
|
|
a32d305ae6 | ||
|
|
a768a6a41d | ||
|
|
25a9949c49 | ||
|
|
392fdd7db6 | ||
|
|
ae1a83f095 | ||
|
|
d60f350cf8 | ||
|
|
eba0e32909 | ||
|
|
29d154cb13 | ||
|
|
6b5b184f21 | ||
|
|
4bf81373a7 | ||
|
|
89ef4efdcf | ||
|
|
d1de7bb383 | ||
|
|
63deb7c369 | ||
|
|
cb379d7797 | ||
|
|
ef7208359f | ||
|
|
5746561428 | ||
|
|
d766e845b3 | ||
|
|
a4bfdf6779 | ||
|
|
44022db8d0 | ||
|
|
a86270f581 | ||
|
|
835eb77a7d | ||
|
|
dbc0ad348e | ||
|
|
9b4c2984d4 | ||
|
|
f3bde21759 | ||
|
|
1c6a3f1097 | ||
|
|
f8b6b1db81 | ||
|
|
031df77dfb | ||
|
|
f9143d0361 | ||
|
|
2880925a44 | ||
|
|
3e19e8fd59 | ||
|
|
c7312c9d52 | ||
|
|
1dc14cefa1 | ||
|
|
7ca84087e6 | ||
|
|
67ac8ef605 | ||
|
|
f48dd99f22 | ||
|
|
dfd54e1433 | ||
|
|
9739820366 | ||
|
|
fd0b1b0208 | ||
|
|
c6e08ad8c1 | ||
|
|
cabf83f2ed | ||
|
|
1e39189393 | ||
|
|
3d9f4fcd8a | ||
|
|
84e01f4b62 | ||
|
|
7edfbae062 | ||
|
|
316289d01d | ||
|
|
30cbfdfa87 | ||
|
|
070499f534 | ||
|
|
ce2ecbe72f | ||
|
|
3fdf9df133 | ||
|
|
ec5e82b77c | ||
|
|
5fc9fc3e3e | ||
|
|
0b9ef93da5 | ||
|
|
34aa1991f1 | ||
|
|
497c5396c0 | ||
|
|
a12e4b0b31 | ||
|
|
0402aef126 | ||
|
|
399e819c9b | ||
|
|
327cf41f0f | ||
|
|
9e7cd2b25a | ||
|
|
73259351ff | ||
|
|
77347d268d | ||
|
|
678f0dbfec | ||
|
|
1bf00a3a95 | ||
|
|
5bf9445351 | ||
|
|
a5f3a34827 | ||
|
|
e6c206d19d | ||
|
|
3c03c25e56 | ||
|
|
ae809f3721 | ||
|
|
a786c1d188 | ||
|
|
0ac7e8d55b | ||
|
|
1ff3e14d5a | ||
|
|
dd354e2134 | ||
|
|
557f608f25 | ||
|
|
05bb5d7d46 | ||
|
|
61b881d4e5 | ||
|
|
55142e3e6c | ||
|
|
115fb0b95d | ||
|
|
ab4cb94227 | ||
|
|
73fe1381aa | ||
|
|
f3bd143867 | ||
|
|
a1f51bf91b | ||
|
|
b4785b5f88 | ||
|
|
2b122da087 | ||
|
|
b42ad670f1 | ||
|
|
646e7e9c11 | ||
|
|
19262f632f | ||
|
|
fcc76cf3e7 | ||
|
|
3104d81b7b | ||
|
|
e307d007aa | ||
|
|
fde48aaa0d | ||
|
|
7795272282 | ||
|
|
78843c3940 | ||
|
|
93817643ee | ||
|
|
27198bfe11 | ||
|
|
b940adae8e | ||
|
|
e924070cee | ||
|
|
a538e6acb2 | ||
|
|
a9adb4175c | ||
|
|
699ec5a87f | ||
|
|
87cf120873 | ||
|
|
9fdf2fa066 | ||
|
|
86022f097e | ||
|
|
cfe50c7107 | ||
|
|
c3e10e180a | ||
|
|
42617f8726 | ||
|
|
9da6ebef3f | ||
|
|
0360b4d0d7 | ||
|
|
a080d7b0fd | ||
|
|
8a89d3aeda | ||
|
|
c577e94b67 | ||
|
|
7d8b38b37b | ||
|
|
6f87f4c69f | ||
|
|
aa36a15f9f | ||
|
|
88e7ca5f2b | ||
|
|
147a940449 | ||
|
|
49d040215a | ||
|
|
5f1b8f707c | ||
|
|
2cf7aeeeb6 | ||
|
|
76a979007e | ||
|
|
7e97980cb4 | ||
|
|
2b72d05c5e | ||
|
|
5d924d44cf | ||
|
|
a134bdde49 | ||
|
|
cd12f0c24a | ||
|
|
421e159888 | ||
|
|
4b61fb8bab | ||
|
|
0442458309 | ||
|
|
a180ed44e8 | ||
|
|
21cd953dbd | ||
|
|
865e518771 | ||
|
|
83961e0299 | ||
|
|
f6b1ce2e3a | ||
|
|
b864cc3810 | ||
|
|
a4b51f6b67 | ||
|
|
3f5975ad5a | ||
|
|
463a230991 | ||
|
|
985fd44ec0 | ||
|
|
bc4e6db749 | ||
|
|
bd82101859 | ||
|
|
033d379eca | ||
|
|
e6fe8d6fa1 | ||
|
|
b571249867 | ||
|
|
24278347b7 | ||
|
|
8f7a54501c | ||
|
|
2f1d96e77d | ||
|
|
84aaefa102 | ||
|
|
c432d9ef81 | ||
|
|
4746ee900f |
1
.codespellignore
Normal file
1
.codespellignore
Normal file
@@ -0,0 +1 @@
|
||||
iTerm
|
||||
6
.codespellrc
Normal file
6
.codespellrc
Normal file
@@ -0,0 +1,6 @@
|
||||
[codespell]
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts
|
||||
check-hidden = true
|
||||
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
|
||||
ignore-words-list = ratatui,ser
|
||||
9
.github/dotslash-config.json
vendored
9
.github/dotslash-config.json
vendored
@@ -1,14 +1,5 @@
|
||||
{
|
||||
"outputs": {
|
||||
"codex-repl": {
|
||||
"platforms": {
|
||||
"macos-aarch64": { "regex": "^codex-repl-aarch64-apple-darwin\\.zst$", "path": "codex-repl" },
|
||||
"macos-x86_64": { "regex": "^codex-repl-x86_64-apple-darwin\\.zst$", "path": "codex-repl" },
|
||||
"linux-x86_64": { "regex": "^codex-repl-x86_64-unknown-linux-musl\\.zst$", "path": "codex-repl" },
|
||||
"linux-aarch64": { "regex": "^codex-repl-aarch64-unknown-linux-gnu\\.zst$", "path": "codex-repl" }
|
||||
}
|
||||
},
|
||||
|
||||
"codex-exec": {
|
||||
"platforms": {
|
||||
"macos-aarch64": { "regex": "^codex-exec-aarch64-apple-darwin\\.zst$", "path": "codex-exec" },
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -68,6 +68,12 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Ensure staging a release works.
|
||||
working-directory: codex-cli
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: pnpm stage-release
|
||||
|
||||
- name: Ensure README.md contains only ASCII and certain Unicode code points
|
||||
run: ./scripts/asciicheck.py README.md
|
||||
- name: Check README ToC
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
path-to-document: docs/CLA.md
|
||||
path-to-document: https://github.com/openai/codex/blob/main/docs/CLA.md
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: cla-signatures
|
||||
allowlist: dependabot[bot]
|
||||
|
||||
27
.github/workflows/codespell.yml
vendored
Normal file
27
.github/workflows/codespell.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Codespell configuration is within .codespellrc
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2
|
||||
with:
|
||||
ignore_words_file: .codespellignore
|
||||
42
.github/workflows/rust-ci.yml
vendored
42
.github/workflows/rust-ci.yml
vendored
@@ -26,7 +26,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.87
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: cargo fmt
|
||||
run: cargo fmt -- --config imports_granularity=Item --check
|
||||
|
||||
@@ -58,9 +60,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.87
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
components: clippy
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
@@ -77,18 +80,35 @@ jobs:
|
||||
run: |
|
||||
sudo apt install -y musl-tools pkg-config
|
||||
|
||||
- name: Initialize failure flag
|
||||
run: echo "FAILED=" >> $GITHUB_ENV
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --target ${{ matrix.target }} --all-features -- -D warnings || echo "FAILED=${FAILED:+$FAILED, }cargo clippy" >> $GITHUB_ENV
|
||||
id: clippy
|
||||
continue-on-error: true
|
||||
run: cargo clippy --target ${{ matrix.target }} --all-features --tests -- -D warnings
|
||||
|
||||
# Running `cargo build` from the workspace root builds the workspace using
|
||||
# the union of all features from third-party crates. This can mask errors
|
||||
# where individual crates have underspecified features. To avoid this, we
|
||||
# run `cargo build` for each crate individually, though because this is
|
||||
# slower, we only do this for the x86_64-unknown-linux-gnu target.
|
||||
- name: cargo build individual crates
|
||||
id: build
|
||||
if: ${{ matrix.target == 'x86_64-unknown-linux-gnu' }}
|
||||
continue-on-error: true
|
||||
run: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo build'
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --target ${{ matrix.target }} || echo "FAILED=${FAILED:+$FAILED, }cargo test" >> $GITHUB_ENV
|
||||
id: test
|
||||
continue-on-error: true
|
||||
run: cargo test --all-features --target ${{ matrix.target }}
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
- name: Fail if any step failed
|
||||
if: env.FAILED != ''
|
||||
# Fail the job if any of the previous steps failed.
|
||||
- name: verify all steps passed
|
||||
if: |
|
||||
steps.clippy.outcome == 'failure' ||
|
||||
steps.build.outcome == 'failure' ||
|
||||
steps.test.outcome == 'failure'
|
||||
run: |
|
||||
echo "See logs above, as the following steps failed:"
|
||||
echo "$FAILED"
|
||||
echo "One or more checks failed (clippy, build, or test). See logs for details."
|
||||
exit 1
|
||||
|
||||
48
.github/workflows/rust-release.yml
vendored
48
.github/workflows/rust-release.yml
vendored
@@ -9,14 +9,14 @@ name: rust-release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "rust-v.*.*.*"
|
||||
- "rust-v*.*.*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG_REGEX: '^rust-v\.[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
TAG_REGEX: '^rust-v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
jobs:
|
||||
tag-check:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|| { echo "❌ Tag '${GITHUB_REF_NAME}' != ${TAG_REGEX}"; exit 1; }
|
||||
|
||||
# 2. Extract versions
|
||||
tag_ver="${GITHUB_REF_NAME#rust-v.}"
|
||||
tag_ver="${GITHUB_REF_NAME#rust-v}"
|
||||
cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \
|
||||
| sed -E 's/version *= *"([^"]+)".*/\1/')"
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.87
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
@@ -102,7 +102,6 @@ jobs:
|
||||
dest="dist/${{ matrix.target }}"
|
||||
mkdir -p "$dest"
|
||||
|
||||
cp target/${{ matrix.target }}/release/codex-repl "$dest/codex-repl-${{ matrix.target }}"
|
||||
cp target/${{ matrix.target }}/release/codex-exec "$dest/codex-exec-${{ matrix.target }}"
|
||||
cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}"
|
||||
|
||||
@@ -116,13 +115,42 @@ jobs:
|
||||
- name: Compress artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
# Path that contains the uncompressed binaries for the current
|
||||
# ${{ matrix.target }}
|
||||
dest="dist/${{ matrix.target }}"
|
||||
zstd -T0 -19 --rm "$dest"/*
|
||||
|
||||
# For compatibility with environments that lack the `zstd` tool we
|
||||
# additionally create a `.tar.gz` alongside every single binary that
|
||||
# we publish. The end result is:
|
||||
# codex-<target>.zst (existing)
|
||||
# codex-<target>.tar.gz (new)
|
||||
# ...same naming for codex-exec-* and codex-linux-sandbox-*
|
||||
|
||||
# 1. Produce a .tar.gz for every file in the directory *before* we
|
||||
# run `zstd --rm`, because that flag deletes the original files.
|
||||
for f in "$dest"/*; do
|
||||
base="$(basename "$f")"
|
||||
# Skip files that are already archives (shouldn't happen, but be
|
||||
# safe).
|
||||
if [[ "$base" == *.tar.gz ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create per-binary tar.gz
|
||||
tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base"
|
||||
|
||||
# Also create .zst (existing behaviour) *and* remove the original
|
||||
# uncompressed binary to keep the directory small.
|
||||
zstd -T0 -19 --rm "$dest/$base"
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: codex-rs/dist/${{ matrix.target }}/*
|
||||
# Upload the per-binary .zst files as well as the new .tar.gz
|
||||
# equivalents we generated in the previous step.
|
||||
path: |
|
||||
codex-rs/dist/${{ matrix.target }}/*
|
||||
|
||||
release:
|
||||
needs: build
|
||||
@@ -143,11 +171,9 @@ jobs:
|
||||
with:
|
||||
tag_name: ${{ env.RELEASE_TAG }}
|
||||
files: dist/**
|
||||
# TODO(ragona): I'm going to leave these as prerelease/draft for now.
|
||||
# It gives us 1) clarity that these are not yet a stable version, and
|
||||
# 2) allows a human step to review the release before publishing the draft.
|
||||
# For now, tag releases as "prerelease" because we are not claiming
|
||||
# the Rust CLI is stable yet.
|
||||
prerelease: true
|
||||
draft: true
|
||||
|
||||
- uses: facebook/dotslash-publish-release@v2
|
||||
env:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -77,3 +77,7 @@ yarn.lock
|
||||
package.json-e
|
||||
session.ts-e
|
||||
CHANGELOG.ignore.md
|
||||
|
||||
# nix related
|
||||
.direnv
|
||||
.envrc
|
||||
|
||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Rust/codex-rs
|
||||
|
||||
In the codex-rs folder where the rust code lives:
|
||||
|
||||
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR`. You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
|
||||
62
CHANGELOG.md
62
CHANGELOG.md
@@ -2,6 +2,68 @@
|
||||
|
||||
You can install any of these versions: `npm install -g codex@version`
|
||||
|
||||
## `0.1.2505172129`
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
|
||||
- Add node version check (#1007)
|
||||
- Persist token after refresh (#1006)
|
||||
|
||||
## `0.1.2505171619`
|
||||
|
||||
- `codex --login` + `codex --free` (#998)
|
||||
|
||||
## `0.1.2505161800`
|
||||
|
||||
- Sign in with chatgpt credits (#974)
|
||||
- Add support for OpenAI tool type, local_shell (#961)
|
||||
|
||||
## `0.1.2505161243`
|
||||
|
||||
- Sign in with chatgpt (#963)
|
||||
- Session history viewer (#912)
|
||||
- Apply patch issue when using different cwd (#942)
|
||||
- Diff command for filenames with special characters (#954)
|
||||
|
||||
## `0.1.2505160811`
|
||||
|
||||
- `codex-mini-latest` (#951)
|
||||
|
||||
## `0.1.2505140839`
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
|
||||
- Gpt-4.1 apply_patch handling (#930)
|
||||
- Add support for fileOpener in config.json (#911)
|
||||
- Patch in #366 and #367 for marked-terminal (#916)
|
||||
- Remember to set lastIndex = 0 on shared RegExp (#918)
|
||||
- Always load version from package.json at runtime (#909)
|
||||
- Tweak the label for citations for better rendering (#919)
|
||||
- Tighten up some logic around session timestamps and ids (#922)
|
||||
- Change EventMsg enum so every variant takes a single struct (#925)
|
||||
- Reasoning default to medium, show workdir when supplied (#931)
|
||||
- Test_dev_null_write() was not using echo as intended (#923)
|
||||
|
||||
## `0.1.2504301751`
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- User config api key (#569)
|
||||
- `@mention` files in codex (#701)
|
||||
- Add `--reasoning` CLI flag (#314)
|
||||
- Lower default retry wait time and increase number of tries (#720)
|
||||
- Add common package registries domains to allowed-domains list (#414)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
|
||||
- Insufficient quota message (#758)
|
||||
- Input keyboard shortcut opt+delete (#685)
|
||||
- `/diff` should include untracked files (#686)
|
||||
- Only allow running without sandbox if explicitly marked in safe container (#699)
|
||||
- Tighten up check for /usr/bin/sandbox-exec (#710)
|
||||
- Check if sandbox-exec is available (#696)
|
||||
- Duplicate messages in quiet mode (#680)
|
||||
|
||||
## `0.1.2504251709`
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
173
README.md
173
README.md
@@ -8,48 +8,48 @@
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Table of Contents</strong></summary>
|
||||
<summary><strong>Table of contents</strong></summary>
|
||||
|
||||
<!-- Begin ToC -->
|
||||
|
||||
- [Experimental Technology Disclaimer](#experimental-technology-disclaimer)
|
||||
- [Experimental technology disclaimer](#experimental-technology-disclaimer)
|
||||
- [Quickstart](#quickstart)
|
||||
- [Why Codex?](#why-codex)
|
||||
- [Security Model & Permissions](#security-model--permissions)
|
||||
- [Security model & permissions](#security-model--permissions)
|
||||
- [Platform sandboxing details](#platform-sandboxing-details)
|
||||
- [System Requirements](#system-requirements)
|
||||
- [CLI Reference](#cli-reference)
|
||||
- [Memory & Project Docs](#memory--project-docs)
|
||||
- [System requirements](#system-requirements)
|
||||
- [CLI reference](#cli-reference)
|
||||
- [Memory & project docs](#memory--project-docs)
|
||||
- [Non-interactive / CI mode](#non-interactive--ci-mode)
|
||||
- [Tracing / Verbose Logging](#tracing--verbose-logging)
|
||||
- [Tracing / verbose logging](#tracing--verbose-logging)
|
||||
- [Recipes](#recipes)
|
||||
- [Installation](#installation)
|
||||
- [Configuration Guide](#configuration-guide)
|
||||
- [Basic Configuration Parameters](#basic-configuration-parameters)
|
||||
- [Custom AI Provider Configuration](#custom-ai-provider-configuration)
|
||||
- [History Configuration](#history-configuration)
|
||||
- [Configuration Examples](#configuration-examples)
|
||||
- [Full Configuration Example](#full-configuration-example)
|
||||
- [Custom Instructions](#custom-instructions)
|
||||
- [Environment Variables Setup](#environment-variables-setup)
|
||||
- [Configuration guide](#configuration-guide)
|
||||
- [Basic configuration parameters](#basic-configuration-parameters)
|
||||
- [Custom AI provider configuration](#custom-ai-provider-configuration)
|
||||
- [History configuration](#history-configuration)
|
||||
- [Configuration examples](#configuration-examples)
|
||||
- [Full configuration example](#full-configuration-example)
|
||||
- [Custom instructions](#custom-instructions)
|
||||
- [Environment variables setup](#environment-variables-setup)
|
||||
- [FAQ](#faq)
|
||||
- [Zero Data Retention (ZDR) Usage](#zero-data-retention-zdr-usage)
|
||||
- [Codex Open Source Fund](#codex-open-source-fund)
|
||||
- [Zero data retention (ZDR) usage](#zero-data-retention-zdr-usage)
|
||||
- [Codex open source fund](#codex-open-source-fund)
|
||||
- [Contributing](#contributing)
|
||||
- [Development workflow](#development-workflow)
|
||||
- [Git Hooks with Husky](#git-hooks-with-husky)
|
||||
- [Git hooks with Husky](#git-hooks-with-husky)
|
||||
- [Debugging](#debugging)
|
||||
- [Writing high-impact code changes](#writing-high-impact-code-changes)
|
||||
- [Opening a pull request](#opening-a-pull-request)
|
||||
- [Review process](#review-process)
|
||||
- [Community values](#community-values)
|
||||
- [Getting help](#getting-help)
|
||||
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [Contributor license agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [Quick fixes](#quick-fixes)
|
||||
- [Releasing `codex`](#releasing-codex)
|
||||
- [Alternative Build Options](#alternative-build-options)
|
||||
- [Nix Flake Development](#nix-flake-development)
|
||||
- [Security & Responsible AI](#security--responsible-ai)
|
||||
- [Alternative build options](#alternative-build-options)
|
||||
- [Nix flake development](#nix-flake-development)
|
||||
- [Security & responsible AI](#security--responsible-ai)
|
||||
- [License](#license)
|
||||
|
||||
<!-- End ToC -->
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Experimental Technology Disclaimer
|
||||
## Experimental technology disclaimer
|
||||
|
||||
Codex CLI is an experimental project under active development. It is not yet stable, may contain bugs, incomplete features, or undergo breaking changes. We're building it in the open with the community and welcome:
|
||||
|
||||
@@ -98,12 +98,14 @@ export OPENAI_API_KEY="your-api-key-here"
|
||||
>
|
||||
> - 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:
|
||||
@@ -158,7 +160,7 @@ And it's **fully open-source** so you can see and contribute to how it develops!
|
||||
|
||||
---
|
||||
|
||||
## Security Model & Permissions
|
||||
## Security model & permissions
|
||||
|
||||
Codex lets you decide _how much autonomy_ the agent receives and auto-approval policy via the
|
||||
`--approval-mode` flag (or the interactive onboarding prompt):
|
||||
@@ -198,7 +200,7 @@ The hardening mechanism Codex uses depends on your OS:
|
||||
|
||||
---
|
||||
|
||||
## System Requirements
|
||||
## System requirements
|
||||
|
||||
| Requirement | Details |
|
||||
| --------------------------- | --------------------------------------------------------------- |
|
||||
@@ -211,7 +213,7 @@ The hardening mechanism Codex uses depends on your OS:
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
## CLI reference
|
||||
|
||||
| Command | Purpose | Example |
|
||||
| ------------------------------------ | ----------------------------------- | ------------------------------------ |
|
||||
@@ -224,15 +226,15 @@ Key flags: `--model/-m`, `--approval-mode/-a`, `--quiet/-q`, and `--notify`.
|
||||
|
||||
---
|
||||
|
||||
## Memory & Project Docs
|
||||
## Memory & project docs
|
||||
|
||||
Codex merges Markdown instructions in this order:
|
||||
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/instructions.md` - personal global guidance
|
||||
2. `codex.md` at repo root - shared project notes
|
||||
3. `codex.md` in cwd - sub-package specifics
|
||||
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 with `--no-project-doc` or `CODEX_DISABLE_PROJECT_DOC=1`.
|
||||
Disable loading of these files with `--no-project-doc` or the environment variable `CODEX_DISABLE_PROJECT_DOC=1`.
|
||||
|
||||
---
|
||||
|
||||
@@ -250,7 +252,7 @@ Run Codex head-less in pipelines. Example GitHub Action step:
|
||||
|
||||
Set `CODEX_QUIET_MODE=1` to silence interactive UI noise.
|
||||
|
||||
## Tracing / Verbose Logging
|
||||
## Tracing / verbose logging
|
||||
|
||||
Setting the environment variable `DEBUG=true` prints full API request and response details:
|
||||
|
||||
@@ -308,6 +310,9 @@ corepack enable
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Linux-only: download prebuilt sandboxing binaries (requires gh and zstd).
|
||||
./scripts/install_native_deps.sh
|
||||
|
||||
# Get the usage and the options
|
||||
node ./dist/cli.js --help
|
||||
|
||||
@@ -322,11 +327,11 @@ pnpm link
|
||||
|
||||
---
|
||||
|
||||
## Configuration Guide
|
||||
## Configuration guide
|
||||
|
||||
Codex configuration files can be placed in the `~/.codex/` directory, supporting both YAML and JSON formats.
|
||||
|
||||
### Basic Configuration Parameters
|
||||
### Basic configuration parameters
|
||||
|
||||
| Parameter | Type | Default | Description | Available Options |
|
||||
| ------------------- | ------- | ---------- | -------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
@@ -335,7 +340,7 @@ Codex configuration files can be placed in the `~/.codex/` directory, supporting
|
||||
| `fullAutoErrorMode` | string | `ask-user` | Error handling in full-auto mode | `ask-user` (prompt for user input)<br>`ignore-and-continue` (ignore and proceed) |
|
||||
| `notify` | boolean | `true` | Enable desktop notifications | `true`/`false` |
|
||||
|
||||
### Custom AI Provider Configuration
|
||||
### Custom AI provider configuration
|
||||
|
||||
In the `providers` object, you can configure multiple AI service providers. Each provider requires the following parameters:
|
||||
|
||||
@@ -345,7 +350,7 @@ In the `providers` object, you can configure multiple AI service providers. Each
|
||||
| `baseURL` | string | API service URL | `"https://api.openai.com/v1"` |
|
||||
| `envKey` | string | Environment variable name (for API key) | `"OPENAI_API_KEY"` |
|
||||
|
||||
### History Configuration
|
||||
### History configuration
|
||||
|
||||
In the `history` object, you can configure conversation history settings:
|
||||
|
||||
@@ -355,7 +360,7 @@ In the `history` object, you can configure conversation history settings:
|
||||
| `saveHistory` | boolean | Whether to save history | `true` |
|
||||
| `sensitivePatterns` | array | Patterns of sensitive information to filter in history | `[]` |
|
||||
|
||||
### Configuration Examples
|
||||
### Configuration examples
|
||||
|
||||
1. YAML format (save as `~/.codex/config.yaml`):
|
||||
|
||||
@@ -377,7 +382,7 @@ notify: true
|
||||
}
|
||||
```
|
||||
|
||||
### Full Configuration Example
|
||||
### Full configuration example
|
||||
|
||||
Below is a comprehensive example of `config.json` with multiple custom providers:
|
||||
|
||||
@@ -391,6 +396,11 @@ Below is a comprehensive example of `config.json` with multiple custom providers
|
||||
"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",
|
||||
@@ -425,6 +435,11 @@ Below is a comprehensive example of `config.json` with multiple custom providers
|
||||
"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": {
|
||||
@@ -435,16 +450,16 @@ Below is a comprehensive example of `config.json` with multiple custom providers
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Instructions
|
||||
### Custom instructions
|
||||
|
||||
You can create a `~/.codex/instructions.md` file to define custom instructions:
|
||||
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
|
||||
### Environment variables setup
|
||||
|
||||
For each AI provider, you need to set the corresponding API key in your environment variables. For example:
|
||||
|
||||
@@ -452,6 +467,10 @@ For each AI provider, you need to set the corresponding API key in your environm
|
||||
# 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-03-01-preview" (Optional)
|
||||
|
||||
# OpenRouter
|
||||
export OPENROUTER_API_KEY="your-openrouter-key-here"
|
||||
|
||||
@@ -497,7 +516,7 @@ Not directly. It requires [Windows Subsystem for Linux (WSL2)](https://learn.mic
|
||||
|
||||
---
|
||||
|
||||
## Zero Data Retention (ZDR) Usage
|
||||
## Zero data retention (ZDR) usage
|
||||
|
||||
Codex CLI **does** support OpenAI organizations with [Zero Data Retention (ZDR)](https://platform.openai.com/docs/guides/your-data#zero-data-retention) enabled. If your OpenAI organization has Zero Data Retention enabled and you still encounter errors such as:
|
||||
|
||||
@@ -509,7 +528,7 @@ You may need to upgrade to a more recent version with: `npm i -g @openai/codex@l
|
||||
|
||||
---
|
||||
|
||||
## Codex Open Source Fund
|
||||
## Codex open source fund
|
||||
|
||||
We're excited to launch a **$1 million initiative** supporting open source projects that use Codex CLI and other OpenAI models.
|
||||
|
||||
@@ -534,7 +553,7 @@ More broadly we welcome contributions - whether you are opening your very first
|
||||
- We use **Vitest** for unit tests, **ESLint** + **Prettier** for style, and **TypeScript** for type-checking.
|
||||
- Before pushing, run the full test/type/lint suite:
|
||||
|
||||
### Git Hooks with Husky
|
||||
### Git hooks with Husky
|
||||
|
||||
This project uses [Husky](https://typicode.github.io/husky/) to enforce code quality checks:
|
||||
|
||||
@@ -608,7 +627,7 @@ If you run into problems setting up the project, would like feedback on an idea,
|
||||
|
||||
Together we can make Codex CLI an incredible tool. **Happy hacking!** :rocket:
|
||||
|
||||
### Contributor License Agreement (CLA)
|
||||
### Contributor license agreement (CLA)
|
||||
|
||||
All contributors **must** accept the CLA. The process is lightweight:
|
||||
|
||||
@@ -633,29 +652,42 @@ The **DCO check** blocks merges until every commit in the PR carries the footer
|
||||
|
||||
### Releasing `codex`
|
||||
|
||||
To publish a new version of the CLI, run the release scripts defined in `codex-cli/package.json`:
|
||||
To publish a new version of the CLI 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:
|
||||
|
||||
1. Open the `codex-cli` directory
|
||||
2. Make sure you're on a branch like `git checkout -b bump-version`
|
||||
3. Bump the version and `CLI_VERSION` to current datetime: `pnpm release:version`
|
||||
4. Commit the version bump (with DCO sign-off):
|
||||
```bash
|
||||
git add codex-cli/src/utils/session.ts codex-cli/package.json
|
||||
git commit -s -m "chore(release): codex-cli v$(node -p \"require('./codex-cli/package.json').version\")"
|
||||
```
|
||||
5. Copy README, build, and publish to npm: `pnpm release`
|
||||
6. Push to branch: `git push origin HEAD`
|
||||
```bash
|
||||
# Classic, JS implementation that includes small, native binaries for Linux sandboxing.
|
||||
pnpm stage-release
|
||||
|
||||
### Alternative Build Options
|
||||
# Optionally specify the temp directory to reuse between runs.
|
||||
RELEASE_DIR=$(mktemp -d)
|
||||
pnpm stage-release --tmp "$RELEASE_DIR"
|
||||
|
||||
#### Nix Flake Development
|
||||
# "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
|
||||
nix develop
|
||||
# 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.
|
||||
@@ -663,19 +695,34 @@ This shell includes Node.js, installs dependencies, builds the CLI, and provides
|
||||
Build and run the CLI directly:
|
||||
|
||||
```bash
|
||||
nix build
|
||||
# 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
|
||||
nix run .#codex
|
||||
# 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
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
env: { browser: true, node: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
|
||||
3
codex-cli/.gitignore
vendored
Normal file
3
codex-cli/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Added by ./scripts/install_native_deps.sh
|
||||
/bin/codex-linux-sandbox-arm64
|
||||
/bin/codex-linux-sandbox-x64
|
||||
@@ -1,17 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
// Unified entry point for the Codex CLI.
|
||||
/*
|
||||
* Behavior
|
||||
* =========
|
||||
* 1. By default we import the JavaScript implementation located in
|
||||
* dist/cli.js.
|
||||
*
|
||||
* 2. Developers can opt-in to a pre-compiled Rust binary by setting the
|
||||
* environment variable CODEX_RUST to a truthy value (`1`, `true`, etc.).
|
||||
* When that variable is present we resolve the correct binary for the
|
||||
* current platform / architecture and execute it via child_process.
|
||||
*
|
||||
* If the CODEX_RUST=1 is specified and there is no native binary for the
|
||||
* current platform / architecture, an error is thrown.
|
||||
*/
|
||||
|
||||
// Unified entry point for Codex CLI on all platforms
|
||||
// Dynamically loads the compiled ESM bundle in dist/cli.js
|
||||
import { spawnSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath, pathToFileURL } from "url";
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
// Determine whether the user explicitly wants the Rust CLI.
|
||||
|
||||
// Determine this script's directory
|
||||
// __dirname equivalent in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// For the @native release of the Node module, the `use-native` file is added,
|
||||
// indicating we should default to the native binary. For other releases,
|
||||
// setting CODEX_RUST=1 will opt-in to the native binary, if included.
|
||||
const wantsNative = fs.existsSync(path.join(__dirname, "use-native")) ||
|
||||
(process.env.CODEX_RUST != null
|
||||
? ["1", "true", "yes"].includes(process.env.CODEX_RUST.toLowerCase())
|
||||
: false);
|
||||
|
||||
// Try native binary if requested.
|
||||
if (wantsNative) {
|
||||
const { platform, arch } = process;
|
||||
|
||||
let targetTriple = null;
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
targetTriple = "x86_64-unknown-linux-musl";
|
||||
break;
|
||||
case "arm64":
|
||||
targetTriple = "aarch64-unknown-linux-gnu";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "darwin":
|
||||
switch (arch) {
|
||||
case "x64":
|
||||
targetTriple = "x86_64-apple-darwin";
|
||||
break;
|
||||
case "arm64":
|
||||
targetTriple = "aarch64-apple-darwin";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (!targetTriple) {
|
||||
throw new Error(`Unsupported platform: ${platform} (${arch})`);
|
||||
}
|
||||
|
||||
const binaryPath = path.join(__dirname, "..", "bin", `codex-${targetTriple}`);
|
||||
const result = spawnSync(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const exitCode = typeof result.status === "number" ? result.status : 1;
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Fallback: execute the original JavaScript CLI.
|
||||
|
||||
// Resolve the path to the compiled CLI bundle
|
||||
const cliPath = path.resolve(__dirname, '../dist/cli.js');
|
||||
const cliPath = path.resolve(__dirname, "../dist/cli.js");
|
||||
const cliUrl = pathToFileURL(cliPath).href;
|
||||
|
||||
// Load and execute the CLI
|
||||
@@ -21,7 +94,6 @@ const cliUrl = pathToFileURL(cliPath).href;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
// eslint-disable-next-line no-undef
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -72,6 +72,9 @@ if (isDevBuild) {
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: ["src/cli.tsx"],
|
||||
// Do not bundle the contents of package.json at build time: always read it
|
||||
// at runtime.
|
||||
external: ["../package.json"],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
|
||||
43
codex-cli/default.nix
Normal file
43
codex-cli/default.nix
Normal file
@@ -0,0 +1,43 @@
|
||||
{ pkgs, monorep-deps ? [], ... }:
|
||||
let
|
||||
node = pkgs.nodejs_22;
|
||||
in
|
||||
rec {
|
||||
package = pkgs.buildNpmPackage {
|
||||
pname = "codex-cli";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
npmDepsHash = "sha256-3tAalmh50I0fhhd7XreM+jvl0n4zcRhqygFNB1Olst8";
|
||||
nodejs = node;
|
||||
npmInstallFlags = [ "--frozen-lockfile" ];
|
||||
meta = with pkgs.lib; {
|
||||
description = "OpenAI Codex command‑line interface";
|
||||
license = licenses.asl20;
|
||||
homepage = "https://github.com/openai/codex";
|
||||
};
|
||||
};
|
||||
devShell = pkgs.mkShell {
|
||||
name = "codex-cli-dev";
|
||||
buildInputs = monorep-deps ++ [
|
||||
node
|
||||
pkgs.pnpm
|
||||
];
|
||||
shellHook = ''
|
||||
echo "Entering development shell for codex-cli"
|
||||
# cd codex-cli
|
||||
if [ -f package-lock.json ]; then
|
||||
pnpm ci || echo "npm ci failed"
|
||||
else
|
||||
pnpm install || echo "npm install failed"
|
||||
fi
|
||||
npm run build || echo "npm build failed"
|
||||
export PATH=$PWD/node_modules/.bin:$PATH
|
||||
alias codex="node $PWD/dist/cli.js"
|
||||
'';
|
||||
};
|
||||
app = {
|
||||
type = "app";
|
||||
program = "${package}/bin/codex";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: "impossible-pong"
|
||||
description: |
|
||||
Update index.html with the following features:
|
||||
- Add an overlayed styled popup to start the game on first load
|
||||
- Add an overlaid styled popup to start the game on first load
|
||||
- Between each point, show a 3 second countdown (this should be skipped if a player wins)
|
||||
- After each game the AI wins, display text at the bottom of the screen with lighthearted insults for the player
|
||||
- Add a leaderboard to the right of the court that shows how many games each player has won.
|
||||
|
||||
@@ -13,7 +13,7 @@ act,prompt,for_devs
|
||||
"Advertiser","I want you to act as an advertiser. You will create a campaign to promote a product or service of your choice. You will choose a target audience, develop key messages and slogans, select the media channels for promotion, and decide on any additional activities needed to reach your goals. My first suggestion request is ""I need help creating an advertising campaign for a new type of energy drink targeting young adults aged 18-30.""",FALSE
|
||||
"Storyteller","I want you to act as a storyteller. You will come up with entertaining stories that are engaging, imaginative and captivating for the audience. It can be fairy tales, educational stories or any other type of stories which has the potential to capture people's attention and imagination. Depending on the target audience, you may choose specific themes or topics for your storytelling session e.g., if it's children then you can talk about animals; If it's adults then history-based tales might engage them better etc. My first request is ""I need an interesting story on perseverance.""",FALSE
|
||||
"Football Commentator","I want you to act as a football commentator. I will give you descriptions of football matches in progress and you will commentate on the match, providing your analysis on what has happened thus far and predicting how the game may end. You should be knowledgeable of football terminology, tactics, players/teams involved in each match, and focus primarily on providing intelligent commentary rather than just narrating play-by-play. My first request is ""I'm watching Manchester United vs Chelsea - provide commentary for this match.""",FALSE
|
||||
"Stand-up Comedian","I want you to act as a stand-up comedian. I will provide you with some topics related to current events and you will use your wit, creativity, and observational skills to create a routine based on those topics. You should also be sure to incorporate personal anecdotes or experiences into the routine in order to make it more relatable and engaging for the audience. My first request is ""I want an humorous take on politics.""",FALSE
|
||||
"Stand-up Comedian","I want you to act as a stand-up comedian. I will provide you with some topics related to current events and you will use your with, creativity, and observational skills to create a routine based on those topics. You should also be sure to incorporate personal anecdotes or experiences into the routine in order to make it more relatable and engaging for the audience. My first request is ""I want an humorous take on politics.""",FALSE
|
||||
"Motivational Coach","I want you to act as a motivational coach. I will provide you with some information about someone's goals and challenges, and it will be your job to come up with strategies that can help this person achieve their goals. This could involve providing positive affirmations, giving helpful advice or suggesting activities they can do to reach their end goal. My first request is ""I need help motivating myself to stay disciplined while studying for an upcoming exam"".",FALSE
|
||||
"Composer","I want you to act as a composer. I will provide the lyrics to a song and you will create music for it. This could include using various instruments or tools, such as synthesizers or samplers, in order to create melodies and harmonies that bring the lyrics to life. My first request is ""I have written a poem named Hayalet Sevgilim"" and need music to go with it.""""""",FALSE
|
||||
"Debater","I want you to act as a debater. I will provide you with some topics related to current events and your task is to research both sides of the debates, present valid arguments for each side, refute opposing points of view, and draw persuasive conclusions based on evidence. Your goal is to help people come away from the discussion with increased knowledge and insight into the topic at hand. My first request is ""I want an opinion piece about Deno.""",FALSE
|
||||
@@ -23,7 +23,7 @@ act,prompt,for_devs
|
||||
"Movie Critic","I want you to act as a movie critic. You will develop an engaging and creative movie review. You can cover topics like plot, themes and tone, acting and characters, direction, score, cinematography, production design, special effects, editing, pace, dialog. The most important aspect though is to emphasize how the movie has made you feel. What has really resonated with you. You can also be critical about the movie. Please avoid spoilers. My first request is ""I need to write a movie review for the movie Interstellar""",FALSE
|
||||
"Relationship Coach","I want you to act as a relationship coach. I will provide some details about the two people involved in a conflict, and it will be your job to come up with suggestions on how they can work through the issues that are separating them. This could include advice on communication techniques or different strategies for improving their understanding of one another's perspectives. My first request is ""I need help solving conflicts between my spouse and myself.""",FALSE
|
||||
"Poet","I want you to act as a poet. You will create poems that evoke emotions and have the power to stir people's soul. Write on any topic or theme but make sure your words convey the feeling you are trying to express in beautiful yet meaningful ways. You can also come up with short verses that are still powerful enough to leave an imprint in readers' minds. My first request is ""I need a poem about love.""",FALSE
|
||||
"Rapper","I want you to act as a rapper. You will come up with powerful and meaningful lyrics, beats and rhythm that can 'wow' the audience. Your lyrics should have an intriguing meaning and message which people can relate too. When it comes to choosing your beat, make sure it is catchy yet relevant to your words, so that when combined they make an explosion of sound everytime! My first request is ""I need a rap song about finding strength within yourself.""",FALSE
|
||||
"Rapper","I want you to act as a rapper. You will come up with powerful and meaningful lyrics, beats and rhythm that can 'wow' the audience. Your lyrics should have an intriguing meaning and message which people can relate too. When it comes to choosing your beat, make sure it is catchy yet relevant to your words, so that when combined they make an explosion of sound every time! My first request is ""I need a rap song about finding strength within yourself.""",FALSE
|
||||
"Motivational Speaker","I want you to act as a motivational speaker. Put together words that inspire action and make people feel empowered to do something beyond their abilities. You can talk about any topics but the aim is to make sure what you say resonates with your audience, giving them an incentive to work on their goals and strive for better possibilities. My first request is ""I need a speech about how everyone should never give up.""",FALSE
|
||||
"Philosophy Teacher","I want you to act as a philosophy teacher. I will provide some topics related to the study of philosophy, and it will be your job to explain these concepts in an easy-to-understand manner. This could include providing examples, posing questions or breaking down complex ideas into smaller pieces that are easier to comprehend. My first request is ""I need help understanding how different philosophical theories can be applied in everyday life.""",FALSE
|
||||
"Philosopher","I want you to act as a philosopher. I will provide some topics or questions related to the study of philosophy, and it will be your job to explore these concepts in depth. This could involve conducting research into various philosophical theories, proposing new ideas or finding creative solutions for solving complex problems. My first request is ""I need help developing an ethical framework for decision making.""",FALSE
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openai/codex",
|
||||
"version": "0.1.2504251709",
|
||||
"version": "0.0.0-dev",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
@@ -20,12 +20,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node build.mjs",
|
||||
"build:dev": "NODE_ENV=development node build.mjs --dev && NODE_OPTIONS=--enable-source-maps node dist/cli-dev.js",
|
||||
"release:readme": "cp ../README.md ./README.md",
|
||||
"release:version": "TS=$(date +%y%m%d%H%M) && sed -E -i'' -e \"s/\\\"0\\.1\\.[0-9]{10}\\\"/\\\"0.1.${TS}\\\"/g\" package.json src/utils/session.ts",
|
||||
"release:build-and-publish": "pnpm run build && npm publish",
|
||||
"release": "pnpm run release:readme && pnpm run release:version && pnpm install && pnpm run release:build-and-publish"
|
||||
"stage-release": "./scripts/stage_release.sh"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
@@ -33,10 +31,12 @@
|
||||
"chalk": "^5.2.0",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.1.4",
|
||||
"express": "^5.1.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-npm-meta": "^0.4.2",
|
||||
"figures": "^6.1.0",
|
||||
"file-type": "^20.1.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ink": "^5.2.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"marked": "^15.0.7",
|
||||
@@ -55,6 +55,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/marked-terminal": "^6.1.1",
|
||||
"@types/react": "^18.0.32",
|
||||
@@ -76,7 +77,8 @@
|
||||
"semver": "^7.7.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.3",
|
||||
"vitest": "^3.0.9",
|
||||
"vite": "^6.3.4",
|
||||
"vitest": "^3.1.2",
|
||||
"whatwg-url": "^14.2.0",
|
||||
"which": "^5.0.0"
|
||||
},
|
||||
|
||||
99
codex-cli/scripts/install_native_deps.sh
Executable file
99
codex-cli/scripts/install_native_deps.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Install native runtime dependencies for codex-cli.
|
||||
#
|
||||
# By default the script copies the sandbox binaries that are required at
|
||||
# runtime. When called with the --full-native flag, it additionally
|
||||
# bundles pre-built Rust CLI binaries so that the resulting npm package can run
|
||||
# the native implementation when users set CODEX_RUST=1.
|
||||
#
|
||||
# Usage
|
||||
# install_native_deps.sh [RELEASE_ROOT] [--full-native]
|
||||
#
|
||||
# The optional RELEASE_ROOT is the path that contains package.json. Omitting
|
||||
# it installs the binaries into the repository's own bin/ folder to support
|
||||
# local development.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ------------------
|
||||
# Parse arguments
|
||||
# ------------------
|
||||
|
||||
DEST_DIR=""
|
||||
INCLUDE_RUST=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--full-native)
|
||||
INCLUDE_RUST=1
|
||||
;;
|
||||
*)
|
||||
if [[ -z "$DEST_DIR" ]]; then
|
||||
DEST_DIR="$arg"
|
||||
else
|
||||
echo "Unexpected argument: $arg" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Determine where the binaries should be installed.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
# The caller supplied a release root directory.
|
||||
CODEX_CLI_ROOT="$1"
|
||||
BIN_DIR="$CODEX_CLI_ROOT/bin"
|
||||
else
|
||||
# No argument; fall back to the repo’s own bin directory.
|
||||
# Resolve the path of this script, then walk up to the repo root.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BIN_DIR="$CODEX_CLI_ROOT/bin"
|
||||
fi
|
||||
|
||||
# Make sure the destination directory exists.
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Download and decompress the artifacts from the GitHub Actions workflow.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
# Until we start publishing stable GitHub releases, we have to grab the binaries
|
||||
# from the GitHub Action that created them. Update the URL below to point to the
|
||||
# appropriate workflow run:
|
||||
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/15334411824"
|
||||
WORKFLOW_ID="${WORKFLOW_URL##*/}"
|
||||
|
||||
ARTIFACTS_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$ARTIFACTS_DIR"' EXIT
|
||||
|
||||
# NB: The GitHub CLI `gh` must be installed and authenticated.
|
||||
gh run download --dir "$ARTIFACTS_DIR" --repo openai/codex "$WORKFLOW_ID"
|
||||
|
||||
# Decompress the artifacts for Linux sandboxing.
|
||||
zstd -d "$ARTIFACTS_DIR/x86_64-unknown-linux-musl/codex-linux-sandbox-x86_64-unknown-linux-musl.zst" \
|
||||
-o "$BIN_DIR/codex-linux-sandbox-x64"
|
||||
|
||||
zstd -d "$ARTIFACTS_DIR/aarch64-unknown-linux-gnu/codex-linux-sandbox-aarch64-unknown-linux-gnu.zst" \
|
||||
-o "$BIN_DIR/codex-linux-sandbox-arm64"
|
||||
|
||||
if [[ "$INCLUDE_RUST" -eq 1 ]]; then
|
||||
# x64 Linux
|
||||
zstd -d "$ARTIFACTS_DIR/x86_64-unknown-linux-musl/codex-x86_64-unknown-linux-musl.zst" \
|
||||
-o "$BIN_DIR/codex-x86_64-unknown-linux-musl"
|
||||
# ARM64 Linux
|
||||
zstd -d "$ARTIFACTS_DIR/aarch64-unknown-linux-gnu/codex-aarch64-unknown-linux-gnu.zst" \
|
||||
-o "$BIN_DIR/codex-aarch64-unknown-linux-gnu"
|
||||
# x64 macOS
|
||||
zstd -d "$ARTIFACTS_DIR/x86_64-apple-darwin/codex-x86_64-apple-darwin.zst" \
|
||||
-o "$BIN_DIR/codex-x86_64-apple-darwin"
|
||||
# ARM64 macOS
|
||||
zstd -d "$ARTIFACTS_DIR/aarch64-apple-darwin/codex-aarch64-apple-darwin.zst" \
|
||||
-o "$BIN_DIR/codex-aarch64-apple-darwin"
|
||||
fi
|
||||
|
||||
echo "Installed native dependencies into $BIN_DIR"
|
||||
147
codex-cli/scripts/stage_release.sh
Executable file
147
codex-cli/scripts/stage_release.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# -----------------------------------------------------------------------------
|
||||
# stage_release.sh
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stages an npm release for @openai/codex.
|
||||
#
|
||||
# The script used to accept a single optional positional argument that indicated
|
||||
# the temporary directory in which to stage the package. We now support a
|
||||
# flag-based interface so that we can extend the command with further options
|
||||
# without breaking the call-site contract.
|
||||
#
|
||||
# --tmp <dir> : Use <dir> instead of a freshly created temp directory.
|
||||
# --native : Bundle the pre-built Rust CLI binaries for Linux alongside
|
||||
# the JavaScript implementation (a so-called "fat" package).
|
||||
# -h|--help : Print usage.
|
||||
#
|
||||
# When --native is supplied we copy the linux-sandbox binaries (as before) and
|
||||
# additionally fetch / unpack the two Rust targets that we currently support:
|
||||
# - x86_64-unknown-linux-musl
|
||||
# - aarch64-unknown-linux-gnu
|
||||
#
|
||||
# NOTE: This script is intended to be run from the repository root via
|
||||
# `pnpm --filter codex-cli stage-release ...` or inside codex-cli with the
|
||||
# helper script entry in package.json (`pnpm stage-release ...`).
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Helper - usage / flag parsing
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [--tmp DIR] [--native]
|
||||
|
||||
Options
|
||||
--tmp DIR Use DIR to stage the release (defaults to a fresh mktemp dir)
|
||||
--native Bundle Rust binaries for Linux (fat package)
|
||||
-h, --help Show this help
|
||||
|
||||
Legacy positional argument: the first non-flag argument is still interpreted
|
||||
as the temporary directory (for backwards compatibility) but is deprecated.
|
||||
EOF
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
TMPDIR=""
|
||||
INCLUDE_NATIVE=0
|
||||
|
||||
# Manual flag parser - Bash getopts does not handle GNU long options well.
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tmp)
|
||||
shift || { echo "--tmp requires an argument"; usage 1; }
|
||||
TMPDIR="$1"
|
||||
;;
|
||||
--tmp=*)
|
||||
TMPDIR="${1#*=}"
|
||||
;;
|
||||
--native)
|
||||
INCLUDE_NATIVE=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
--*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage 1
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected extra argument: $1" >&2
|
||||
usage 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Fallback when the caller did not specify a directory.
|
||||
# If no directory was specified create a fresh temporary one.
|
||||
if [[ -z "$TMPDIR" ]]; then
|
||||
TMPDIR="$(mktemp -d)"
|
||||
fi
|
||||
|
||||
# Ensure the directory exists, then resolve to an absolute path.
|
||||
mkdir -p "$TMPDIR"
|
||||
TMPDIR="$(cd "$TMPDIR" && pwd)"
|
||||
|
||||
# Main build logic
|
||||
|
||||
echo "Staging release in $TMPDIR"
|
||||
|
||||
# The script lives in codex-cli/scripts/ - change into codex-cli root so that
|
||||
# relative paths keep working.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CODEX_CLI_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
pushd "$CODEX_CLI_ROOT" >/dev/null
|
||||
|
||||
# 1. Build the JS artifacts ---------------------------------------------------
|
||||
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Paths inside the staged package
|
||||
mkdir -p "$TMPDIR/bin"
|
||||
|
||||
cp -r bin/codex.js "$TMPDIR/bin/codex.js"
|
||||
cp -r dist "$TMPDIR/dist"
|
||||
cp -r src "$TMPDIR/src" # keep source for TS sourcemaps
|
||||
cp ../README.md "$TMPDIR" || true # README is one level up - ignore if missing
|
||||
|
||||
# Derive a timestamp-based version (keep same scheme as before)
|
||||
VERSION="$(printf '0.1.%d' "$(date +%y%m%d%H%M)")"
|
||||
|
||||
# Modify package.json - bump version and optionally add the native directory to
|
||||
# the files array so that the binaries are published to npm.
|
||||
|
||||
jq --arg version "$VERSION" \
|
||||
'.version = $version' \
|
||||
package.json > "$TMPDIR/package.json"
|
||||
|
||||
# 2. Native runtime deps (sandbox plus optional Rust binaries)
|
||||
|
||||
if [[ "$INCLUDE_NATIVE" -eq 1 ]]; then
|
||||
./scripts/install_native_deps.sh "$TMPDIR" --full-native
|
||||
touch "${TMPDIR}/bin/use-native"
|
||||
else
|
||||
./scripts/install_native_deps.sh "$TMPDIR"
|
||||
fi
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
echo "Staged version $VERSION for release in $TMPDIR"
|
||||
|
||||
if [[ "$INCLUDE_NATIVE" -eq 1 ]]; then
|
||||
echo "Test Rust:"
|
||||
echo " node ${TMPDIR}/bin/codex.js --help"
|
||||
else
|
||||
echo "Test Node:"
|
||||
echo " node ${TMPDIR}/bin/codex.js --help"
|
||||
fi
|
||||
|
||||
# Print final hint for convenience
|
||||
if [[ "$INCLUDE_NATIVE" -eq 1 ]]; then
|
||||
echo "Next: cd \"$TMPDIR\" && npm publish --tag native"
|
||||
else
|
||||
echo "Next: cd \"$TMPDIR\" && npm publish"
|
||||
fi
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { ApprovalPolicy } from "./approvals";
|
||||
import type { AppConfig } from "./utils/config";
|
||||
import type { TerminalChatSession } from "./utils/session.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
|
||||
import TerminalChat from "./components/chat/terminal-chat";
|
||||
import TerminalChatPastRollout from "./components/chat/terminal-chat-past-rollout";
|
||||
import { checkInGit } from "./utils/check-in-git";
|
||||
import { CLI_VERSION, type TerminalChatSession } from "./utils/session.js";
|
||||
import { onExit } from "./utils/terminal";
|
||||
import { CLI_VERSION } from "./version";
|
||||
import { ConfirmInput } from "@inkjs/ui";
|
||||
import { Box, Text, useApp, useStdin } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
@@ -49,6 +50,7 @@ export default function App({
|
||||
<TerminalChatPastRollout
|
||||
session={rollout.session}
|
||||
items={rollout.items}
|
||||
fileOpener={config.fileOpener}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,12 +281,14 @@ export function resolvePathAgainstWorkdir(
|
||||
candidatePath: string,
|
||||
workdir: string | undefined,
|
||||
): string {
|
||||
if (path.isAbsolute(candidatePath)) {
|
||||
return candidatePath;
|
||||
// Normalize candidatePath to prevent path traversal attacks
|
||||
const normalizedCandidatePath = path.normalize(candidatePath);
|
||||
if (path.isAbsolute(normalizedCandidatePath)) {
|
||||
return normalizedCandidatePath;
|
||||
} else if (workdir != null) {
|
||||
return path.resolve(workdir, candidatePath);
|
||||
return path.resolve(workdir, normalizedCandidatePath);
|
||||
} else {
|
||||
return path.resolve(candidatePath);
|
||||
return path.resolve(normalizedCandidatePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +365,11 @@ export function isSafeCommand(
|
||||
reason: "View file contents",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "nl":
|
||||
return {
|
||||
reason: "View file with line numbers",
|
||||
group: "Reading files",
|
||||
};
|
||||
case "rg":
|
||||
return {
|
||||
reason: "Ripgrep search",
|
||||
@@ -446,11 +453,15 @@ export function isSafeCommand(
|
||||
}
|
||||
break;
|
||||
case "sed":
|
||||
// We allow two types of sed invocations:
|
||||
// 1. `sed -n 1,200p FILE`
|
||||
// 2. `sed -n 1,200p` because the file is passed via stdin, e.g.,
|
||||
// `nl -ba README.md | sed -n '1,200p'`
|
||||
if (
|
||||
cmd1 === "-n" &&
|
||||
isValidSedNArg(cmd2) &&
|
||||
typeof cmd3 === "string" &&
|
||||
command.length === 4
|
||||
(command.length === 3 ||
|
||||
(typeof cmd3 === "string" && command.length === 4))
|
||||
) {
|
||||
return {
|
||||
reason: "Sed print subset",
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
import "dotenv/config";
|
||||
|
||||
// Exit early if on an older version of Node.js (< 22)
|
||||
const major = process.versions.node.split(".").map(Number)[0]!;
|
||||
if (major < 22) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"\n" +
|
||||
"Codex CLI requires Node.js version 22 or newer.\n" +
|
||||
`You are running Node.js v${process.versions.node}.\n` +
|
||||
"Please upgrade Node.js: https://nodejs.org/en/download/\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hack to suppress deprecation warnings (punycode)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process as any).noDeprecation = true;
|
||||
@@ -14,16 +27,20 @@ import type { ReasoningEffort } from "openai/resources.mjs";
|
||||
|
||||
import App from "./app";
|
||||
import { runSinglePass } from "./cli-singlepass";
|
||||
import SessionsOverlay from "./components/sessions-overlay.js";
|
||||
import { AgentLoop } from "./utils/agent/agent-loop";
|
||||
import { ReviewDecision } from "./utils/agent/review";
|
||||
import { AutoApprovalMode } from "./utils/auto-approval-mode";
|
||||
import { checkForUpdates } from "./utils/check-updates";
|
||||
import {
|
||||
getApiKey,
|
||||
loadConfig,
|
||||
PRETTY_PRINT,
|
||||
INSTRUCTIONS_FILEPATH,
|
||||
} from "./utils/config";
|
||||
import {
|
||||
getApiKey as fetchApiKey,
|
||||
maybeRedeemCredits,
|
||||
} from "./utils/get-api-key";
|
||||
import { createInputItem } from "./utils/input-utils";
|
||||
import { initLogger } from "./utils/logger/log";
|
||||
import { isModelSupportedForResponses } from "./utils/model-utils.js";
|
||||
@@ -34,6 +51,7 @@ import { spawnSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import { render } from "ink";
|
||||
import meow from "meow";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
|
||||
@@ -56,10 +74,13 @@ const cli = meow(
|
||||
--version Print version and exit
|
||||
|
||||
-h, --help Show usage and exit
|
||||
-m, --model <model> Model to use for completions (default: o4-mini)
|
||||
-m, --model <model> Model to use for completions (default: codex-mini-latest)
|
||||
-p, --provider <provider> Provider to use for completions (default: openai)
|
||||
-i, --image <path> Path(s) to image files to include as input
|
||||
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
|
||||
--history Browse previous sessions
|
||||
--login Start a new sign in flow
|
||||
--free Retry redeeming free credits
|
||||
-q, --quiet Non-interactive mode that only prints the assistant's final output
|
||||
-c, --config Open the instructions file in your editor
|
||||
-w, --writable-root <path> Writable folder for sandbox in full-auto mode (can be specified multiple times)
|
||||
@@ -68,7 +89,7 @@ const cli = meow(
|
||||
--auto-edit Automatically approve file edits; still prompt for commands
|
||||
--full-auto Automatically approve edits and commands when executed in the sandbox
|
||||
|
||||
--no-project-doc Do not automatically include the repository's 'codex.md'
|
||||
--no-project-doc Do not automatically include the repository's 'AGENTS.md'
|
||||
--project-doc <file> Include an additional markdown file at <file> as context
|
||||
--full-stdout Do not truncate stdout/stderr from command outputs
|
||||
--notify Enable desktop notifications for responses
|
||||
@@ -79,6 +100,8 @@ const cli = meow(
|
||||
--flex-mode Use "flex-mode" processing mode for the request (only supported
|
||||
with models o3 and o4-mini)
|
||||
|
||||
--reasoning <effort> Set the reasoning effort level (low, medium, high) (default: high)
|
||||
|
||||
Dangerous options
|
||||
--dangerously-auto-approve-everything
|
||||
Skip all confirmation prompts and execute commands without
|
||||
@@ -102,6 +125,9 @@ const cli = meow(
|
||||
help: { type: "boolean", aliases: ["h"] },
|
||||
version: { type: "boolean", description: "Print version and exit" },
|
||||
view: { type: "string" },
|
||||
history: { type: "boolean", description: "Browse previous sessions" },
|
||||
login: { type: "boolean", description: "Force a new sign in flow" },
|
||||
free: { type: "boolean", description: "Retry redeeming free credits" },
|
||||
model: { type: "string", aliases: ["m"] },
|
||||
provider: { type: "string", aliases: ["p"] },
|
||||
image: { type: "string", isMultiple: true, aliases: ["i"] },
|
||||
@@ -144,7 +170,7 @@ const cli = meow(
|
||||
},
|
||||
noProjectDoc: {
|
||||
type: "boolean",
|
||||
description: "Disable automatic inclusion of project-level codex.md",
|
||||
description: "Disable automatic inclusion of project-level AGENTS.md",
|
||||
},
|
||||
projectDoc: {
|
||||
type: "string",
|
||||
@@ -259,11 +285,82 @@ let config = loadConfig(undefined, undefined, {
|
||||
isFullContext: fullContextMode,
|
||||
});
|
||||
|
||||
const prompt = cli.input[0];
|
||||
// `prompt` can be updated later when the user resumes a previous session
|
||||
// via the `--history` flag. Therefore it must be declared with `let` rather
|
||||
// than `const`.
|
||||
let prompt = cli.input[0];
|
||||
const model = cli.flags.model ?? config.model;
|
||||
const imagePaths = cli.flags.image;
|
||||
const provider = cli.flags.provider ?? config.provider ?? "openai";
|
||||
const apiKey = getApiKey(provider);
|
||||
|
||||
const client = {
|
||||
issuer: "https://auth.openai.com",
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
};
|
||||
|
||||
let apiKey = "";
|
||||
let savedTokens:
|
||||
| {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Try to load existing auth file if present
|
||||
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;
|
||||
const lastRefreshTime = data.last_refresh
|
||||
? new Date(data.last_refresh).getTime()
|
||||
: 0;
|
||||
const expired = Date.now() - lastRefreshTime > 28 * 24 * 60 * 60 * 1000;
|
||||
if (data.OPENAI_API_KEY && !expired) {
|
||||
apiKey = data.OPENAI_API_KEY;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 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;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (!apiKey) {
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${chalk.bold("codex --free")} attempting to redeem credits...`);
|
||||
if (!savedTokens?.refresh_token) {
|
||||
apiKey = await fetchApiKey(client.issuer, client.client_id, true);
|
||||
// fetchApiKey includes credit redemption as the end of the flow
|
||||
} else {
|
||||
await maybeRedeemCredits(
|
||||
client.issuer,
|
||||
client.client_id,
|
||||
savedTokens.refresh_token,
|
||||
savedTokens.id_token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set of providers that don't require API keys
|
||||
const NO_API_KEY_REQUIRED = new Set(["ollama"]);
|
||||
@@ -306,8 +403,8 @@ config = {
|
||||
model: model ?? config.model,
|
||||
notify: Boolean(cli.flags.notify),
|
||||
reasoningEffort:
|
||||
(cli.flags.reasoning as ReasoningEffort | undefined) ?? "high",
|
||||
flexMode: Boolean(cli.flags.flexMode),
|
||||
(cli.flags.reasoning as ReasoningEffort | undefined) ?? "medium",
|
||||
flexMode: cli.flags.flexMode || (config.flexMode ?? false),
|
||||
provider,
|
||||
disableResponseStorage,
|
||||
};
|
||||
@@ -321,15 +418,19 @@ try {
|
||||
}
|
||||
|
||||
// For --flex-mode, validate and exit if incorrect.
|
||||
if (cli.flags.flexMode) {
|
||||
if (config.flexMode) {
|
||||
const allowedFlexModels = new Set(["o3", "o4-mini"]);
|
||||
if (!allowedFlexModels.has(config.model)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`The --flex-mode option is only supported when using the 'o3' or 'o4-mini' models. ` +
|
||||
`Current model: '${config.model}'.`,
|
||||
);
|
||||
process.exit(1);
|
||||
if (cli.flags.flexMode) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`The --flex-mode option is only supported when using the 'o3' or 'o4-mini' models. ` +
|
||||
`Current model: '${config.model}'.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
config.flexMode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +450,46 @@ if (
|
||||
|
||||
let rollout: AppRollout | undefined;
|
||||
|
||||
// For --history, show session selector and optionally update prompt or rollout.
|
||||
if (cli.flags.history) {
|
||||
const result: { path: string; mode: "view" | "resume" } | null =
|
||||
await new Promise((resolve) => {
|
||||
const instance = render(
|
||||
React.createElement(SessionsOverlay, {
|
||||
onView: (p: string) => {
|
||||
instance.unmount();
|
||||
resolve({ path: p, mode: "view" });
|
||||
},
|
||||
onResume: (p: string) => {
|
||||
instance.unmount();
|
||||
resolve({ path: p, mode: "resume" });
|
||||
},
|
||||
onExit: () => {
|
||||
instance.unmount();
|
||||
resolve(null);
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (result.mode === "view") {
|
||||
try {
|
||||
const content = fs.readFileSync(result.path, "utf-8");
|
||||
rollout = JSON.parse(content) as AppRollout;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error reading session file:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
prompt = `Resume this session: ${result.path}`;
|
||||
}
|
||||
}
|
||||
|
||||
// For --view, optionally load an existing rollout from disk, display it and exit.
|
||||
if (cli.flags.view) {
|
||||
const viewPath = cli.flags.view;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TerminalHeaderProps } from "./terminal-header.js";
|
||||
import type { GroupedResponseItem } from "./use-message-grouping.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
import type { FileOpenerScheme } from "src/utils/config.js";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
@@ -19,11 +20,13 @@ type MessageHistoryProps = {
|
||||
confirmationPrompt: React.ReactNode;
|
||||
loading: boolean;
|
||||
headerProps: TerminalHeaderProps;
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
};
|
||||
|
||||
const MessageHistory: React.FC<MessageHistoryProps> = ({
|
||||
batch,
|
||||
headerProps,
|
||||
fileOpener,
|
||||
}) => {
|
||||
const messages = batch.map(({ item }) => item!);
|
||||
|
||||
@@ -68,7 +71,10 @@ const MessageHistory: React.FC<MessageHistoryProps> = ({
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem item={message} />
|
||||
<TerminalChatResponseItem
|
||||
item={message}
|
||||
fileOpener={fileOpener}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -137,6 +137,9 @@ export interface MultilineTextEditorProps {
|
||||
|
||||
// Called when the internal text buffer updates.
|
||||
readonly onChange?: (text: string) => void;
|
||||
|
||||
// Optional initial cursor position (character offset)
|
||||
readonly initialCursorOffset?: number;
|
||||
}
|
||||
|
||||
// Expose a minimal imperative API so parent components (e.g. TerminalChatInput)
|
||||
@@ -169,6 +172,7 @@ const MultilineTextEditorInner = (
|
||||
onSubmit,
|
||||
focus = true,
|
||||
onChange,
|
||||
initialCursorOffset,
|
||||
}: MultilineTextEditorProps,
|
||||
ref: React.Ref<MultilineTextEditorHandle | null>,
|
||||
): React.ReactElement => {
|
||||
@@ -176,7 +180,7 @@ const MultilineTextEditorInner = (
|
||||
// Editor State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buffer = useRef(new TextBuffer(initialText));
|
||||
const buffer = useRef(new TextBuffer(initialText, initialCursorOffset));
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Keep track of the current terminal size so that the editor grows/shrinks
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MultilineTextEditorHandle } from "./multiline-editor";
|
||||
import type { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import type { FileSystemSuggestion } from "../../utils/file-system-suggestions.js";
|
||||
import type { HistoryEntry } from "../../utils/storage/command-history.js";
|
||||
import type {
|
||||
ResponseInputItem,
|
||||
@@ -11,6 +12,7 @@ import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
|
||||
import TextCompletions from "./terminal-chat-completions.js";
|
||||
import { loadConfig } from "../../utils/config.js";
|
||||
import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js";
|
||||
import { expandFileTags } from "../../utils/file-tag-utils";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
import { log } from "../../utils/logger/log.js";
|
||||
import { setSessionId } from "../../utils/session.js";
|
||||
@@ -52,6 +54,7 @@ export default function TerminalChatInput({
|
||||
openApprovalOverlay,
|
||||
openHelpOverlay,
|
||||
openDiffOverlay,
|
||||
openSessionsOverlay,
|
||||
onCompact,
|
||||
interruptAgent,
|
||||
active,
|
||||
@@ -75,6 +78,7 @@ export default function TerminalChatInput({
|
||||
openApprovalOverlay: () => void;
|
||||
openHelpOverlay: () => void;
|
||||
openDiffOverlay: () => void;
|
||||
openSessionsOverlay: () => void;
|
||||
onCompact: () => void;
|
||||
interruptAgent: () => void;
|
||||
active: boolean;
|
||||
@@ -92,16 +96,120 @@ export default function TerminalChatInput({
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [draftInput, setDraftInput] = useState<string>("");
|
||||
const [skipNextSubmit, setSkipNextSubmit] = useState<boolean>(false);
|
||||
const [fsSuggestions, setFsSuggestions] = useState<Array<string>>([]);
|
||||
const [fsSuggestions, setFsSuggestions] = useState<
|
||||
Array<FileSystemSuggestion>
|
||||
>([]);
|
||||
const [selectedCompletion, setSelectedCompletion] = useState<number>(-1);
|
||||
// Multiline text editor key to force remount after submission
|
||||
const [editorKey, setEditorKey] = useState(0);
|
||||
const [editorState, setEditorState] = useState<{
|
||||
key: number;
|
||||
initialCursorOffset?: number;
|
||||
}>({ key: 0 });
|
||||
// Imperative handle from the multiline editor so we can query caret position
|
||||
const editorRef = useRef<MultilineTextEditorHandle | null>(null);
|
||||
// Track the caret row across keystrokes
|
||||
const prevCursorRow = useRef<number | null>(null);
|
||||
const prevCursorWasAtLastRow = useRef<boolean>(false);
|
||||
|
||||
// --- Helper for updating input, remounting editor, and moving cursor to end ---
|
||||
const applyFsSuggestion = useCallback((newInputText: string) => {
|
||||
setInput(newInputText);
|
||||
setEditorState((s) => ({
|
||||
key: s.key + 1,
|
||||
initialCursorOffset: newInputText.length,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// --- Helper for updating file system suggestions ---
|
||||
function updateFsSuggestions(
|
||||
txt: string,
|
||||
alwaysUpdateSelection: boolean = false,
|
||||
) {
|
||||
// Clear file system completions if a space is typed
|
||||
if (txt.endsWith(" ")) {
|
||||
setFsSuggestions([]);
|
||||
setSelectedCompletion(-1);
|
||||
} else {
|
||||
// Determine the current token (last whitespace-separated word)
|
||||
const words = txt.trim().split(/\s+/);
|
||||
const lastWord = words[words.length - 1] ?? "";
|
||||
|
||||
const shouldUpdateSelection =
|
||||
lastWord.startsWith("@") || alwaysUpdateSelection;
|
||||
|
||||
// Strip optional leading '@' for the path prefix
|
||||
let pathPrefix: string;
|
||||
if (lastWord.startsWith("@")) {
|
||||
pathPrefix = lastWord.slice(1);
|
||||
// If only '@' is typed, list everything in the current directory
|
||||
pathPrefix = pathPrefix.length === 0 ? "./" : pathPrefix;
|
||||
} else {
|
||||
pathPrefix = lastWord;
|
||||
}
|
||||
|
||||
if (shouldUpdateSelection) {
|
||||
const completions = getFileSystemSuggestions(pathPrefix);
|
||||
setFsSuggestions(completions);
|
||||
if (completions.length > 0) {
|
||||
setSelectedCompletion((prev) =>
|
||||
prev < 0 || prev >= completions.length ? 0 : prev,
|
||||
);
|
||||
} else {
|
||||
setSelectedCompletion(-1);
|
||||
}
|
||||
} else if (fsSuggestions.length > 0) {
|
||||
// Token cleared → clear menu
|
||||
setFsSuggestions([]);
|
||||
setSelectedCompletion(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of replacing text with a file system suggestion
|
||||
*/
|
||||
interface ReplacementResult {
|
||||
/** The new text with the suggestion applied */
|
||||
text: string;
|
||||
/** The selected suggestion if a replacement was made */
|
||||
suggestion: FileSystemSuggestion | null;
|
||||
/** Whether a replacement was actually made */
|
||||
wasReplaced: boolean;
|
||||
}
|
||||
|
||||
// --- Helper for replacing input with file system suggestion ---
|
||||
function getFileSystemSuggestion(
|
||||
txt: string,
|
||||
requireAtPrefix: boolean = false,
|
||||
): ReplacementResult {
|
||||
if (fsSuggestions.length === 0 || selectedCompletion < 0) {
|
||||
return { text: txt, suggestion: null, wasReplaced: false };
|
||||
}
|
||||
|
||||
const words = txt.trim().split(/\s+/);
|
||||
const lastWord = words[words.length - 1] ?? "";
|
||||
|
||||
// Check if @ prefix is required and the last word doesn't have it
|
||||
if (requireAtPrefix && !lastWord.startsWith("@")) {
|
||||
return { text: txt, suggestion: null, wasReplaced: false };
|
||||
}
|
||||
|
||||
const selected = fsSuggestions[selectedCompletion];
|
||||
if (!selected) {
|
||||
return { text: txt, suggestion: null, wasReplaced: false };
|
||||
}
|
||||
|
||||
const replacement = lastWord.startsWith("@")
|
||||
? `@${selected.path}`
|
||||
: selected.path;
|
||||
words[words.length - 1] = replacement;
|
||||
return {
|
||||
text: words.join(" "),
|
||||
suggestion: selected,
|
||||
wasReplaced: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Load command history on component mount
|
||||
useEffect(() => {
|
||||
async function loadHistory() {
|
||||
@@ -174,6 +282,9 @@ export default function TerminalChatInput({
|
||||
case "/history":
|
||||
openOverlay();
|
||||
break;
|
||||
case "/sessions":
|
||||
openSessionsOverlay();
|
||||
break;
|
||||
case "/help":
|
||||
openHelpOverlay();
|
||||
break;
|
||||
@@ -223,21 +334,12 @@ export default function TerminalChatInput({
|
||||
}
|
||||
|
||||
if (_key.tab && selectedCompletion >= 0) {
|
||||
const words = input.trim().split(/\s+/);
|
||||
const selected = fsSuggestions[selectedCompletion];
|
||||
|
||||
if (words.length > 0 && selected) {
|
||||
words[words.length - 1] = selected;
|
||||
const newText = words.join(" ");
|
||||
setInput(newText);
|
||||
// Force remount of the editor with the new text
|
||||
setEditorKey((k) => k + 1);
|
||||
|
||||
// We need to move the cursor to the end after editor remounts
|
||||
setTimeout(() => {
|
||||
editorRef.current?.moveCursorToEnd?.();
|
||||
}, 0);
|
||||
const { text: newText, wasReplaced } =
|
||||
getFileSystemSuggestion(input);
|
||||
|
||||
// Only proceed if the text was actually changed
|
||||
if (wasReplaced) {
|
||||
applyFsSuggestion(newText);
|
||||
setFsSuggestions([]);
|
||||
setSelectedCompletion(-1);
|
||||
}
|
||||
@@ -277,7 +379,7 @@ export default function TerminalChatInput({
|
||||
|
||||
setInput(history[newIndex]?.command ?? "");
|
||||
// Re-mount the editor so it picks up the new initialText
|
||||
setEditorKey((k) => k + 1);
|
||||
setEditorState((s) => ({ key: s.key + 1 }));
|
||||
return; // handled
|
||||
}
|
||||
|
||||
@@ -296,28 +398,23 @@ export default function TerminalChatInput({
|
||||
if (newIndex >= history.length) {
|
||||
setHistoryIndex(null);
|
||||
setInput(draftInput);
|
||||
setEditorKey((k) => k + 1);
|
||||
setEditorState((s) => ({ key: s.key + 1 }));
|
||||
} else {
|
||||
setHistoryIndex(newIndex);
|
||||
setInput(history[newIndex]?.command ?? "");
|
||||
setEditorKey((k) => k + 1);
|
||||
setEditorState((s) => ({ key: s.key + 1 }));
|
||||
}
|
||||
return; // handled
|
||||
}
|
||||
// Otherwise let it propagate
|
||||
}
|
||||
|
||||
if (_key.tab) {
|
||||
const words = input.split(/\s+/);
|
||||
const mostRecentWord = words[words.length - 1];
|
||||
if (mostRecentWord === undefined || mostRecentWord === "") {
|
||||
return;
|
||||
}
|
||||
const completions = getFileSystemSuggestions(mostRecentWord);
|
||||
setFsSuggestions(completions);
|
||||
if (completions.length > 0) {
|
||||
setSelectedCompletion(0);
|
||||
}
|
||||
// Defer filesystem suggestion logic to onSubmit if enter key is pressed
|
||||
if (!_key.return) {
|
||||
// Pressing tab should trigger the file system suggestions
|
||||
const shouldUpdateSelection = _key.tab;
|
||||
const targetInput = _key.delete ? input.slice(0, -1) : input + _input;
|
||||
updateFsSuggestions(targetInput, shouldUpdateSelection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +489,10 @@ export default function TerminalChatInput({
|
||||
setInput("");
|
||||
openOverlay();
|
||||
return;
|
||||
} else if (inputValue === "/sessions") {
|
||||
setInput("");
|
||||
openSessionsOverlay();
|
||||
return;
|
||||
} else if (inputValue === "/help") {
|
||||
setInput("");
|
||||
openHelpOverlay();
|
||||
@@ -492,7 +593,7 @@ export default function TerminalChatInput({
|
||||
|
||||
try {
|
||||
const os = await import("node:os");
|
||||
const { CLI_VERSION } = await import("../../utils/session.js");
|
||||
const { CLI_VERSION } = await import("../../version.js");
|
||||
const { buildBugReportUrl } = await import(
|
||||
"../../utils/bug-report.js"
|
||||
);
|
||||
@@ -599,7 +700,10 @@ export default function TerminalChatInput({
|
||||
);
|
||||
text = text.trim();
|
||||
|
||||
const inputItem = await createInputItem(text, images);
|
||||
// Expand @file tokens into XML blocks for the model
|
||||
const expandedText = await expandFileTags(text);
|
||||
|
||||
const inputItem = await createInputItem(expandedText, images);
|
||||
submitInput([inputItem]);
|
||||
|
||||
// Get config for history persistence.
|
||||
@@ -633,6 +737,7 @@ export default function TerminalChatInput({
|
||||
openModelOverlay,
|
||||
openHelpOverlay,
|
||||
openDiffOverlay,
|
||||
openSessionsOverlay,
|
||||
history,
|
||||
onCompact,
|
||||
skipNextSubmit,
|
||||
@@ -673,28 +778,30 @@ export default function TerminalChatInput({
|
||||
setHistoryIndex(null);
|
||||
}
|
||||
setInput(txt);
|
||||
|
||||
// Clear tab completions if a space is typed
|
||||
if (txt.endsWith(" ")) {
|
||||
setFsSuggestions([]);
|
||||
setSelectedCompletion(-1);
|
||||
} else if (fsSuggestions.length > 0) {
|
||||
// Update file suggestions as user types
|
||||
const words = txt.trim().split(/\s+/);
|
||||
const mostRecentWord =
|
||||
words.length > 0 ? words[words.length - 1] : "";
|
||||
if (mostRecentWord !== undefined) {
|
||||
setFsSuggestions(getFileSystemSuggestions(mostRecentWord));
|
||||
}
|
||||
}
|
||||
}}
|
||||
key={editorKey}
|
||||
key={editorState.key}
|
||||
initialCursorOffset={editorState.initialCursorOffset}
|
||||
initialText={input}
|
||||
height={6}
|
||||
focus={active}
|
||||
onSubmit={(txt) => {
|
||||
onSubmit(txt);
|
||||
setEditorKey((k) => k + 1);
|
||||
// If final token is an @path, replace with filesystem suggestion if available
|
||||
const {
|
||||
text: replacedText,
|
||||
suggestion,
|
||||
wasReplaced,
|
||||
} = getFileSystemSuggestion(txt, true);
|
||||
|
||||
// If we replaced @path token with a directory, don't submit
|
||||
if (wasReplaced && suggestion?.isDirectory) {
|
||||
applyFsSuggestion(replacedText);
|
||||
// Update suggestions for the new directory
|
||||
updateFsSuggestions(replacedText, true);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(replacedText);
|
||||
setEditorState((s) => ({ key: s.key + 1 }));
|
||||
setInput("");
|
||||
setHistoryIndex(null);
|
||||
setDraftInput("");
|
||||
@@ -741,7 +848,7 @@ export default function TerminalChatInput({
|
||||
</Text>
|
||||
) : fsSuggestions.length > 0 ? (
|
||||
<TextCompletions
|
||||
completions={fsSuggestions}
|
||||
completions={fsSuggestions.map((suggestion) => suggestion.path)}
|
||||
selectedCompletion={selectedCompletion}
|
||||
displayLimit={5}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TerminalChatSession } from "../../utils/session.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
import type { FileOpenerScheme } from "src/utils/config.js";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item";
|
||||
import { Box, Text } from "ink";
|
||||
@@ -8,9 +9,11 @@ import React from "react";
|
||||
export default function TerminalChatPastRollout({
|
||||
session,
|
||||
items,
|
||||
fileOpener,
|
||||
}: {
|
||||
session: TerminalChatSession;
|
||||
items: Array<ResponseItem>;
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
}): React.ReactElement {
|
||||
const { version, id: sessionId, model } = session;
|
||||
return (
|
||||
@@ -51,9 +54,13 @@ export default function TerminalChatPastRollout({
|
||||
{React.useMemo(
|
||||
() =>
|
||||
items.map((item, key) => (
|
||||
<TerminalChatResponseItem key={key} item={item} />
|
||||
<TerminalChatResponseItem
|
||||
key={key}
|
||||
item={item}
|
||||
fileOpener={fileOpener}
|
||||
/>
|
||||
)),
|
||||
[items],
|
||||
[items, fileOpener],
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -8,23 +8,30 @@ import type {
|
||||
ResponseOutputMessage,
|
||||
ResponseReasoningItem,
|
||||
} from "openai/resources/responses/responses";
|
||||
import type { FileOpenerScheme } from "src/utils/config";
|
||||
|
||||
import { useTerminalSize } from "../../hooks/use-terminal-size";
|
||||
import { collapseXmlBlocks } from "../../utils/file-tag-utils";
|
||||
import { parseToolCall, parseToolCallOutput } from "../../utils/parsers";
|
||||
import chalk, { type ForegroundColorName } from "chalk";
|
||||
import { Box, Text } from "ink";
|
||||
import { parse, setOptions } from "marked";
|
||||
import TerminalRenderer from "marked-terminal";
|
||||
import path from "path";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { formatCommandForDisplay } from "src/format-command.js";
|
||||
import supportsHyperlinks from "supports-hyperlinks";
|
||||
|
||||
export default function TerminalChatResponseItem({
|
||||
item,
|
||||
fullStdout = false,
|
||||
setOverlayMode,
|
||||
fileOpener,
|
||||
}: {
|
||||
item: ResponseItem;
|
||||
fullStdout?: boolean;
|
||||
setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>;
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
}): React.ReactElement {
|
||||
switch (item.type) {
|
||||
case "message":
|
||||
@@ -32,10 +39,15 @@ export default function TerminalChatResponseItem({
|
||||
<TerminalChatResponseMessage
|
||||
setOverlayMode={setOverlayMode}
|
||||
message={item}
|
||||
fileOpener={fileOpener}
|
||||
/>
|
||||
);
|
||||
// @ts-expect-error new item types aren't in SDK yet
|
||||
case "local_shell_call":
|
||||
case "function_call":
|
||||
return <TerminalChatResponseToolCall message={item} />;
|
||||
// @ts-expect-error new item types aren't in SDK yet
|
||||
case "local_shell_call_output":
|
||||
case "function_call_output":
|
||||
return (
|
||||
<TerminalChatResponseToolCallOutput
|
||||
@@ -49,7 +61,9 @@ export default function TerminalChatResponseItem({
|
||||
|
||||
// @ts-expect-error `reasoning` is not in the responses API yet
|
||||
if (item.type === "reasoning") {
|
||||
return <TerminalChatResponseReasoning message={item} />;
|
||||
return (
|
||||
<TerminalChatResponseReasoning message={item} fileOpener={fileOpener} />
|
||||
);
|
||||
}
|
||||
|
||||
return <TerminalChatResponseGenericMessage message={item} />;
|
||||
@@ -77,8 +91,10 @@ export default function TerminalChatResponseItem({
|
||||
|
||||
export function TerminalChatResponseReasoning({
|
||||
message,
|
||||
fileOpener,
|
||||
}: {
|
||||
message: ResponseReasoningItem & { duration_ms?: number };
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
}): React.ReactElement | null {
|
||||
// Only render when there is a reasoning summary
|
||||
if (!message.summary || message.summary.length === 0) {
|
||||
@@ -91,7 +107,7 @@ export function TerminalChatResponseReasoning({
|
||||
return (
|
||||
<Box key={key} flexDirection="column">
|
||||
{s.headline && <Text bold>{s.headline}</Text>}
|
||||
<Markdown>{s.text}</Markdown>
|
||||
<Markdown fileOpener={fileOpener}>{s.text}</Markdown>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
@@ -107,9 +123,11 @@ const colorsByRole: Record<string, ForegroundColorName> = {
|
||||
function TerminalChatResponseMessage({
|
||||
message,
|
||||
setOverlayMode,
|
||||
fileOpener,
|
||||
}: {
|
||||
message: ResponseInputMessageItem | ResponseOutputMessage;
|
||||
setOverlayMode?: React.Dispatch<React.SetStateAction<OverlayModeType>>;
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
}) {
|
||||
// auto switch to model mode if the system message contains "has been deprecated"
|
||||
useEffect(() => {
|
||||
@@ -128,7 +146,7 @@ function TerminalChatResponseMessage({
|
||||
<Text bold color={colorsByRole[message.role] || "gray"}>
|
||||
{message.role === "assistant" ? "codex" : message.role}
|
||||
</Text>
|
||||
<Markdown>
|
||||
<Markdown fileOpener={fileOpener}>
|
||||
{message.content
|
||||
.map(
|
||||
(c) =>
|
||||
@@ -137,7 +155,7 @@ function TerminalChatResponseMessage({
|
||||
: c.type === "refusal"
|
||||
? c.refusal
|
||||
: c.type === "input_text"
|
||||
? c.text
|
||||
? collapseXmlBlocks(c.text)
|
||||
: c.type === "input_image"
|
||||
? "<Image>"
|
||||
: c.type === "input_file"
|
||||
@@ -153,16 +171,28 @@ function TerminalChatResponseMessage({
|
||||
function TerminalChatResponseToolCall({
|
||||
message,
|
||||
}: {
|
||||
message: ResponseFunctionToolCallItem;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
message: ResponseFunctionToolCallItem | any;
|
||||
}) {
|
||||
const details = parseToolCall(message);
|
||||
let workdir: string | undefined;
|
||||
let cmdReadableText: string | undefined;
|
||||
if (message.type === "function_call") {
|
||||
const details = parseToolCall(message);
|
||||
workdir = details?.workdir;
|
||||
cmdReadableText = details?.cmdReadableText;
|
||||
} else if (message.type === "local_shell_call") {
|
||||
const action = message.action;
|
||||
workdir = action.working_directory;
|
||||
cmdReadableText = formatCommandForDisplay(action.command);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="magentaBright" bold>
|
||||
command
|
||||
{workdir ? <Text dimColor>{` (${workdir})`}</Text> : ""}
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>$</Text> {details?.cmdReadableText}
|
||||
<Text dimColor>$</Text> {cmdReadableText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -172,7 +202,8 @@ function TerminalChatResponseToolCallOutput({
|
||||
message,
|
||||
fullStdout,
|
||||
}: {
|
||||
message: ResponseFunctionToolCallOutputItem;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
message: ResponseFunctionToolCallOutputItem | any;
|
||||
fullStdout: boolean;
|
||||
}) {
|
||||
const { output, metadata } = parseToolCallOutput(message.output);
|
||||
@@ -239,26 +270,91 @@ export function TerminalChatResponseGenericMessage({
|
||||
|
||||
export type MarkdownProps = TerminalRendererOptions & {
|
||||
children: string;
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
/** Base path for resolving relative file citation paths. */
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export function Markdown({
|
||||
children,
|
||||
fileOpener,
|
||||
cwd,
|
||||
...options
|
||||
}: MarkdownProps): React.ReactElement {
|
||||
const size = useTerminalSize();
|
||||
|
||||
const rendered = React.useMemo(() => {
|
||||
const linkifiedMarkdown = rewriteFileCitations(children, fileOpener, cwd);
|
||||
|
||||
// Configure marked for this specific render
|
||||
setOptions({
|
||||
// @ts-expect-error missing parser, space props
|
||||
renderer: new TerminalRenderer({ ...options, width: size.columns }),
|
||||
});
|
||||
const parsed = parse(children, { async: false }).trim();
|
||||
const parsed = parse(linkifiedMarkdown, { async: false }).trim();
|
||||
|
||||
// Remove the truncation logic
|
||||
return parsed;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- options is an object of primitives
|
||||
}, [children, size.columns, size.rows]);
|
||||
}, [
|
||||
children,
|
||||
size.columns,
|
||||
size.rows,
|
||||
fileOpener,
|
||||
supportsHyperlinks.stdout,
|
||||
chalk.level,
|
||||
]);
|
||||
|
||||
return <Text>{rendered}</Text>;
|
||||
}
|
||||
|
||||
/** Regex to match citations for source files (hence the `F:` prefix). */
|
||||
const citationRegex = new RegExp(
|
||||
[
|
||||
// Opening marker
|
||||
"【",
|
||||
|
||||
// Capture group 1: file ID or name (anything except '†')
|
||||
"F:([^†]+)",
|
||||
|
||||
// Field separator
|
||||
"†",
|
||||
|
||||
// Capture group 2: start line (digits)
|
||||
"L(\\d+)",
|
||||
|
||||
// Non-capturing group for optional end line
|
||||
"(?:",
|
||||
|
||||
// Capture group 3: end line (digits or '?')
|
||||
"-L(\\d+|\\?)",
|
||||
|
||||
// End of optional group (may not be present)
|
||||
")?",
|
||||
|
||||
// Closing marker
|
||||
"】",
|
||||
].join(""),
|
||||
"g", // Global flag
|
||||
);
|
||||
|
||||
function rewriteFileCitations(
|
||||
markdown: string,
|
||||
fileOpener: FileOpenerScheme | undefined,
|
||||
cwd: string = process.cwd(),
|
||||
): string {
|
||||
citationRegex.lastIndex = 0;
|
||||
return markdown.replace(citationRegex, (_match, file, start, _end) => {
|
||||
const absPath = path.resolve(cwd, file);
|
||||
if (!fileOpener) {
|
||||
return `[${file}](${absPath})`;
|
||||
}
|
||||
const uri = `${fileOpener}://file${absPath}:${start}`;
|
||||
const label = `${file}:${start}`;
|
||||
// In practice, sometimes multiple citations for the same file, but with a
|
||||
// different line number, are shown sequentially, so we:
|
||||
// - include the line number in the label to disambiguate them
|
||||
// - add a space after the link to make it easier to read
|
||||
return `[${label}](${uri}) `;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AppRollout } from "../../app.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js";
|
||||
import type { CommandConfirmation } from "../../utils/agent/agent-loop.js";
|
||||
import type { AppConfig } from "../../utils/config.js";
|
||||
@@ -5,6 +6,7 @@ import type { ColorName } from "chalk";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import TerminalChatInput from "./terminal-chat-input.js";
|
||||
import TerminalChatPastRollout from "./terminal-chat-past-rollout.js";
|
||||
import { TerminalChatToolCallCommand } from "./terminal-chat-tool-call-command.js";
|
||||
import TerminalMessageHistory from "./terminal-message-history.js";
|
||||
import { formatCommandForDisplay } from "../../format-command.js";
|
||||
@@ -13,7 +15,7 @@ import { useTerminalSize } from "../../hooks/use-terminal-size.js";
|
||||
import { AgentLoop } from "../../utils/agent/agent-loop.js";
|
||||
import { ReviewDecision } from "../../utils/agent/review.js";
|
||||
import { generateCompactSummary } from "../../utils/compact-summary.js";
|
||||
import { getBaseUrl, getApiKey, saveConfig } from "../../utils/config.js";
|
||||
import { saveConfig } from "../../utils/config.js";
|
||||
import { extractAppliedPatches as _extractAppliedPatches } from "../../utils/extract-applied-patches.js";
|
||||
import { getGitDiff } from "../../utils/get-diff.js";
|
||||
import { createInputItem } from "../../utils/input-utils.js";
|
||||
@@ -23,24 +25,27 @@ import {
|
||||
calculateContextPercentRemaining,
|
||||
uniqueById,
|
||||
} from "../../utils/model-utils.js";
|
||||
import { CLI_VERSION } from "../../utils/session.js";
|
||||
import { createOpenAIClient } from "../../utils/openai-client.js";
|
||||
import { shortCwd } from "../../utils/short-path.js";
|
||||
import { saveRollout } from "../../utils/storage/save-rollout.js";
|
||||
import { CLI_VERSION } from "../../version.js";
|
||||
import ApprovalModeOverlay from "../approval-mode-overlay.js";
|
||||
import DiffOverlay from "../diff-overlay.js";
|
||||
import HelpOverlay from "../help-overlay.js";
|
||||
import HistoryOverlay from "../history-overlay.js";
|
||||
import ModelOverlay from "../model-overlay.js";
|
||||
import SessionsOverlay from "../sessions-overlay.js";
|
||||
import chalk from "chalk";
|
||||
import fs from "fs/promises";
|
||||
import { Box, Text } from "ink";
|
||||
import { spawn } from "node:child_process";
|
||||
import OpenAI from "openai";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { inspect } from "util";
|
||||
|
||||
export type OverlayModeType =
|
||||
| "none"
|
||||
| "history"
|
||||
| "sessions"
|
||||
| "model"
|
||||
| "approval"
|
||||
| "help"
|
||||
@@ -78,10 +83,7 @@ async function generateCommandExplanation(
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Create a temporary OpenAI client
|
||||
const oai = new OpenAI({
|
||||
apiKey: getApiKey(config.provider),
|
||||
baseURL: getBaseUrl(config.provider),
|
||||
});
|
||||
const oai = createOpenAIClient(config);
|
||||
|
||||
// Format the command for display
|
||||
const commandForDisplay = formatCommandForDisplay(command);
|
||||
@@ -194,6 +196,7 @@ export default function TerminalChat({
|
||||
submitConfirmation,
|
||||
} = useConfirmation();
|
||||
const [overlayMode, setOverlayMode] = useState<OverlayModeType>("none");
|
||||
const [viewRollout, setViewRollout] = useState<AppRollout | null>(null);
|
||||
|
||||
// Store the diff text when opening the diff overlay so the view isn’t
|
||||
// recomputed on every re‑render while it is open.
|
||||
@@ -457,6 +460,16 @@ export default function TerminalChat({
|
||||
[items, model],
|
||||
);
|
||||
|
||||
if (viewRollout) {
|
||||
return (
|
||||
<TerminalChatPastRollout
|
||||
fileOpener={config.fileOpener}
|
||||
session={viewRollout.session}
|
||||
items={viewRollout.items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column">
|
||||
@@ -483,6 +496,7 @@ export default function TerminalChat({
|
||||
initialImagePaths,
|
||||
flexModeEnabled: Boolean(config.flexMode),
|
||||
}}
|
||||
fileOpener={config.fileOpener}
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
@@ -511,6 +525,7 @@ export default function TerminalChat({
|
||||
openModelOverlay={() => setOverlayMode("model")}
|
||||
openApprovalOverlay={() => setOverlayMode("approval")}
|
||||
openHelpOverlay={() => setOverlayMode("help")}
|
||||
openSessionsOverlay={() => setOverlayMode("sessions")}
|
||||
openDiffOverlay={() => {
|
||||
const { isGitRepo, diff } = getGitDiff();
|
||||
let text: string;
|
||||
@@ -570,6 +585,25 @@ export default function TerminalChat({
|
||||
{overlayMode === "history" && (
|
||||
<HistoryOverlay items={items} onExit={() => setOverlayMode("none")} />
|
||||
)}
|
||||
{overlayMode === "sessions" && (
|
||||
<SessionsOverlay
|
||||
onView={async (p) => {
|
||||
try {
|
||||
const txt = await fs.readFile(p, "utf-8");
|
||||
const data = JSON.parse(txt) as AppRollout;
|
||||
setViewRollout(data);
|
||||
setOverlayMode("none");
|
||||
} catch {
|
||||
setOverlayMode("none");
|
||||
}
|
||||
}}
|
||||
onResume={(p) => {
|
||||
setOverlayMode("none");
|
||||
setInitialPrompt(`Resume this session: ${p}`);
|
||||
}}
|
||||
onExit={() => setOverlayMode("none")}
|
||||
/>
|
||||
)}
|
||||
{overlayMode === "model" && (
|
||||
<ModelOverlay
|
||||
currentModel={model}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OverlayModeType } from "./terminal-chat.js";
|
||||
import type { TerminalHeaderProps } from "./terminal-header.js";
|
||||
import type { GroupedResponseItem } from "./use-message-grouping.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
import type { FileOpenerScheme } from "src/utils/config.js";
|
||||
|
||||
import TerminalChatResponseItem from "./terminal-chat-response-item.js";
|
||||
import TerminalHeader from "./terminal-header.js";
|
||||
@@ -23,6 +24,7 @@ type TerminalMessageHistoryProps = {
|
||||
headerProps: TerminalHeaderProps;
|
||||
fullStdout: boolean;
|
||||
setOverlayMode: React.Dispatch<React.SetStateAction<OverlayModeType>>;
|
||||
fileOpener: FileOpenerScheme | undefined;
|
||||
};
|
||||
|
||||
const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
|
||||
@@ -33,6 +35,7 @@ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
|
||||
thinkingSeconds: _thinkingSeconds,
|
||||
fullStdout,
|
||||
setOverlayMode,
|
||||
fileOpener,
|
||||
}) => {
|
||||
// Flatten batch entries to response items.
|
||||
const messages = useMemo(() => batch.map(({ item }) => item!), [batch]);
|
||||
@@ -59,16 +62,25 @@ const TerminalMessageHistory: React.FC<TerminalMessageHistoryProps> = ({
|
||||
key={`${message.id}-${index}`}
|
||||
flexDirection="column"
|
||||
marginLeft={
|
||||
message.type === "message" && message.role === "user" ? 0 : 4
|
||||
message.type === "message" &&
|
||||
(message.role === "user" || message.role === "assistant")
|
||||
? 0
|
||||
: 4
|
||||
}
|
||||
marginTop={
|
||||
message.type === "message" && message.role === "user" ? 0 : 1
|
||||
}
|
||||
marginBottom={
|
||||
message.type === "message" && message.role === "assistant"
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<TerminalChatResponseItem
|
||||
item={message}
|
||||
fullStdout={fullStdout}
|
||||
setOverlayMode={setOverlayMode}
|
||||
fileOpener={fileOpener}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
130
codex-cli/src/components/sessions-overlay.tsx
Normal file
130
codex-cli/src/components/sessions-overlay.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { TypeaheadItem } from "./typeahead-overlay.js";
|
||||
|
||||
import TypeaheadOverlay from "./typeahead-overlay.js";
|
||||
import fs from "fs/promises";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const SESSIONS_ROOT = path.join(os.homedir(), ".codex", "sessions");
|
||||
|
||||
export type SessionMeta = {
|
||||
path: string;
|
||||
timestamp: string;
|
||||
userMessages: number;
|
||||
toolCalls: number;
|
||||
firstMessage: string;
|
||||
};
|
||||
|
||||
async function loadSessions(): Promise<Array<SessionMeta>> {
|
||||
try {
|
||||
const entries = await fs.readdir(SESSIONS_ROOT);
|
||||
const sessions: Array<SessionMeta> = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(SESSIONS_ROOT, entry);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const data = JSON.parse(content) as {
|
||||
session?: { timestamp?: string };
|
||||
items?: Array<{
|
||||
type: string;
|
||||
role: string;
|
||||
content: Array<{ text: string }>;
|
||||
}>;
|
||||
};
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const firstUser = items.find(
|
||||
(i) => i?.type === "message" && i.role === "user",
|
||||
);
|
||||
const firstText =
|
||||
firstUser?.content?.[0]?.text?.replace(/\n/g, " ").slice(0, 16) ?? "";
|
||||
const userMessages = items.filter(
|
||||
(i) => i?.type === "message" && i.role === "user",
|
||||
).length;
|
||||
const toolCalls = items.filter(
|
||||
(i) => i?.type === "function_call",
|
||||
).length;
|
||||
sessions.push({
|
||||
path: filePath,
|
||||
timestamp: data.session?.timestamp || "",
|
||||
userMessages,
|
||||
toolCalls,
|
||||
firstMessage: firstText,
|
||||
});
|
||||
} catch {
|
||||
/* ignore invalid session */
|
||||
}
|
||||
}
|
||||
sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||
return sessions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onView: (sessionPath: string) => void;
|
||||
onResume: (sessionPath: string) => void;
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
export default function SessionsOverlay({
|
||||
onView,
|
||||
onResume,
|
||||
onExit,
|
||||
}: Props): JSX.Element {
|
||||
const [items, setItems] = useState<Array<TypeaheadItem>>([]);
|
||||
const [mode, setMode] = useState<"view" | "resume">("view");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const sessions = await loadSessions();
|
||||
const formatted = sessions.map((s) => {
|
||||
const ts = s.timestamp
|
||||
? new Date(s.timestamp).toLocaleString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "";
|
||||
const first = s.firstMessage?.slice(0, 50);
|
||||
const label = `${ts} · ${s.userMessages} msgs/${s.toolCalls} tools · ${first}`;
|
||||
return { label, value: s.path } as TypeaheadItem;
|
||||
});
|
||||
setItems(formatted);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.tab) {
|
||||
setMode((m) => (m === "view" ? "resume" : "view"));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<TypeaheadOverlay
|
||||
title={mode === "view" ? "View session" : "Resume session"}
|
||||
description={
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{mode === "view" ? "press enter to view" : "press enter to resume"}
|
||||
</Text>
|
||||
<Text dimColor>tab to toggle mode · esc to cancel</Text>
|
||||
</Box>
|
||||
}
|
||||
initialItems={items}
|
||||
onSelect={(value) => {
|
||||
if (mode === "view") {
|
||||
onView(value);
|
||||
} else {
|
||||
onResume(value);
|
||||
}
|
||||
}}
|
||||
onExit={onExit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,7 @@ import type { FileOperation } from "../utils/singlepass/file_ops";
|
||||
|
||||
import Spinner from "./vendor/ink-spinner"; // Third‑party / vendor components
|
||||
import TextInput from "./vendor/ink-text-input";
|
||||
import {
|
||||
OPENAI_TIMEOUT_MS,
|
||||
OPENAI_ORGANIZATION,
|
||||
OPENAI_PROJECT,
|
||||
getBaseUrl,
|
||||
getApiKey,
|
||||
} from "../utils/config";
|
||||
import { createOpenAIClient } from "../utils/openai-client";
|
||||
import {
|
||||
generateDiffSummary,
|
||||
generateEditSummary,
|
||||
@@ -26,7 +20,6 @@ import { EditedFilesSchema } from "../utils/singlepass/file_ops";
|
||||
import * as fsSync from "fs";
|
||||
import * as fsPromises from "fs/promises";
|
||||
import { Box, Text, useApp, useInput } from "ink";
|
||||
import OpenAI from "openai";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import path from "path";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
@@ -399,20 +392,7 @@ export function SinglePassApp({
|
||||
files,
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (OPENAI_ORGANIZATION) {
|
||||
headers["OpenAI-Organization"] = OPENAI_ORGANIZATION;
|
||||
}
|
||||
if (OPENAI_PROJECT) {
|
||||
headers["OpenAI-Project"] = OPENAI_PROJECT;
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: getApiKey(config.provider),
|
||||
baseURL: getBaseUrl(config.provider),
|
||||
timeout: OPENAI_TIMEOUT_MS,
|
||||
defaultHeaders: headers,
|
||||
});
|
||||
const openai = createOpenAIClient(config);
|
||||
const chatResp = await openai.beta.chat.completions.parse({
|
||||
model: config.model,
|
||||
...(config.flexMode ? { service_tier: "flex" } : {}),
|
||||
|
||||
@@ -100,11 +100,14 @@ export default class TextBuffer {
|
||||
|
||||
private clipboard: string | null = null;
|
||||
|
||||
constructor(text = "") {
|
||||
constructor(text = "", initialCursorIdx = 0) {
|
||||
this.lines = text.split("\n");
|
||||
if (this.lines.length === 0) {
|
||||
this.lines = [""];
|
||||
}
|
||||
|
||||
// No need to reset cursor on failure - class already default cursor position to 0,0
|
||||
this.setCursorIdx(initialCursorIdx);
|
||||
}
|
||||
|
||||
/* =======================================================================
|
||||
@@ -122,6 +125,39 @@ export default class TextBuffer {
|
||||
this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cursor position based on a character offset from the start of the document.
|
||||
* @param idx The character offset to move to (0-based)
|
||||
* @returns true if successful, false if the index was invalid
|
||||
*/
|
||||
private setCursorIdx(idx: number): boolean {
|
||||
// Reset preferred column since this is an explicit horizontal movement
|
||||
this.preferredCol = null;
|
||||
|
||||
let remainingChars = idx;
|
||||
let row = 0;
|
||||
|
||||
// Count characters line by line until we find the right position
|
||||
while (row < this.lines.length) {
|
||||
const lineLength = this.lineLen(row);
|
||||
// Add 1 for the newline character (except for the last line)
|
||||
const totalChars = lineLength + (row < this.lines.length - 1 ? 1 : 0);
|
||||
|
||||
if (remainingChars <= lineLength) {
|
||||
this.cursorRow = row;
|
||||
this.cursorCol = remainingChars;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move to next line, subtract this line's characters plus newline
|
||||
remainingChars -= totalChars;
|
||||
row++;
|
||||
}
|
||||
|
||||
// If we get here, the index was too large
|
||||
return false;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* History helpers
|
||||
* =================================================================== */
|
||||
@@ -489,6 +525,22 @@ export default class TextBuffer {
|
||||
end++;
|
||||
}
|
||||
|
||||
/*
|
||||
* After consuming the actual word we also want to swallow any immediate
|
||||
* separator run that *follows* it so that a forward word-delete mirrors
|
||||
* the behaviour of common shells/editors (and matches the expectations
|
||||
* encoded in our test-suite).
|
||||
*
|
||||
* Example – given the text "foo bar baz" and the caret placed at the
|
||||
* beginning of "bar" (index 4) we want Alt+Delete to turn the string
|
||||
* into "foo␠baz" (single space). Without this extra loop we would stop
|
||||
* right before the separating space, producing "foo␠␠baz".
|
||||
*/
|
||||
|
||||
while (end < arr.length && !isWordChar(arr[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
this.lines[this.cursorRow] =
|
||||
cpSlice(line, 0, this.cursorCol) + cpSlice(line, end);
|
||||
// caret stays in place
|
||||
@@ -823,12 +875,42 @@ export default class TextBuffer {
|
||||
// no `key.backspace` flag set. Treat that byte exactly like an ordinary
|
||||
// Backspace for parity with textarea.rs and to make interactive tests
|
||||
// feedable through the simpler `(ch, {}, vp)` path.
|
||||
// ------------------------------------------------------------------
|
||||
// Word-wise deletions
|
||||
//
|
||||
// macOS (and many terminals on Linux/BSD) map the physical “Delete” key
|
||||
// to a *backspace* operation – emitting either the raw DEL (0x7f) byte
|
||||
// or setting `key.backspace = true` in Ink’s parsed event. Holding the
|
||||
// Option/Alt modifier therefore *also* sends backspace semantics even
|
||||
// though users colloquially refer to the shortcut as “⌥+Delete”.
|
||||
//
|
||||
// Historically we treated **modifier + Delete** as a *forward* word
|
||||
// deletion. This behaviour, however, diverges from the default found
|
||||
// in shells (zsh, bash, fish, etc.) and native macOS text fields where
|
||||
// ⌥+Delete removes the word *to the left* of the caret. Update the
|
||||
// mapping so that both
|
||||
//
|
||||
// • ⌥/Alt/Meta + Backspace and
|
||||
// • ⌥/Alt/Meta + Delete
|
||||
//
|
||||
// perform a **backward** word deletion. We keep the ability to delete
|
||||
// the *next* word by requiring an additional Shift modifier – a common
|
||||
// binding on full-size keyboards that expose a dedicated Forward Delete
|
||||
// key.
|
||||
// ------------------------------------------------------------------
|
||||
else if (
|
||||
// ⌥/Alt/Meta + (Backspace|Delete|DEL byte) → backward word delete
|
||||
(key["meta"] || key["ctrl"] || key["alt"]) &&
|
||||
(key["backspace"] || input === "\x7f")
|
||||
!key["shift"] &&
|
||||
(key["backspace"] || input === "\x7f" || key["delete"])
|
||||
) {
|
||||
this.deleteWordLeft();
|
||||
} else if ((key["meta"] || key["ctrl"] || key["alt"]) && key["delete"]) {
|
||||
} else if (
|
||||
// ⇧+⌥/Alt/Meta + (Backspace|Delete|DEL byte) → forward word delete
|
||||
(key["meta"] || key["ctrl"] || key["alt"]) &&
|
||||
key["shift"] &&
|
||||
(key["backspace"] || input === "\x7f" || key["delete"])
|
||||
) {
|
||||
this.deleteWordRight();
|
||||
} else if (
|
||||
key["backspace"] ||
|
||||
|
||||
@@ -8,29 +8,34 @@ import type {
|
||||
ResponseItem,
|
||||
ResponseCreateParams,
|
||||
FunctionTool,
|
||||
Tool,
|
||||
} from "openai/resources/responses/responses.mjs";
|
||||
import type { Reasoning } from "openai/resources.mjs";
|
||||
|
||||
import { CLI_VERSION } from "../../version.js";
|
||||
import {
|
||||
OPENAI_TIMEOUT_MS,
|
||||
OPENAI_ORGANIZATION,
|
||||
OPENAI_PROJECT,
|
||||
getApiKey,
|
||||
getBaseUrl,
|
||||
AZURE_OPENAI_API_VERSION,
|
||||
} from "../config.js";
|
||||
import { log } from "../logger/log.js";
|
||||
import { parseToolCallArguments } from "../parsers.js";
|
||||
import { responsesCreateViaChatCompletions } from "../responses.js";
|
||||
import {
|
||||
ORIGIN,
|
||||
CLI_VERSION,
|
||||
getSessionId,
|
||||
setCurrentModel,
|
||||
setSessionId,
|
||||
} from "../session.js";
|
||||
import { applyPatchToolInstructions } from "./apply-patch.js";
|
||||
import { handleExecCommand } from "./handle-exec-command.js";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import OpenAI, { APIConnectionTimeoutError } from "openai";
|
||||
import OpenAI, { APIConnectionTimeoutError, AzureOpenAI } from "openai";
|
||||
import os from "os";
|
||||
|
||||
// Wait time before retrying after rate limit errors (ms).
|
||||
const RATE_LIMIT_RETRY_WAIT_MS = parseInt(
|
||||
@@ -38,6 +43,9 @@ const RATE_LIMIT_RETRY_WAIT_MS = parseInt(
|
||||
10,
|
||||
);
|
||||
|
||||
// See https://github.com/openai/openai-node/tree/v4?tab=readme-ov-file#configuring-an-https-agent-eg-for-proxies
|
||||
const PROXY_URL = process.env["HTTPS_PROXY"];
|
||||
|
||||
export type CommandConfirmation = {
|
||||
review: ReviewDecision;
|
||||
applyPatch?: ApplyPatchCommand | undefined;
|
||||
@@ -76,7 +84,7 @@ type AgentLoopParams = {
|
||||
onLastResponseId: (lastResponseId: string) => void;
|
||||
};
|
||||
|
||||
const shellTool: FunctionTool = {
|
||||
const shellFunctionTool: FunctionTool = {
|
||||
type: "function",
|
||||
name: "shell",
|
||||
description: "Runs a shell command, and returns its output.",
|
||||
@@ -100,6 +108,11 @@ const shellTool: FunctionTool = {
|
||||
},
|
||||
};
|
||||
|
||||
const localShellTool: Tool = {
|
||||
//@ts-expect-error - waiting on sdk
|
||||
type: "local_shell",
|
||||
};
|
||||
|
||||
export class AgentLoop {
|
||||
private model: string;
|
||||
private provider: string;
|
||||
@@ -293,7 +306,7 @@ export class AgentLoop {
|
||||
this.sessionId = getSessionId() || randomUUID().replaceAll("-", "");
|
||||
// Configure OpenAI client with optional timeout (ms) from environment
|
||||
const timeoutMs = OPENAI_TIMEOUT_MS;
|
||||
const apiKey = getApiKey(this.provider);
|
||||
const apiKey = this.config.apiKey ?? process.env["OPENAI_API_KEY"] ?? "";
|
||||
const baseURL = getBaseUrl(this.provider);
|
||||
|
||||
this.oai = new OpenAI({
|
||||
@@ -314,9 +327,29 @@ export class AgentLoop {
|
||||
: {}),
|
||||
...(OPENAI_PROJECT ? { "OpenAI-Project": OPENAI_PROJECT } : {}),
|
||||
},
|
||||
httpAgent: PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined,
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
});
|
||||
|
||||
if (this.provider.toLowerCase() === "azure") {
|
||||
this.oai = new AzureOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
apiVersion: AZURE_OPENAI_API_VERSION,
|
||||
defaultHeaders: {
|
||||
originator: ORIGIN,
|
||||
version: CLI_VERSION,
|
||||
session_id: this.sessionId,
|
||||
...(OPENAI_ORGANIZATION
|
||||
? { "OpenAI-Organization": OPENAI_ORGANIZATION }
|
||||
: {}),
|
||||
...(OPENAI_PROJECT ? { "OpenAI-Project": OPENAI_PROJECT } : {}),
|
||||
},
|
||||
httpAgent: PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined,
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
setSessionId(this.sessionId);
|
||||
setCurrentModel(this.model);
|
||||
|
||||
@@ -433,6 +466,73 @@ export class AgentLoop {
|
||||
return [outputItem, ...additionalItems];
|
||||
}
|
||||
|
||||
private async handleLocalShellCall(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
item: any,
|
||||
): Promise<Array<ResponseInputItem>> {
|
||||
// If the agent has been canceled in the meantime we should not perform any
|
||||
// additional work. Returning an empty array ensures that we neither execute
|
||||
// the requested tool call nor enqueue any follow‑up input items. This keeps
|
||||
// the cancellation semantics intuitive for users – once they interrupt a
|
||||
// task no further actions related to that task should be taken.
|
||||
if (this.canceled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const outputItem: any = {
|
||||
type: "local_shell_call_output",
|
||||
// `call_id` is mandatory – ensure we never send `undefined` which would
|
||||
// trigger the "No tool output found…" 400 from the API.
|
||||
call_id: item.call_id,
|
||||
output: "no function found",
|
||||
};
|
||||
|
||||
// We intentionally *do not* remove this `callId` from the `pendingAborts`
|
||||
// set right away. The output produced below is only queued up for the
|
||||
// *next* request to the OpenAI API – it has not been delivered yet. If
|
||||
// the user presses ESC‑ESC (i.e. invokes `cancel()`) in the small window
|
||||
// between queuing the result and the actual network call, we need to be
|
||||
// able to surface a synthetic `function_call_output` marked as
|
||||
// "aborted". Keeping the ID in the set until the run concludes
|
||||
// successfully lets the next `run()` differentiate between an aborted
|
||||
// tool call (needs the synthetic output) and a completed one (cleared
|
||||
// below in the `flush()` helper).
|
||||
|
||||
// used to tell model to stop if needed
|
||||
const additionalItems: Array<ResponseInputItem> = [];
|
||||
|
||||
if (item.action.type !== "exec") {
|
||||
throw new Error("Invalid action type");
|
||||
}
|
||||
|
||||
const args = {
|
||||
cmd: item.action.command,
|
||||
workdir: item.action.working_directory,
|
||||
timeoutInMillis: item.action.timeout_ms,
|
||||
};
|
||||
|
||||
const {
|
||||
outputText,
|
||||
metadata,
|
||||
additionalItems: additionalItemsFromExec,
|
||||
} = await handleExecCommand(
|
||||
args,
|
||||
this.config,
|
||||
this.approvalPolicy,
|
||||
this.additionalWritableRoots,
|
||||
this.getCommandConfirmation,
|
||||
this.execAbortController?.signal,
|
||||
);
|
||||
outputItem.output = JSON.stringify({ output: outputText, metadata });
|
||||
|
||||
if (additionalItemsFromExec) {
|
||||
additionalItems.push(...additionalItemsFromExec);
|
||||
}
|
||||
|
||||
return [outputItem, ...additionalItems];
|
||||
}
|
||||
|
||||
public async run(
|
||||
input: Array<ResponseInputItem>,
|
||||
previousResponseId: string = "",
|
||||
@@ -517,6 +617,11 @@ export class AgentLoop {
|
||||
// `disableResponseStorage === true`.
|
||||
let transcriptPrefixLen = 0;
|
||||
|
||||
let tools: Array<Tool> = [shellFunctionTool];
|
||||
if (this.model.startsWith("codex")) {
|
||||
tools = [localShellTool];
|
||||
}
|
||||
|
||||
const stripInternalFields = (
|
||||
item: ResponseInputItem,
|
||||
): ResponseInputItem => {
|
||||
@@ -620,6 +725,8 @@ export class AgentLoop {
|
||||
if (
|
||||
(item as ResponseInputItem).type === "function_call" ||
|
||||
(item as ResponseInputItem).type === "reasoning" ||
|
||||
//@ts-expect-error - waiting on sdk
|
||||
(item as ResponseInputItem).type === "local_shell_call" ||
|
||||
((item as ResponseInputItem).type === "message" &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(item as any).role === "user")
|
||||
@@ -658,7 +765,7 @@ export class AgentLoop {
|
||||
// prompts) and so that freshly generated `function_call_output`s are
|
||||
// shown immediately.
|
||||
// Figure out what subset of `turnInput` constitutes *new* information
|
||||
// for the UI so that we don’t spam the interface with repeats of the
|
||||
// for the UI so that we don't spam the interface with repeats of the
|
||||
// entire transcript on every iteration when response storage is
|
||||
// disabled.
|
||||
const deltaInput = this.disableResponseStorage
|
||||
@@ -675,13 +782,19 @@ export class AgentLoop {
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
let reasoning: Reasoning | undefined;
|
||||
if (this.model.startsWith("o")) {
|
||||
reasoning = { effort: this.config.reasoningEffort ?? "high" };
|
||||
if (this.model === "o3" || this.model === "o4-mini") {
|
||||
reasoning.summary = "auto";
|
||||
}
|
||||
let modelSpecificInstructions: string | undefined;
|
||||
if (this.model.startsWith("o") || this.model.startsWith("codex")) {
|
||||
reasoning = { effort: this.config.reasoningEffort ?? "medium" };
|
||||
reasoning.summary = "auto";
|
||||
}
|
||||
const mergedInstructions = [prefix, this.instructions]
|
||||
if (this.model.startsWith("gpt-4.1")) {
|
||||
modelSpecificInstructions = applyPatchToolInstructions;
|
||||
}
|
||||
const mergedInstructions = [
|
||||
prefix,
|
||||
modelSpecificInstructions,
|
||||
this.instructions,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
@@ -714,7 +827,7 @@ export class AgentLoop {
|
||||
store: true,
|
||||
previous_response_id: lastResponseId || undefined,
|
||||
}),
|
||||
tools: [shellTool],
|
||||
tools: tools,
|
||||
// Explicitly tell the model it is allowed to pick whatever
|
||||
// tool it deems appropriate. Omitting this sometimes leads to
|
||||
// the model ignoring the available tools and responding with
|
||||
@@ -739,7 +852,13 @@ export class AgentLoop {
|
||||
const errCtx = error as any;
|
||||
const status =
|
||||
errCtx?.status ?? errCtx?.httpStatus ?? errCtx?.statusCode;
|
||||
const isServerError = typeof status === "number" && status >= 500;
|
||||
// Treat classical 5xx *and* explicit OpenAI `server_error` types
|
||||
// as transient server-side failures that qualify for a retry. The
|
||||
// SDK often omits the numeric status for these, reporting only
|
||||
// the `type` field.
|
||||
const isServerError =
|
||||
(typeof status === "number" && status >= 500) ||
|
||||
errCtx?.type === "server_error";
|
||||
if (
|
||||
(isTimeout || isServerError || isConnectionError) &&
|
||||
attempt < MAX_RETRIES
|
||||
@@ -928,7 +1047,10 @@ export class AgentLoop {
|
||||
if (maybeReasoning.type === "reasoning") {
|
||||
maybeReasoning.duration_ms = Date.now() - thinkingStart;
|
||||
}
|
||||
if (item.type === "function_call") {
|
||||
if (
|
||||
item.type === "function_call" ||
|
||||
item.type === "local_shell_call"
|
||||
) {
|
||||
// Track outstanding tool call so we can abort later if needed.
|
||||
// The item comes from the streaming response, therefore it has
|
||||
// either `id` (chat) or `call_id` (responses) – we normalise
|
||||
@@ -1051,7 +1173,11 @@ export class AgentLoop {
|
||||
let reasoning: Reasoning | undefined;
|
||||
if (this.model.startsWith("o")) {
|
||||
reasoning = { effort: "high" };
|
||||
if (this.model === "o3" || this.model === "o4-mini") {
|
||||
if (
|
||||
this.model === "o3" ||
|
||||
this.model === "o4-mini" ||
|
||||
this.model === "codex-mini-latest"
|
||||
) {
|
||||
reasoning.summary = "auto";
|
||||
}
|
||||
}
|
||||
@@ -1090,7 +1216,7 @@ export class AgentLoop {
|
||||
store: true,
|
||||
previous_response_id: lastResponseId || undefined,
|
||||
}),
|
||||
tools: [shellTool],
|
||||
tools: tools,
|
||||
tool_choice: "auto",
|
||||
});
|
||||
|
||||
@@ -1137,7 +1263,7 @@ export class AgentLoop {
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "⚠️ Insufficient quota. Please check your billing details and retry.",
|
||||
text: `\u26a0 Insufficient quota: ${err instanceof Error && err.message ? err.message.trim() : "No remaining quota."} Manage or purchase credits at https://platform.openai.com/account/billing.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1452,6 +1578,17 @@ export class AgentLoop {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await this.handleFunctionCall(item);
|
||||
turnInput.push(...result);
|
||||
//@ts-expect-error - waiting on sdk
|
||||
} else if (item.type === "local_shell_call") {
|
||||
//@ts-expect-error - waiting on sdk
|
||||
if (alreadyProcessedResponses.has(item.id)) {
|
||||
continue;
|
||||
}
|
||||
//@ts-expect-error - waiting on sdk
|
||||
alreadyProcessedResponses.add(item.id);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await this.handleLocalShellCall(item);
|
||||
turnInput.push(...result);
|
||||
}
|
||||
emitItem(item as ResponseItem);
|
||||
}
|
||||
@@ -1459,6 +1596,19 @@ export class AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic developer message prefix: includes user, workdir, and rg suggestion.
|
||||
const userName = os.userInfo().username;
|
||||
const workdir = process.cwd();
|
||||
const dynamicLines: Array<string> = [
|
||||
`User: ${userName}`,
|
||||
`Workdir: ${workdir}`,
|
||||
];
|
||||
if (spawnSync("rg", ["--version"], { stdio: "ignore" }).status === 0) {
|
||||
dynamicLines.push(
|
||||
"- Always use rg instead of grep/ls -R because it is much faster and respects gitignore",
|
||||
);
|
||||
}
|
||||
const dynamicPrefix = dynamicLines.join("\n");
|
||||
const prefix = `You are operating as and within the Codex CLI, a terminal-based agentic coding assistant built by OpenAI. It wraps OpenAI models to enable natural language interaction with a local codebase. You are expected to be precise, safe, and helpful.
|
||||
|
||||
You can:
|
||||
@@ -1494,7 +1644,6 @@ You MUST adhere to the following criteria when executing the task:
|
||||
- If there is a .pre-commit-config.yaml, use \`pre-commit run --files ...\` to check that your changes pass the pre-commit checks. However, do not fix pre-existing errors on lines you didn't touch.
|
||||
- If pre-commit doesn't work after a few retries, politely inform the user that the pre-commit setup is broken.
|
||||
- Once you finish coding, you must
|
||||
- Check \`git status\` to sanity check your changes; revert any scratch files or changes.
|
||||
- Remove all inline comments you added as much as possible, even if they look normal. Check using \`git diff\`. Inline comments must be generally avoided, unless active maintainers of the repo, after long careful study of the code and the issue, will still misinterpret the code without the comments.
|
||||
- Check if you accidentally add copyright or license headers. If so, remove them.
|
||||
- Try to run pre-commit if it is available.
|
||||
@@ -1504,7 +1653,9 @@ You MUST adhere to the following criteria when executing the task:
|
||||
- Respond in a friendly tone as a remote teammate, who is knowledgeable, capable and eager to help with coding.
|
||||
- When your task involves writing or modifying files:
|
||||
- Do NOT tell the user to "save the file" or "copy the code into a file" if you already created or modified the file using \`apply_patch\`. Instead, reference the file as already saved.
|
||||
- Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.`;
|
||||
- Do NOT show the full contents of large files you have already written, unless the user explicitly asks for them.
|
||||
|
||||
${dynamicPrefix}`;
|
||||
|
||||
function filterToApiMessages(
|
||||
items: Array<ResponseInputItem>,
|
||||
|
||||
@@ -550,7 +550,15 @@ export function text_to_patch(
|
||||
!(lines[0] ?? "").startsWith(PATCH_PREFIX.trim()) ||
|
||||
lines[lines.length - 1] !== PATCH_SUFFIX.trim()
|
||||
) {
|
||||
throw new DiffError("Invalid patch text");
|
||||
let reason = "Invalid patch text: ";
|
||||
if (lines.length < 2) {
|
||||
reason += "Patch text must have at least two lines.";
|
||||
} else if (!(lines[0] ?? "").startsWith(PATCH_PREFIX.trim())) {
|
||||
reason += "Patch text must start with the correct patch prefix.";
|
||||
} else if (lines[lines.length - 1] !== PATCH_SUFFIX.trim()) {
|
||||
reason += "Patch text must end with the correct patch suffix.";
|
||||
}
|
||||
throw new DiffError(reason);
|
||||
}
|
||||
const parser = new Parser(orig, lines);
|
||||
parser.index = 1;
|
||||
@@ -762,3 +770,46 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const applyPatchToolInstructions = `
|
||||
To edit files, ALWAYS use the \`shell\` tool with \`apply_patch\` CLI. \`apply_patch\` effectively allows you to execute a diff/patch against a file, but the format of the diff specification is unique to this task, so pay careful attention to these instructions. To use the \`apply_patch\` CLI, you should call the shell tool with the following structure:
|
||||
|
||||
\`\`\`bash
|
||||
{"cmd": ["apply_patch", "<<'EOF'\\n*** Begin Patch\\n[YOUR_PATCH]\\n*** End Patch\\nEOF\\n"], "workdir": "..."}
|
||||
\`\`\`
|
||||
|
||||
Where [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.
|
||||
|
||||
*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete.
|
||||
For each snippet of code that needs to be changed, repeat the following:
|
||||
[context_before] -> See below for further instructions on context.
|
||||
- [old_code] -> Precede the old code with a minus sign.
|
||||
+ [new_code] -> Precede the new, replacement code with a plus sign.
|
||||
[context_after] -> See below for further instructions on context.
|
||||
|
||||
For instructions on [context_before] and [context_after]:
|
||||
- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines.
|
||||
- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
|
||||
@@ class BaseClass
|
||||
[3 lines of pre-context]
|
||||
- [old_code]
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
- If a code block is repeated so many times in a class or function such that even a single \`@@\` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple \`@@\` statements to jump to the right context. For instance:
|
||||
|
||||
@@ class BaseClass
|
||||
@@ def method():
|
||||
[3 lines of pre-context]
|
||||
- [old_code]
|
||||
+ [new_code]
|
||||
[3 lines of post-context]
|
||||
|
||||
Note, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as "input" to this function, in order to apply a patch, is shown below.
|
||||
|
||||
\`\`\`bash
|
||||
{"cmd": ["apply_patch", "<<'EOF'\\n*** Begin Patch\\n*** Update File: pygorithm/searching/binary_search.py\\n@@ class BaseClass\\n@@ def search():\\n- pass\\n+ raise NotImplementedError()\\n@@ class Subclass\\n@@ def search():\\n- pass\\n+ raise NotImplementedError()\\n*** End Patch\\nEOF\\n"], "workdir": "..."}
|
||||
\`\`\`
|
||||
|
||||
File references can only be relative, NEVER ABSOLUTE. After the apply_patch command is run, it will always say "Done!", regardless of whether the patch was successfully applied or not. However, you can determine if there are issue and errors by looking at any warnings or logging lines printed BEFORE the "Done!" is output.
|
||||
`;
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { ExecInput, ExecResult } from "./sandbox/interface.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
import type { ParseEntry } from "shell-quote";
|
||||
|
||||
import { process_patch } from "./apply-patch.js";
|
||||
import { SandboxType } from "./sandbox/interface.js";
|
||||
import { execWithLandlock } from "./sandbox/landlock.js";
|
||||
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
|
||||
import { exec as rawExec } from "./sandbox/raw-exec.js";
|
||||
import { formatCommandForDisplay } from "../../format-command.js";
|
||||
import { log } from "../logger/log.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { parse } from "shell-quote";
|
||||
import { resolvePathAgainstWorkdir } from "src/approvals.js";
|
||||
import { PATCH_SUFFIX } from "src/parse-apply-patch.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
|
||||
|
||||
@@ -40,38 +44,61 @@ export function exec(
|
||||
additionalWritableRoots,
|
||||
}: ExecInput & { additionalWritableRoots: ReadonlyArray<string> },
|
||||
sandbox: SandboxType,
|
||||
config: AppConfig,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
// This is a temporary measure to understand what are the common base commands
|
||||
// until we start persisting and uploading rollouts
|
||||
|
||||
const execForSandbox =
|
||||
sandbox === SandboxType.MACOS_SEATBELT ? execWithSeatbelt : rawExec;
|
||||
|
||||
const opts: SpawnOptions = {
|
||||
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
|
||||
...(requiresShell(cmd) ? { shell: true } : {}),
|
||||
...(workdir ? { cwd: workdir } : {}),
|
||||
};
|
||||
// Merge default writable roots with any user-specified ones.
|
||||
const writableRoots = [
|
||||
process.cwd(),
|
||||
os.tmpdir(),
|
||||
...additionalWritableRoots,
|
||||
];
|
||||
return execForSandbox(cmd, opts, writableRoots, abortSignal);
|
||||
|
||||
switch (sandbox) {
|
||||
case SandboxType.NONE: {
|
||||
// SandboxType.NONE uses the raw exec implementation.
|
||||
return rawExec(cmd, opts, config, abortSignal);
|
||||
}
|
||||
case SandboxType.MACOS_SEATBELT: {
|
||||
// Merge default writable roots with any user-specified ones.
|
||||
const writableRoots = [
|
||||
process.cwd(),
|
||||
os.tmpdir(),
|
||||
...additionalWritableRoots,
|
||||
];
|
||||
return execWithSeatbelt(cmd, opts, writableRoots, config, abortSignal);
|
||||
}
|
||||
case SandboxType.LINUX_LANDLOCK: {
|
||||
return execWithLandlock(
|
||||
cmd,
|
||||
opts,
|
||||
additionalWritableRoots,
|
||||
config,
|
||||
abortSignal,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function execApplyPatch(
|
||||
patchText: string,
|
||||
workdir: string | undefined = undefined,
|
||||
): ExecResult {
|
||||
// This is a temporary measure to understand what are the common base commands
|
||||
// until we start persisting and uploading rollouts
|
||||
// This find/replace is required from some models like 4.1 where the patch
|
||||
// text is wrapped in quotes that breaks the apply_patch command.
|
||||
let applyPatchInput = patchText
|
||||
.replace(/('|")?<<('|")EOF('|")/, "")
|
||||
.replace(/\*\*\* End Patch\nEOF('|")?/, "*** End Patch")
|
||||
.trim();
|
||||
|
||||
if (!applyPatchInput.endsWith(PATCH_SUFFIX)) {
|
||||
applyPatchInput += "\n" + PATCH_SUFFIX;
|
||||
}
|
||||
|
||||
log(`Applying patch: \`\`\`${applyPatchInput}\`\`\`\n\n`);
|
||||
|
||||
try {
|
||||
const result = process_patch(
|
||||
patchText,
|
||||
applyPatchInput,
|
||||
(p) => fs.readFileSync(resolvePathAgainstWorkdir(p, workdir), "utf8"),
|
||||
(p, c) => {
|
||||
const resolvedPath = resolvePathAgainstWorkdir(p, workdir);
|
||||
|
||||
@@ -94,6 +94,7 @@ export async function handleExecCommand(
|
||||
/* applyPatch */ undefined,
|
||||
/* runInSandbox */ false,
|
||||
additionalWritableRoots,
|
||||
config,
|
||||
abortSignal,
|
||||
).then(convertSummaryToResult);
|
||||
}
|
||||
@@ -142,6 +143,7 @@ export async function handleExecCommand(
|
||||
applyPatch,
|
||||
runInSandbox,
|
||||
additionalWritableRoots,
|
||||
config,
|
||||
abortSignal,
|
||||
);
|
||||
// If the operation was aborted in the meantime, propagate the cancellation
|
||||
@@ -179,6 +181,7 @@ export async function handleExecCommand(
|
||||
applyPatch,
|
||||
false,
|
||||
additionalWritableRoots,
|
||||
config,
|
||||
abortSignal,
|
||||
);
|
||||
return convertSummaryToResult(summary);
|
||||
@@ -213,6 +216,7 @@ async function execCommand(
|
||||
applyPatchCommand: ApplyPatchCommand | undefined,
|
||||
runInSandbox: boolean,
|
||||
additionalWritableRoots: ReadonlyArray<string>,
|
||||
config: AppConfig,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecCommandSummary> {
|
||||
let { workdir } = execInput;
|
||||
@@ -252,6 +256,7 @@ async function execCommand(
|
||||
: await exec(
|
||||
{ ...execInput, additionalWritableRoots },
|
||||
await getSandbox(runInSandbox),
|
||||
config,
|
||||
abortSignal,
|
||||
);
|
||||
const duration = Date.now() - start;
|
||||
@@ -303,6 +308,11 @@ async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
|
||||
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
|
||||
);
|
||||
}
|
||||
} else if (process.platform === "linux") {
|
||||
// TODO: Need to verify that the Landlock sandbox is working. For example,
|
||||
// using Landlock in a Linux Docker container from a macOS host may not
|
||||
// work.
|
||||
return SandboxType.LINUX_LANDLOCK;
|
||||
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
|
||||
// Allow running without a sandbox if the user has explicitly marked the
|
||||
// environment as already being sufficiently locked-down.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Maximum output cap: either MAX_OUTPUT_LINES lines or MAX_OUTPUT_BYTES bytes,
|
||||
// whichever limit is reached first.
|
||||
const MAX_OUTPUT_BYTES = 1024 * 10; // 10 KB
|
||||
const MAX_OUTPUT_LINES = 256;
|
||||
import { DEFAULT_SHELL_MAX_BYTES, DEFAULT_SHELL_MAX_LINES } from "../../config";
|
||||
|
||||
/**
|
||||
* Creates a collector that accumulates data Buffers from a stream up to
|
||||
@@ -10,8 +9,8 @@ const MAX_OUTPUT_LINES = 256;
|
||||
*/
|
||||
export function createTruncatingCollector(
|
||||
stream: NodeJS.ReadableStream,
|
||||
byteLimit: number = MAX_OUTPUT_BYTES,
|
||||
lineLimit: number = MAX_OUTPUT_LINES,
|
||||
byteLimit: number = DEFAULT_SHELL_MAX_BYTES,
|
||||
lineLimit: number = DEFAULT_SHELL_MAX_LINES,
|
||||
): {
|
||||
getString: () => string;
|
||||
hit: boolean;
|
||||
|
||||
175
codex-cli/src/utils/agent/sandbox/landlock.ts
Normal file
175
codex-cli/src/utils/agent/sandbox/landlock.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { ExecResult } from "./interface.js";
|
||||
import type { AppConfig } from "../../config.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
|
||||
import { exec } from "./raw-exec.js";
|
||||
import { execFile } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { log } from "src/utils/logger/log.js";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
/**
|
||||
* Runs Landlock with the following permissions:
|
||||
* - can read any file on disk
|
||||
* - can write to process.cwd()
|
||||
* - can write to the platform user temp folder
|
||||
* - can write to any user-provided writable root
|
||||
*/
|
||||
export async function execWithLandlock(
|
||||
cmd: Array<string>,
|
||||
opts: SpawnOptions,
|
||||
userProvidedWritableRoots: ReadonlyArray<string>,
|
||||
config: AppConfig,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
const sandboxExecutable = await getSandboxExecutable();
|
||||
|
||||
const extraSandboxPermissions = userProvidedWritableRoots.flatMap(
|
||||
(root: string) => ["--sandbox-permission", `disk-write-folder=${root}`],
|
||||
);
|
||||
|
||||
const fullCommand = [
|
||||
sandboxExecutable,
|
||||
"--sandbox-permission",
|
||||
"disk-full-read-access",
|
||||
|
||||
"--sandbox-permission",
|
||||
"disk-write-cwd",
|
||||
|
||||
"--sandbox-permission",
|
||||
"disk-write-platform-user-temp-folder",
|
||||
|
||||
...extraSandboxPermissions,
|
||||
|
||||
"--",
|
||||
...cmd,
|
||||
];
|
||||
|
||||
return exec(fullCommand, opts, config, abortSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily initialized promise that resolves to the absolute path of the
|
||||
* architecture-specific Landlock helper binary.
|
||||
*/
|
||||
let sandboxExecutablePromise: Promise<string> | null = null;
|
||||
|
||||
async function detectSandboxExecutable(): Promise<string> {
|
||||
// Find the executable relative to the package.json file.
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
let dir: string = path.dirname(__filename);
|
||||
|
||||
// Ascend until package.json is found or we reach the filesystem root.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.promises.access(
|
||||
path.join(dir, "package.json"),
|
||||
fs.constants.F_OK,
|
||||
);
|
||||
break; // Found the package.json ⇒ dir is our project root.
|
||||
} catch {
|
||||
// keep searching
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error("Unable to locate package.json");
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
const sandboxExecutable = getLinuxSandboxExecutableForCurrentArchitecture();
|
||||
const candidate = path.join(dir, "bin", sandboxExecutable);
|
||||
try {
|
||||
await fs.promises.access(candidate, fs.constants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`${candidate} not found or not executable`);
|
||||
}
|
||||
|
||||
// Will throw if the executable is not working in this environment.
|
||||
await verifySandboxExecutable(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const ERROR_WHEN_LANDLOCK_NOT_SUPPORTED = `\
|
||||
The combination of seccomp/landlock that Codex uses for sandboxing is not
|
||||
supported in this environment.
|
||||
|
||||
If you are running in a Docker container, you may want to try adding
|
||||
restrictions to your Docker container such that it provides your desired
|
||||
sandboxing guarantees and then run Codex with the
|
||||
--dangerously-auto-approve-everything option inside the container.
|
||||
|
||||
If you are running on an older Linux kernel that does not support newer
|
||||
features of seccomp/landlock, you will have to update your kernel to a newer
|
||||
version.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Now that we have the path to the executable, make sure that it works in
|
||||
* this environment. For example, when running a Linux Docker container from
|
||||
* macOS like so:
|
||||
*
|
||||
* docker run -it alpine:latest /bin/sh
|
||||
*
|
||||
* Running `codex-linux-sandbox-x64 -- true` in the container fails with:
|
||||
*
|
||||
* ```
|
||||
* Error: sandbox error: seccomp setup error
|
||||
*
|
||||
* Caused by:
|
||||
* 0: seccomp setup error
|
||||
* 1: Error calling `seccomp`: Invalid argument (os error 22)
|
||||
* 2: Invalid argument (os error 22)
|
||||
* ```
|
||||
*/
|
||||
function verifySandboxExecutable(sandboxExecutable: string): Promise<void> {
|
||||
// Note we are running `true` rather than `bash -lc true` because we want to
|
||||
// ensure we run an executable, not a shell built-in. Note that `true` should
|
||||
// always be available in a POSIX environment.
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ["--", "true"];
|
||||
execFile(sandboxExecutable, args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
log(
|
||||
`Sandbox check failed for ${sandboxExecutable} ${args.join(" ")}: ${error}`,
|
||||
);
|
||||
log(`stdout: ${stdout}`);
|
||||
log(`stderr: ${stderr}`);
|
||||
reject(new Error(ERROR_WHEN_LANDLOCK_NOT_SUPPORTED));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the architecture-specific Landlock helper
|
||||
* binary. (Could be a rejected promise if not found.)
|
||||
*/
|
||||
function getSandboxExecutable(): Promise<string> {
|
||||
if (!sandboxExecutablePromise) {
|
||||
sandboxExecutablePromise = detectSandboxExecutable();
|
||||
}
|
||||
|
||||
return sandboxExecutablePromise;
|
||||
}
|
||||
|
||||
/** @return name of the native executable to use for Linux sandboxing. */
|
||||
function getLinuxSandboxExecutableForCurrentArchitecture(): string {
|
||||
switch (process.arch) {
|
||||
case "arm64":
|
||||
return "codex-linux-sandbox-arm64";
|
||||
case "x64":
|
||||
return "codex-linux-sandbox-x64";
|
||||
// Fall back to the x86_64 build for anything else – it will obviously
|
||||
// fail on incompatible systems but gives a sane error message rather
|
||||
// than crashing earlier.
|
||||
default:
|
||||
return "codex-linux-sandbox-x64";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecResult } from "./interface.js";
|
||||
import type { AppConfig } from "../../config.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
|
||||
import { exec } from "./raw-exec.js";
|
||||
@@ -24,6 +25,7 @@ export function execWithSeatbelt(
|
||||
cmd: Array<string>,
|
||||
opts: SpawnOptions,
|
||||
writableRoots: ReadonlyArray<string>,
|
||||
config: AppConfig,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
let scopedWritePolicy: string;
|
||||
@@ -72,7 +74,7 @@ export function execWithSeatbelt(
|
||||
"--",
|
||||
...cmd,
|
||||
];
|
||||
return exec(fullCommand, opts, writableRoots, abortSignal);
|
||||
return exec(fullCommand, opts, config, abortSignal);
|
||||
}
|
||||
|
||||
const READ_ONLY_SEATBELT_POLICY = `
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecResult } from "./interface";
|
||||
import type { AppConfig } from "../../config";
|
||||
import type {
|
||||
ChildProcess,
|
||||
SpawnOptions,
|
||||
@@ -20,7 +21,7 @@ import * as os from "os";
|
||||
export function exec(
|
||||
command: Array<string>,
|
||||
options: SpawnOptions,
|
||||
_writableRoots: ReadonlyArray<string>,
|
||||
config: AppConfig,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
// Adapt command for the current platform (e.g., convert 'ls' to 'dir' on Windows)
|
||||
@@ -143,9 +144,21 @@ export function exec(
|
||||
// ExecResult object so the rest of the agent loop can carry on gracefully.
|
||||
|
||||
return new Promise<ExecResult>((resolve) => {
|
||||
// Get shell output limits from config if available
|
||||
const maxBytes = config?.tools?.shell?.maxBytes;
|
||||
const maxLines = config?.tools?.shell?.maxLines;
|
||||
|
||||
// Collect stdout and stderr up to configured limits.
|
||||
const stdoutCollector = createTruncatingCollector(child.stdout!);
|
||||
const stderrCollector = createTruncatingCollector(child.stderr!);
|
||||
const stdoutCollector = createTruncatingCollector(
|
||||
child.stdout!,
|
||||
maxBytes,
|
||||
maxLines,
|
||||
);
|
||||
const stderrCollector = createTruncatingCollector(
|
||||
child.stderr!,
|
||||
maxBytes,
|
||||
maxLines,
|
||||
);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const stdout = stdoutCollector.getString();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentName } from "package-manager-detector";
|
||||
|
||||
import { detectInstallerByPath } from "./package-manager-detector";
|
||||
import { CLI_VERSION } from "./session";
|
||||
import { CLI_VERSION } from "../version";
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
import { getLatestVersion } from "fast-npm-meta";
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { AppConfig } from "./config.js";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { getBaseUrl, getApiKey } from "./config.js";
|
||||
import OpenAI from "openai";
|
||||
import { createOpenAIClient } from "./openai-client.js";
|
||||
|
||||
/**
|
||||
* Generate a condensed summary of the conversation items.
|
||||
* @param items The list of conversation items to summarize
|
||||
* @param model The model to use for generating the summary
|
||||
* @param flexMode Whether to use the flex-mode service tier
|
||||
* @param config The configuration object
|
||||
* @returns A concise structured summary string
|
||||
*/
|
||||
/**
|
||||
@@ -23,10 +25,7 @@ export async function generateCompactSummary(
|
||||
flexMode = false,
|
||||
config: AppConfig,
|
||||
): Promise<string> {
|
||||
const oai = new OpenAI({
|
||||
apiKey: getApiKey(config.provider),
|
||||
baseURL: getBaseUrl(config.provider),
|
||||
});
|
||||
const oai = createOpenAIClient(config);
|
||||
|
||||
const conversationText = items
|
||||
.filter(
|
||||
|
||||
@@ -43,11 +43,15 @@ if (!isVitest) {
|
||||
loadDotenv({ path: USER_WIDE_CONFIG_PATH });
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENTIC_MODEL = "o4-mini";
|
||||
export const DEFAULT_AGENTIC_MODEL = "codex-mini-latest";
|
||||
export const DEFAULT_FULL_CONTEXT_MODEL = "gpt-4.1";
|
||||
export const DEFAULT_APPROVAL_MODE = AutoApprovalMode.SUGGEST;
|
||||
export const DEFAULT_INSTRUCTIONS = "";
|
||||
|
||||
// Default shell output limits
|
||||
export const DEFAULT_SHELL_MAX_BYTES = 1024 * 10; // 10 KB
|
||||
export const DEFAULT_SHELL_MAX_LINES = 256;
|
||||
|
||||
export const CONFIG_DIR = join(homedir(), ".codex");
|
||||
export const CONFIG_JSON_FILEPATH = join(CONFIG_DIR, "config.json");
|
||||
export const CONFIG_YAML_FILEPATH = join(CONFIG_DIR, "config.yaml");
|
||||
@@ -64,12 +68,15 @@ export const OPENAI_TIMEOUT_MS =
|
||||
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";
|
||||
|
||||
export const DEFAULT_REASONING_EFFORT = "high";
|
||||
export const OPENAI_ORGANIZATION = process.env["OPENAI_ORGANIZATION"] || "";
|
||||
export const OPENAI_PROJECT = process.env["OPENAI_PROJECT"] || "";
|
||||
|
||||
// Can be set `true` when Codex is running in an environment that is marked as already
|
||||
// considered sufficiently locked-down so that we allow running wihtout an explicit sandbox.
|
||||
// considered sufficiently locked-down so that we allow running without an explicit sandbox.
|
||||
export const CODEX_UNSAFE_ALLOW_NO_SANDBOX = Boolean(
|
||||
process.env["CODEX_UNSAFE_ALLOW_NO_SANDBOX"] || "",
|
||||
);
|
||||
@@ -113,7 +120,7 @@ export function getApiKey(provider: string = "openai"): string | undefined {
|
||||
return process.env[providerInfo.envKey];
|
||||
}
|
||||
|
||||
// Checking `PROVIDER_API_KEY feels more intuitive with a custom provider.
|
||||
// Checking `PROVIDER_API_KEY` feels more intuitive with a custom provider.
|
||||
const customApiKey = process.env[`${provider.toUpperCase()}_API_KEY`];
|
||||
if (customApiKey) {
|
||||
return customApiKey;
|
||||
@@ -128,6 +135,8 @@ export function getApiKey(provider: string = "openai"): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type FileOpenerScheme = "vscode" | "cursor" | "windsurf";
|
||||
|
||||
// Represents config as persisted in config.json.
|
||||
export type StoredConfig = {
|
||||
model?: string;
|
||||
@@ -139,15 +148,28 @@ export type StoredConfig = {
|
||||
notify?: boolean;
|
||||
/** Disable server-side response storage (send full transcript each request) */
|
||||
disableResponseStorage?: boolean;
|
||||
flexMode?: boolean;
|
||||
providers?: Record<string, { name: string; baseURL: string; envKey: string }>;
|
||||
history?: {
|
||||
maxSize?: number;
|
||||
saveHistory?: boolean;
|
||||
sensitivePatterns?: Array<string>;
|
||||
};
|
||||
tools?: {
|
||||
shell?: {
|
||||
maxBytes?: number;
|
||||
maxLines?: number;
|
||||
};
|
||||
};
|
||||
/** User-defined safe commands */
|
||||
safeCommands?: Array<string>;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
|
||||
/**
|
||||
* URI-based file opener. This is used when linking code references in
|
||||
* terminal output.
|
||||
*/
|
||||
fileOpener?: FileOpenerScheme;
|
||||
};
|
||||
|
||||
// Minimal config written on first run. An *empty* model string ensures that
|
||||
@@ -186,18 +208,35 @@ export type AppConfig = {
|
||||
saveHistory: boolean;
|
||||
sensitivePatterns: Array<string>;
|
||||
};
|
||||
tools?: {
|
||||
shell?: {
|
||||
maxBytes: number;
|
||||
maxLines: number;
|
||||
};
|
||||
};
|
||||
fileOpener?: FileOpenerScheme;
|
||||
};
|
||||
|
||||
// Formatting (quiet mode-only).
|
||||
export const PRETTY_PRINT = Boolean(process.env["PRETTY_PRINT"] || "");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project doc support (codex.md)
|
||||
// Project doc support (AGENTS.md / codex.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PROJECT_DOC_MAX_BYTES = 32 * 1024; // 32 kB
|
||||
|
||||
const PROJECT_DOC_FILENAMES = ["codex.md", ".codex.md", "CODEX.md"];
|
||||
// We support multiple filenames for project-level agent instructions. As of
|
||||
// 2025 the recommended convention is to use `AGENTS.md`, however we keep
|
||||
// the legacy `codex.md` variants for backwards-compatibility so that existing
|
||||
// repositories continue to work without changes. The list is ordered so that
|
||||
// the first match wins – newer conventions first, older fallbacks later.
|
||||
const PROJECT_DOC_FILENAMES = [
|
||||
"AGENTS.md", // preferred
|
||||
"codex.md", // legacy
|
||||
".codex.md",
|
||||
"CODEX.md",
|
||||
];
|
||||
const PROJECT_DOC_SEPARATOR = "\n\n--- project-doc ---\n\n";
|
||||
|
||||
export function discoverProjectDocPath(startDir: string): string | null {
|
||||
@@ -238,7 +277,8 @@ export function discoverProjectDocPath(startDir: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the project documentation markdown (codex.md) if present. If the file
|
||||
* Load the project documentation markdown (`AGENTS.md` – or the legacy
|
||||
* `codex.md`) if present. If the file
|
||||
* exceeds {@link PROJECT_DOC_MAX_BYTES} it will be truncated and a warning is
|
||||
* logged.
|
||||
*
|
||||
@@ -388,8 +428,17 @@ export const loadConfig = (
|
||||
instructions: combinedInstructions,
|
||||
notify: storedConfig.notify === true,
|
||||
approvalMode: storedConfig.approvalMode,
|
||||
tools: {
|
||||
shell: {
|
||||
maxBytes:
|
||||
storedConfig.tools?.shell?.maxBytes ?? DEFAULT_SHELL_MAX_BYTES,
|
||||
maxLines:
|
||||
storedConfig.tools?.shell?.maxLines ?? DEFAULT_SHELL_MAX_LINES,
|
||||
},
|
||||
},
|
||||
disableResponseStorage: storedConfig.disableResponseStorage === true,
|
||||
reasoningEffort: storedConfig.reasoningEffort,
|
||||
fileOpener: storedConfig.fileOpener,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -451,6 +500,10 @@ export const loadConfig = (
|
||||
}
|
||||
// Notification setting: enable desktop notifications when set in config
|
||||
config.notify = storedConfig.notify === true;
|
||||
// Flex-mode setting: enable the flex-mode service tier when set in config
|
||||
if (storedConfig.flexMode !== undefined) {
|
||||
config.flexMode = storedConfig.flexMode;
|
||||
}
|
||||
|
||||
// Add default history config if not provided
|
||||
if (storedConfig.history !== undefined) {
|
||||
@@ -505,6 +558,7 @@ export const saveConfig = (
|
||||
providers: config.providers,
|
||||
approvalMode: config.approvalMode,
|
||||
disableResponseStorage: config.disableResponseStorage,
|
||||
flexMode: config.flexMode,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
};
|
||||
|
||||
@@ -517,6 +571,18 @@ export const saveConfig = (
|
||||
};
|
||||
}
|
||||
|
||||
// Add tools settings if they exist
|
||||
if (config.tools) {
|
||||
configToSave.tools = {
|
||||
shell: config.tools.shell
|
||||
? {
|
||||
maxBytes: config.tools.shell.maxBytes,
|
||||
maxLines: config.tools.shell.maxLines,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (ext === ".yaml" || ext === ".yml") {
|
||||
writeFileSync(targetPath, dumpYaml(configToSave), "utf-8");
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,24 @@ import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
|
||||
/**
|
||||
* Represents a file system suggestion with path and directory information
|
||||
*/
|
||||
export interface FileSystemSuggestion {
|
||||
/** The full path of the suggestion */
|
||||
path: string;
|
||||
/** Whether the suggestion is a directory */
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets file system suggestions based on a path prefix
|
||||
* @param pathPrefix The path prefix to search for
|
||||
* @returns Array of file system suggestions
|
||||
*/
|
||||
export function getFileSystemSuggestions(
|
||||
pathPrefix: string,
|
||||
): Array<FileSystemSuggestion> {
|
||||
if (!pathPrefix) {
|
||||
return [];
|
||||
}
|
||||
@@ -31,10 +48,10 @@ export function getFileSystemSuggestions(pathPrefix: string): Array<string> {
|
||||
.map((item) => {
|
||||
const fullPath = path.join(readDir, item);
|
||||
const isDirectory = fs.statSync(fullPath).isDirectory();
|
||||
if (isDirectory) {
|
||||
return path.join(fullPath, sep);
|
||||
}
|
||||
return fullPath;
|
||||
return {
|
||||
path: isDirectory ? path.join(fullPath, sep) : fullPath,
|
||||
isDirectory,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
62
codex-cli/src/utils/file-tag-utils.ts
Normal file
62
codex-cli/src/utils/file-tag-utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Replaces @path tokens in the input string with <path>file contents</path> XML blocks for LLM context.
|
||||
* Only replaces if the path points to a file; directories are ignored.
|
||||
*/
|
||||
export async function expandFileTags(raw: string): Promise<string> {
|
||||
const re = /@([\w./~-]+)/g;
|
||||
let out = raw;
|
||||
type MatchInfo = { index: number; length: number; path: string };
|
||||
const matches: Array<MatchInfo> = [];
|
||||
|
||||
for (const m of raw.matchAll(re) as IterableIterator<RegExpMatchArray>) {
|
||||
const idx = m.index;
|
||||
const captured = m[1];
|
||||
if (idx !== undefined && captured) {
|
||||
matches.push({ index: idx, length: m[0].length, path: captured });
|
||||
}
|
||||
}
|
||||
|
||||
// Process in reverse to avoid index shifting.
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const { index, length, path: p } = matches[i]!;
|
||||
const resolved = path.resolve(process.cwd(), p);
|
||||
try {
|
||||
const st = fs.statSync(resolved);
|
||||
if (st.isFile()) {
|
||||
const content = fs.readFileSync(resolved, "utf-8");
|
||||
const rel = path.relative(process.cwd(), resolved);
|
||||
const xml = `<${rel}>\n${content}\n</${rel}>`;
|
||||
out = out.slice(0, index) + xml + out.slice(index + length);
|
||||
}
|
||||
} catch {
|
||||
// If path invalid, leave token as is
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses <path>content</path> XML blocks back to @path format.
|
||||
* This is the reverse operation of expandFileTags.
|
||||
* Only collapses blocks where the path points to a valid file; invalid paths remain unchanged.
|
||||
*/
|
||||
export function collapseXmlBlocks(text: string): string {
|
||||
return text.replace(
|
||||
/<([^\n>]+)>([\s\S]*?)<\/\1>/g,
|
||||
(match, path1: string) => {
|
||||
const filePath = path.normalize(path1.trim());
|
||||
|
||||
try {
|
||||
// Only convert to @path format if it's a valid file
|
||||
return fs.statSync(path.resolve(process.cwd(), filePath)).isFile()
|
||||
? "@" + filePath
|
||||
: match;
|
||||
} catch {
|
||||
return match; // Keep XML block if path is invalid
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
75
codex-cli/src/utils/get-api-key-components.tsx
Normal file
75
codex-cli/src/utils/get-api-key-components.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import SelectInput from "../components/select-input/select-input.js";
|
||||
import Spinner from "../components/vendor/ink-spinner.js";
|
||||
import TextInput from "../components/vendor/ink-text-input.js";
|
||||
import { Box, Text } from "ink";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export type Choice = { type: "signin" } | { type: "apikey"; key: string };
|
||||
|
||||
export function ApiKeyPrompt({
|
||||
onDone,
|
||||
}: {
|
||||
onDone: (choice: Choice) => void;
|
||||
}): JSX.Element {
|
||||
const [step, setStep] = useState<"select" | "paste">("select");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
if (step === "select") {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
Sign in with ChatGPT to generate an API key or paste one you already
|
||||
have.
|
||||
</Text>
|
||||
<Text dimColor>[use arrows to move, enter to select]</Text>
|
||||
</Box>
|
||||
<SelectInput
|
||||
items={[
|
||||
{ label: "Sign in with ChatGPT", value: "signin" },
|
||||
{
|
||||
label: "Paste an API key (or set as OPENAI_API_KEY)",
|
||||
value: "paste",
|
||||
},
|
||||
]}
|
||||
onSelect={(item: { value: string }) => {
|
||||
if (item.value === "signin") {
|
||||
onDone({ type: "signin" });
|
||||
} else {
|
||||
setStep("paste");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>Paste your OpenAI API key and press <Enter>:</Text>
|
||||
<TextInput
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
onSubmit={(value: string) => {
|
||||
if (value.trim() !== "") {
|
||||
onDone({ type: "apikey", key: value.trim() });
|
||||
}
|
||||
}}
|
||||
placeholder="sk-..."
|
||||
mask="*"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function WaitingForAuth(): JSX.Element {
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Spinner type="ball" />
|
||||
<Text>
|
||||
{" "}
|
||||
Waiting for authentication… <Text dimColor>ctrl + c to quit</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
764
codex-cli/src/utils/get-api-key.tsx
Normal file
764
codex-cli/src/utils/get-api-key.tsx
Normal file
@@ -0,0 +1,764 @@
|
||||
import type { Choice } from "./get-api-key-components";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { ApiKeyPrompt, WaitingForAuth } from "./get-api-key-components";
|
||||
import chalk from "chalk";
|
||||
import express from "express";
|
||||
import fs from "fs/promises";
|
||||
import { render } from "ink";
|
||||
import crypto from "node:crypto";
|
||||
import { URL } from "node:url";
|
||||
import open from "open";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
|
||||
function promptUserForChoice(): Promise<Choice> {
|
||||
return new Promise<Choice>((resolve) => {
|
||||
const instance = render(
|
||||
<ApiKeyPrompt
|
||||
onDone={(choice: Choice) => {
|
||||
resolve(choice);
|
||||
instance.unmount();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface OidcConfiguration {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
}
|
||||
|
||||
async function getOidcConfiguration(
|
||||
issuer: string,
|
||||
): Promise<OidcConfiguration> {
|
||||
const discoveryUrl = new URL(issuer);
|
||||
discoveryUrl.pathname = "/.well-known/openid-configuration";
|
||||
|
||||
if (issuer === "https://auth.openai.com") {
|
||||
// Account for legacy quirk in production tenant
|
||||
discoveryUrl.pathname = "/v2.0" + discoveryUrl.pathname;
|
||||
}
|
||||
|
||||
const res = await fetch(discoveryUrl.toString());
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch OIDC configuration");
|
||||
}
|
||||
return (await res.json()) as OidcConfiguration;
|
||||
}
|
||||
|
||||
interface IDTokenClaims {
|
||||
"exp": number;
|
||||
"https://api.openai.com/auth": {
|
||||
organization_id: string;
|
||||
project_id: string;
|
||||
completed_platform_onboarding: boolean;
|
||||
is_org_owner: boolean;
|
||||
chatgpt_subscription_active_start: string;
|
||||
chatgpt_subscription_active_until: string;
|
||||
chatgpt_plan_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AccessTokenClaims {
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_plan_type: string;
|
||||
};
|
||||
}
|
||||
|
||||
function generatePKCECodes(): {
|
||||
code_verifier: string;
|
||||
code_challenge: string;
|
||||
} {
|
||||
const code_verifier = crypto.randomBytes(64).toString("hex");
|
||||
const code_challenge = crypto
|
||||
.createHash("sha256")
|
||||
.update(code_verifier)
|
||||
.digest("base64url");
|
||||
return { code_verifier, code_challenge };
|
||||
}
|
||||
|
||||
async function maybeRedeemCredits(
|
||||
issuer: string,
|
||||
clientId: string,
|
||||
refreshToken: string,
|
||||
idToken?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let currentIdToken = idToken;
|
||||
let idClaims: IDTokenClaims | undefined;
|
||||
|
||||
if (
|
||||
currentIdToken &&
|
||||
typeof currentIdToken === "string" &&
|
||||
currentIdToken.split(".")[1]
|
||||
) {
|
||||
idClaims = JSON.parse(
|
||||
Buffer.from(currentIdToken.split(".")[1]!, "base64url").toString(
|
||||
"utf8",
|
||||
),
|
||||
) as IDTokenClaims;
|
||||
} else {
|
||||
currentIdToken = "";
|
||||
}
|
||||
|
||||
// Validate idToken expiration
|
||||
// if expired, attempt token-exchange for a fresh idToken
|
||||
if (!idClaims || !idClaims.exp || Date.now() >= idClaims.exp * 1000) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.dim("Refreshing credentials..."));
|
||||
try {
|
||||
const refreshRes = await fetch("https://auth.openai.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
scope: "openid profile email",
|
||||
}),
|
||||
});
|
||||
if (!refreshRes.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Failed to refresh credentials: ${refreshRes.status} ${refreshRes.statusText}\n${chalk.dim(await refreshRes.text())}`,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Please sign in again to redeem credits: ${chalk.bold("codex --login")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const refreshData = (await refreshRes.json()) as {
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
};
|
||||
currentIdToken = refreshData.id_token;
|
||||
idClaims = JSON.parse(
|
||||
Buffer.from(currentIdToken.split(".")[1]!, "base64url").toString(
|
||||
"utf8",
|
||||
),
|
||||
) as IDTokenClaims;
|
||||
if (refreshData.refresh_token) {
|
||||
try {
|
||||
const home = os.homedir();
|
||||
const authDir = path.join(home, ".codex");
|
||||
const authFile = path.join(authDir, "auth.json");
|
||||
const existingJson = JSON.parse(
|
||||
await fs.readFile(authFile, "utf-8"),
|
||||
);
|
||||
existingJson.tokens.id_token = currentIdToken;
|
||||
existingJson.tokens.refresh_token = refreshData.refresh_token;
|
||||
existingJson.last_refresh = new Date().toISOString();
|
||||
await fs.writeFile(
|
||||
authFile,
|
||||
JSON.stringify(existingJson, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Unable to update refresh token in auth file:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Unable to refresh ID token via token-exchange:", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm the subscription is active for more than 7 days
|
||||
const subStart =
|
||||
idClaims["https://api.openai.com/auth"]
|
||||
?.chatgpt_subscription_active_start;
|
||||
if (
|
||||
typeof subStart === "string" &&
|
||||
Date.now() - new Date(subStart).getTime() < 7 * 24 * 60 * 60 * 1000
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Sorry, your subscription must be active for more than 7 days to redeem credits.\nMore info: " +
|
||||
chalk.dim("https://help.openai.com/en/articles/11381614") +
|
||||
chalk.bold(
|
||||
"\nPlease try again on " +
|
||||
new Date(
|
||||
new Date(subStart).getTime() + 7 * 24 * 60 * 60 * 1000,
|
||||
).toLocaleDateString() +
|
||||
" " +
|
||||
new Date(
|
||||
new Date(subStart).getTime() + 7 * 24 * 60 * 60 * 1000,
|
||||
).toLocaleTimeString(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const completed = Boolean(
|
||||
idClaims["https://api.openai.com/auth"]?.completed_platform_onboarding,
|
||||
);
|
||||
const isOwner = Boolean(
|
||||
idClaims["https://api.openai.com/auth"]?.is_org_owner,
|
||||
);
|
||||
const needsSetup = !completed && isOwner;
|
||||
|
||||
const planType = idClaims["https://api.openai.com/auth"]
|
||||
?.chatgpt_plan_type as string | undefined;
|
||||
|
||||
if (needsSetup || !(planType === "plus" || planType === "pro")) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Users with Plus or Pro subscriptions can redeem free API credits.\nMore info: " +
|
||||
chalk.dim("https://help.openai.com/en/articles/11381614"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiHost =
|
||||
issuer === "https://auth.openai.com"
|
||||
? "https://api.openai.com"
|
||||
: "https://api.openai.org";
|
||||
|
||||
const redeemRes = await fetch(`${apiHost}/v1/billing/redeem_credits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id_token: currentIdToken }),
|
||||
});
|
||||
|
||||
if (!redeemRes.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Credit redemption request failed: ${redeemRes.status} ${redeemRes.statusText}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const redeemData = (await redeemRes.json()) as {
|
||||
granted_chatgpt_subscriber_api_credits?: number;
|
||||
};
|
||||
const granted = redeemData?.granted_chatgpt_subscriber_api_credits ?? 0;
|
||||
if (granted > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
chalk.green(
|
||||
`${chalk.bold(
|
||||
`Thanks for being a ChatGPT ${
|
||||
planType === "plus" ? "Plus" : "Pro"
|
||||
} subscriber!`,
|
||||
)}\nIf you haven't already redeemed, you should receive ${
|
||||
planType === "plus" ? "$5" : "$50"
|
||||
} in API credits\nCredits: ${chalk.dim(chalk.underline("https://platform.openai.com/settings/organization/billing/credit-grants"))}\nMore info: ${chalk.dim(chalk.underline("https://help.openai.com/en/articles/11381614"))}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
chalk.green(
|
||||
`It looks like no credits were granted:\n${JSON.stringify(
|
||||
redeemData,
|
||||
null,
|
||||
2,
|
||||
)}\nCredits: ${chalk.dim(
|
||||
chalk.underline(
|
||||
"https://platform.openai.com/settings/organization/billing/credit-grants",
|
||||
),
|
||||
)}\nMore info: ${chalk.dim(
|
||||
chalk.underline("https://help.openai.com/en/articles/11381614"),
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Unable to parse credit redemption response:", parseErr);
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Unable to redeem ChatGPT subscriber API credits:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCallback(
|
||||
req: Request,
|
||||
issuer: string,
|
||||
oidcConfig: OidcConfiguration,
|
||||
codeVerifier: string,
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
expectedState: string,
|
||||
): Promise<{ access_token: string; success_url: string }> {
|
||||
const state = (req.query as Record<string, string>)["state"] as
|
||||
| string
|
||||
| undefined;
|
||||
if (!state || state !== expectedState) {
|
||||
throw new Error("Invalid state parameter");
|
||||
}
|
||||
|
||||
const code = (req.query as Record<string, string>)["code"] as
|
||||
| string
|
||||
| undefined;
|
||||
if (!code) {
|
||||
throw new Error("Missing authorization code");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("grant_type", "authorization_code");
|
||||
params.append("code", code);
|
||||
params.append("redirect_uri", redirectUri);
|
||||
params.append("client_id", clientId);
|
||||
params.append("code_verifier", codeVerifier);
|
||||
|
||||
oidcConfig.token_endpoint = `${issuer}/oauth/token`;
|
||||
const tokenRes = await fetch(oidcConfig.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
throw new Error("Failed to exchange authorization code for tokens");
|
||||
}
|
||||
|
||||
const tokenData = (await tokenRes.json()) as {
|
||||
id_token: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
const idTokenParts = tokenData.id_token.split(".");
|
||||
if (idTokenParts.length !== 3) {
|
||||
throw new Error("Invalid ID token");
|
||||
}
|
||||
const accessTokenParts = tokenData.access_token.split(".");
|
||||
if (accessTokenParts.length !== 3) {
|
||||
throw new Error("Invalid access token");
|
||||
}
|
||||
|
||||
const idTokenClaims = JSON.parse(
|
||||
Buffer.from(idTokenParts[1]!, "base64url").toString("utf8"),
|
||||
) as IDTokenClaims;
|
||||
|
||||
const accessTokenClaims = JSON.parse(
|
||||
Buffer.from(accessTokenParts[1]!, "base64url").toString("utf8"),
|
||||
) as AccessTokenClaims;
|
||||
|
||||
const org_id = idTokenClaims["https://api.openai.com/auth"]?.organization_id;
|
||||
|
||||
if (!org_id) {
|
||||
throw new Error("Missing organization in id_token claims");
|
||||
}
|
||||
const project_id = idTokenClaims["https://api.openai.com/auth"]?.project_id;
|
||||
|
||||
if (!project_id) {
|
||||
throw new Error("Missing project in id_token claims");
|
||||
}
|
||||
|
||||
const randomId = crypto.randomBytes(6).toString("hex");
|
||||
const exchangeParams = new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
client_id: clientId,
|
||||
requested_token: "openai-api-key",
|
||||
subject_token: tokenData.id_token,
|
||||
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
|
||||
name: `Codex CLI [auto-generated] (${new Date().toISOString().slice(0, 10)}) [${
|
||||
randomId
|
||||
}]`,
|
||||
});
|
||||
const exchangeRes = await fetch(oidcConfig.token_endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: exchangeParams.toString(),
|
||||
});
|
||||
if (!exchangeRes.ok) {
|
||||
throw new Error(`Failed to create API key: ${await exchangeRes.text()}`);
|
||||
}
|
||||
|
||||
const exchanged = (await exchangeRes.json()) as {
|
||||
access_token: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
// Determine whether the organization still requires additional
|
||||
// setup (e.g., adding a payment method) based on the ID-token
|
||||
// claim provided by the auth service.
|
||||
const completedOnboarding = Boolean(
|
||||
idTokenClaims["https://api.openai.com/auth"]?.completed_platform_onboarding,
|
||||
);
|
||||
const chatgptPlanType =
|
||||
accessTokenClaims["https://api.openai.com/auth"]?.chatgpt_plan_type;
|
||||
const isOrgOwner = Boolean(
|
||||
idTokenClaims["https://api.openai.com/auth"]?.is_org_owner,
|
||||
);
|
||||
const needsSetup = !completedOnboarding && isOrgOwner;
|
||||
|
||||
// Build the success URL on the same host/port as the callback and
|
||||
// include the required query parameters for the front-end page.
|
||||
// console.log("Redirecting to success page");
|
||||
const successUrl = new URL("/success", redirectUri);
|
||||
if (issuer === "https://auth.openai.com") {
|
||||
successUrl.searchParams.set("platform_url", "https://platform.openai.com");
|
||||
} else {
|
||||
successUrl.searchParams.set(
|
||||
"platform_url",
|
||||
"https://platform.api.openai.org",
|
||||
);
|
||||
}
|
||||
successUrl.searchParams.set("id_token", tokenData.id_token);
|
||||
successUrl.searchParams.set("needs_setup", needsSetup ? "true" : "false");
|
||||
successUrl.searchParams.set("org_id", org_id);
|
||||
successUrl.searchParams.set("project_id", project_id);
|
||||
successUrl.searchParams.set("plan_type", chatgptPlanType);
|
||||
|
||||
try {
|
||||
const home = os.homedir();
|
||||
const authDir = path.join(home, ".codex");
|
||||
await fs.mkdir(authDir, { recursive: true });
|
||||
const authFile = path.join(authDir, "auth.json");
|
||||
const authData = {
|
||||
tokens: tokenData,
|
||||
last_refresh: new Date().toISOString(),
|
||||
OPENAI_API_KEY: exchanged.access_token,
|
||||
};
|
||||
await fs.writeFile(authFile, JSON.stringify(authData, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Unable to save auth file:", err);
|
||||
}
|
||||
|
||||
await maybeRedeemCredits(
|
||||
issuer,
|
||||
clientId,
|
||||
tokenData.refresh_token,
|
||||
tokenData.id_token,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: exchanged.access_token,
|
||||
success_url: successUrl.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const LOGIN_SUCCESS_HTML = String.raw`
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Sign into Codex CLI</title>
|
||||
<link rel="icon" href='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"%3E%3Cpath stroke="%23000" stroke-linecap="round" stroke-width="2.484" d="M22.356 19.797H17.17M9.662 12.29l1.979 3.576a.511.511 0 0 1-.005.504l-1.974 3.409M30.758 16c0 8.15-6.607 14.758-14.758 14.758-8.15 0-14.758-6.607-14.758-14.758C1.242 7.85 7.85 1.242 16 1.242c8.15 0 14.758 6.608 14.758 14.758Z"/%3E%3C/svg%3E' type="image/svg+xml">
|
||||
<style>
|
||||
.container {
|
||||
margin: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background: white;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
.inner-container {
|
||||
width: 400px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
display: inline-flex;
|
||||
}
|
||||
.content {
|
||||
align-self: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
display: flex;
|
||||
}
|
||||
.svg-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.title {
|
||||
text-align: center;
|
||||
color: var(--text-primary, #0D0D0D);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 36.40px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.setup-box {
|
||||
width: 600px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-primary, white);
|
||||
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 16px;
|
||||
outline: 1px var(--border-default, rgba(13, 13, 13, 0.10)) solid;
|
||||
outline-offset: -1px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
display: inline-flex;
|
||||
}
|
||||
.setup-content {
|
||||
flex: 1 1 0;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
display: flex;
|
||||
}
|
||||
.setup-text {
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
display: inline-flex;
|
||||
}
|
||||
.setup-title {
|
||||
align-self: stretch;
|
||||
color: var(--text-primary, #0D0D0D);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.setup-description {
|
||||
align-self: stretch;
|
||||
color: var(--text-secondary, #5D5D5D);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.redirect-box {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
}
|
||||
.close-button,
|
||||
.redirect-button {
|
||||
height: 28px;
|
||||
padding: 8px 16px;
|
||||
background: var(--interactive-bg-primary-default, #0D0D0D);
|
||||
border-radius: 999px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
}
|
||||
.close-button,
|
||||
.redirect-text {
|
||||
color: var(--interactive-label-primary-default, white);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
line-height: 20px;
|
||||
word-wrap: break-word;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="inner-container">
|
||||
<div class="content">
|
||||
<div data-svg-wrapper class="svg-wrapper">
|
||||
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.6665 28.0003C4.6665 15.1137 15.1132 4.66699 27.9998 4.66699C40.8865 4.66699 51.3332 15.1137 51.3332 28.0003C51.3332 40.887 40.8865 51.3337 27.9998 51.3337C15.1132 51.3337 4.6665 40.887 4.6665 28.0003ZM37.5093 18.5088C36.4554 17.7672 34.9999 18.0203 34.2583 19.0742L24.8508 32.4427L20.9764 28.1808C20.1095 27.2272 18.6338 27.1569 17.6803 28.0238C16.7267 28.8906 16.6565 30.3664 17.5233 31.3199L23.3566 37.7366C23.833 38.2606 24.5216 38.5399 25.2284 38.4958C25.9353 38.4517 26.5838 38.089 26.9914 37.5098L38.0747 21.7598C38.8163 20.7059 38.5632 19.2504 37.5093 18.5088Z" fill="var(--green-400, #04B84C)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="title">Signed in to Codex CLI</div>
|
||||
</div>
|
||||
<div class="close-box" style="display: none;">
|
||||
<div class="setup-description">You may now close this page</div>
|
||||
</div>
|
||||
<div class="setup-box" style="display: none;">
|
||||
<div class="setup-content">
|
||||
<div class="setup-text">
|
||||
<div class="setup-title">Finish setting up your API organization</div>
|
||||
<div class="setup-description">Add a payment method to use your organization.</div>
|
||||
</div>
|
||||
<div class="redirect-box">
|
||||
<div data-hasendicon="false" data-hasstarticon="false" data-ishovered="false" data-isinactive="false" data-ispressed="false" data-size="large" data-type="primary" class="redirect-button">
|
||||
<div class="redirect-text">Redirecting in 3s...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const needsSetup = params.get('needs_setup') === 'true';
|
||||
const platformUrl = params.get('platform_url') || 'https://platform.openai.com';
|
||||
const orgId = params.get('org_id');
|
||||
const projectId = params.get('project_id');
|
||||
const planType = params.get('plan_type');
|
||||
const idToken = params.get('id_token');
|
||||
// Show different message and optional redirect when setup is required
|
||||
if (needsSetup) {
|
||||
const setupBox = document.querySelector('.setup-box');
|
||||
setupBox.style.display = 'flex';
|
||||
const redirectUrlObj = new URL('/org-setup', platformUrl);
|
||||
redirectUrlObj.searchParams.set('p', planType);
|
||||
redirectUrlObj.searchParams.set('t', idToken);
|
||||
redirectUrlObj.searchParams.set('with_org', orgId);
|
||||
redirectUrlObj.searchParams.set('project_id', projectId);
|
||||
const redirectUrl = redirectUrlObj.toString();
|
||||
const message = document.querySelector('.redirect-text');
|
||||
let countdown = 3;
|
||||
function tick() {
|
||||
message.textContent =
|
||||
'Redirecting in ' + countdown + 's…';
|
||||
if (countdown === 0) {
|
||||
window.location.replace(redirectUrl);
|
||||
} else {
|
||||
countdown -= 1;
|
||||
setTimeout(tick, 1000);
|
||||
}
|
||||
}
|
||||
tick();
|
||||
} else {
|
||||
const closeBox = document.querySelector('.close-box');
|
||||
closeBox.style.display = 'flex';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function signInFlow(issuer: string, clientId: string): Promise<string> {
|
||||
const app = express();
|
||||
|
||||
let codeVerifier = "";
|
||||
let redirectUri = "";
|
||||
let server: ReturnType<typeof app.listen>;
|
||||
const state = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
const apiKeyPromise = new Promise<string>((resolve, reject) => {
|
||||
let _apiKey: string | undefined;
|
||||
|
||||
app.get("/success", (_req: Request, res: Response) => {
|
||||
res.type("text/html").send(LOGIN_SUCCESS_HTML);
|
||||
if (_apiKey) {
|
||||
resolve(_apiKey);
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Sorry, it seems like the authentication flow failed. Please try again, or submit an issue on our GitHub if it continues.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Callback route -------------------------------------------------------
|
||||
app.get("/auth/callback", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const oidcConfig = await getOidcConfiguration(issuer);
|
||||
oidcConfig.token_endpoint = `${issuer}/oauth/token`;
|
||||
oidcConfig.authorization_endpoint = `${issuer}/oauth/authorize`;
|
||||
const { access_token, success_url } = await handleCallback(
|
||||
req,
|
||||
issuer,
|
||||
oidcConfig,
|
||||
codeVerifier,
|
||||
clientId,
|
||||
redirectUri,
|
||||
state,
|
||||
);
|
||||
_apiKey = access_token;
|
||||
res.redirect(success_url);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server = app.listen(1455, "127.0.0.1", async () => {
|
||||
const address = server.address();
|
||||
if (typeof address === "string" || !address) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"It seems like you might already be trying to sign in (port :1455 already in use)",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
redirectUri = `http://localhost:${port}/auth/callback`;
|
||||
|
||||
try {
|
||||
const oidcConfig = await getOidcConfiguration(issuer);
|
||||
oidcConfig.token_endpoint = `${issuer}/oauth/token`;
|
||||
oidcConfig.authorization_endpoint = `${issuer}/oauth/authorize`;
|
||||
const pkce = generatePKCECodes();
|
||||
codeVerifier = pkce.code_verifier;
|
||||
|
||||
const authUrl = new URL(oidcConfig.authorization_endpoint);
|
||||
authUrl.searchParams.append("response_type", "code");
|
||||
authUrl.searchParams.append("client_id", clientId);
|
||||
authUrl.searchParams.append("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.append(
|
||||
"scope",
|
||||
"openid profile email offline_access",
|
||||
);
|
||||
authUrl.searchParams.append("code_challenge", pkce.code_challenge);
|
||||
authUrl.searchParams.append("code_challenge_method", "S256");
|
||||
authUrl.searchParams.append("id_token_add_organizations", "true");
|
||||
authUrl.searchParams.append("state", state);
|
||||
|
||||
// Open the browser immediately.
|
||||
open(authUrl.toString());
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\nOpening login page in your browser: ${authUrl.toString()}\n`,
|
||||
);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure the server is closed afterwards.
|
||||
return apiKeyPromise.finally(() => {
|
||||
if (server) {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiKey(
|
||||
issuer: string,
|
||||
clientId: string,
|
||||
forceLogin: boolean = false,
|
||||
): Promise<string> {
|
||||
if (!forceLogin && process.env["OPENAI_API_KEY"]) {
|
||||
return process.env["OPENAI_API_KEY"]!;
|
||||
}
|
||||
const choice = await promptUserForChoice();
|
||||
if (choice.type === "apikey") {
|
||||
process.env["OPENAI_API_KEY"] = choice.key;
|
||||
return choice.key;
|
||||
}
|
||||
const spinner = render(<WaitingForAuth />);
|
||||
try {
|
||||
const key = await signInFlow(issuer, clientId);
|
||||
spinner.clear();
|
||||
spinner.unmount();
|
||||
process.env["OPENAI_API_KEY"] = key;
|
||||
return key;
|
||||
} catch (err) {
|
||||
spinner.clear();
|
||||
spinner.unmount();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export { maybeRedeemCredits };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
|
||||
// The objects thrown by `child_process.execSync()` are `Error` instances that
|
||||
// include additional, undocumented properties such as `status` (exit code) and
|
||||
@@ -89,12 +89,18 @@ export function getGitDiff(): {
|
||||
//
|
||||
// `git diff --color --no-index /dev/null <file>` exits with status 1
|
||||
// when differences are found, so we capture stdout from the thrown
|
||||
// error object instead of letting it propagate.
|
||||
execSync(`git diff --color --no-index -- "${nullDevice}" "${file}"`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
// error object instead of letting it propagate. Using `execFileSync`
|
||||
// avoids shell interpolation issues with special characters in the
|
||||
// path.
|
||||
execFileSync(
|
||||
"git",
|
||||
["diff", "--color", "--no-index", "--", nullDevice, file],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
isExecSyncError(err) &&
|
||||
|
||||
@@ -19,6 +19,10 @@ export const openAiModelInfo = {
|
||||
label: "o3 (2025-04-16)",
|
||||
maxContextLength: 200000,
|
||||
},
|
||||
"codex-mini-latest": {
|
||||
label: "codex-mini-latest",
|
||||
maxContextLength: 200000,
|
||||
},
|
||||
"o4-mini": {
|
||||
label: "o4 Mini",
|
||||
maxContextLength: 200000,
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { ResponseItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { approximateTokensUsed } from "./approximate-tokens-used.js";
|
||||
import {
|
||||
OPENAI_ORGANIZATION,
|
||||
OPENAI_PROJECT,
|
||||
getBaseUrl,
|
||||
getApiKey,
|
||||
} from "./config";
|
||||
import { getApiKey } from "./config.js";
|
||||
import { type SupportedModelId, openAiModelInfo } from "./model-info.js";
|
||||
import OpenAI from "openai";
|
||||
import { createOpenAIClient } from "./openai-client.js";
|
||||
|
||||
const MODEL_LIST_TIMEOUT_MS = 2_000; // 2 seconds
|
||||
export const RECOMMENDED_MODELS: Array<string> = ["o4-mini", "o3"];
|
||||
@@ -27,19 +22,7 @@ async function fetchModels(provider: string): Promise<Array<string>> {
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (OPENAI_ORGANIZATION) {
|
||||
headers["OpenAI-Organization"] = OPENAI_ORGANIZATION;
|
||||
}
|
||||
if (OPENAI_PROJECT) {
|
||||
headers["OpenAI-Project"] = OPENAI_PROJECT;
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: getApiKey(provider),
|
||||
baseURL: getBaseUrl(provider),
|
||||
defaultHeaders: headers,
|
||||
});
|
||||
const openai = createOpenAIClient({ provider });
|
||||
const list = await openai.models.list();
|
||||
const models: Array<string> = [];
|
||||
for await (const model of list as AsyncIterable<{ id?: string }>) {
|
||||
|
||||
51
codex-cli/src/utils/openai-client.ts
Normal file
51
codex-cli/src/utils/openai-client.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { AppConfig } from "./config.js";
|
||||
|
||||
import {
|
||||
getBaseUrl,
|
||||
getApiKey,
|
||||
AZURE_OPENAI_API_VERSION,
|
||||
OPENAI_TIMEOUT_MS,
|
||||
OPENAI_ORGANIZATION,
|
||||
OPENAI_PROJECT,
|
||||
} from "./config.js";
|
||||
import OpenAI, { AzureOpenAI } from "openai";
|
||||
|
||||
type OpenAIClientConfig = {
|
||||
provider: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an OpenAI client instance based on the provided configuration.
|
||||
* Handles both standard OpenAI and Azure OpenAI configurations.
|
||||
*
|
||||
* @param config The configuration containing provider information
|
||||
* @returns An instance of either OpenAI or AzureOpenAI client
|
||||
*/
|
||||
export function createOpenAIClient(
|
||||
config: OpenAIClientConfig | AppConfig,
|
||||
): OpenAI | AzureOpenAI {
|
||||
const headers: Record<string, string> = {};
|
||||
if (OPENAI_ORGANIZATION) {
|
||||
headers["OpenAI-Organization"] = OPENAI_ORGANIZATION;
|
||||
}
|
||||
if (OPENAI_PROJECT) {
|
||||
headers["OpenAI-Project"] = OPENAI_PROJECT;
|
||||
}
|
||||
|
||||
if (config.provider?.toLowerCase() === "azure") {
|
||||
return new AzureOpenAI({
|
||||
apiKey: getApiKey(config.provider),
|
||||
baseURL: getBaseUrl(config.provider),
|
||||
apiVersion: AZURE_OPENAI_API_VERSION,
|
||||
timeout: OPENAI_TIMEOUT_MS,
|
||||
defaultHeaders: headers,
|
||||
});
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey: getApiKey(config.provider),
|
||||
baseURL: getBaseUrl(config.provider),
|
||||
timeout: OPENAI_TIMEOUT_MS,
|
||||
defaultHeaders: headers,
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export function parseToolCallOutput(toolCallOutput: string): {
|
||||
export type CommandReviewDetails = {
|
||||
cmd: Array<string>;
|
||||
cmdReadableText: string;
|
||||
workdir: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -51,12 +52,13 @@ export function parseToolCall(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { cmd } = toolCallArgs;
|
||||
const { cmd, workdir } = toolCallArgs;
|
||||
const cmdReadableText = formatCommandForDisplay(cmd);
|
||||
|
||||
return {
|
||||
cmd,
|
||||
cmdReadableText,
|
||||
workdir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ export const providers: Record<
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
envKey: "OPENROUTER_API_KEY",
|
||||
},
|
||||
azure: {
|
||||
name: "AzureOpenAI",
|
||||
baseURL: "https://YOUR_PROJECT_NAME.openai.azure.com/openai",
|
||||
envKey: "AZURE_OPENAI_API_KEY",
|
||||
},
|
||||
gemini: {
|
||||
name: "Gemini",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
@@ -42,4 +47,9 @@ export const providers: Record<
|
||||
baseURL: "https://api.groq.com/openai/v1",
|
||||
envKey: "GROQ_API_KEY",
|
||||
},
|
||||
arceeai: {
|
||||
name: "ArceeAI",
|
||||
baseURL: "https://conductor.arcee.ai/v1",
|
||||
envKey: "ARCEEAI_API_KEY",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -487,7 +487,7 @@ async function* streamResponses(
|
||||
let isToolCall = false;
|
||||
for await (const chunk of completion as AsyncIterable<OpenAI.ChatCompletionChunk>) {
|
||||
// console.error('\nCHUNK: ', JSON.stringify(chunk));
|
||||
const choice = chunk.choices[0];
|
||||
const choice = chunk.choices?.[0];
|
||||
if (!choice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const CLI_VERSION = "0.1.2504251709"; // Must be in sync with package.json.
|
||||
export const ORIGIN = "codex_cli_ts";
|
||||
|
||||
export type TerminalChatSession = {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const SLASH_COMMANDS: Array<SlashCommand> = [
|
||||
"Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]",
|
||||
},
|
||||
{ command: "/history", description: "Open command history" },
|
||||
{ command: "/sessions", description: "Browse previous sessions" },
|
||||
{ command: "/help", description: "Show list of commands" },
|
||||
{ command: "/model", description: "Open model selection panel" },
|
||||
{ command: "/approval", description: "Open approval mode selection panel" },
|
||||
|
||||
8
codex-cli/src/version.ts
Normal file
8
codex-cli/src/version.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Note that "../package.json" is marked external in build.mjs. This ensures
|
||||
// that the contents of package.json will always be read at runtime, which is
|
||||
// preferable so we do not have to make a temporary change to package.json in
|
||||
// the source tree to update the version number in the code.
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
|
||||
// Read the version directly from package.json.
|
||||
export const CLI_VERSION: string = (pkg as { version: string }).version;
|
||||
@@ -132,8 +132,6 @@ describe("cancel clears previous_response_id", () => {
|
||||
] as any);
|
||||
|
||||
const bodies = _test.getBodies();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(bodies, null, 2));
|
||||
expect(bodies.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The *last* invocation belongs to the second run (after cancellation).
|
||||
|
||||
@@ -32,6 +32,12 @@ describe("canAutoApprove()", () => {
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["nl", "-ba", "README.md"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file with line numbers",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
expect(check(["pwd"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Print working directory",
|
||||
@@ -147,4 +153,41 @@ describe("canAutoApprove()", () => {
|
||||
type: "ask-user",
|
||||
});
|
||||
});
|
||||
|
||||
test("sed", () => {
|
||||
// `sed` used to read lines from a file.
|
||||
expect(check(["sed", "-n", "1,200p", "filename.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Sed print subset",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
// Bad quoting! The model is doing the wrong thing here, so this should not
|
||||
// be auto-approved.
|
||||
expect(check(["sed", "-n", "'1,200p'", "filename.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
// Extra arg: here we are extra conservative, we do not auto-approve.
|
||||
expect(check(["sed", "-n", "1,200p", "file1.txt", "file2.txt"])).toEqual({
|
||||
type: "ask-user",
|
||||
});
|
||||
|
||||
// `sed` used to read lines from a file with a shell command.
|
||||
expect(check(["bash", "-lc", "sed -n '1,200p' filename.txt"])).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "Sed print subset",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
|
||||
// Pipe the output of `nl` to `sed`.
|
||||
expect(
|
||||
check(["bash", "-lc", "nl -ba README.md | sed -n '1,200p'"]),
|
||||
).toEqual({
|
||||
type: "auto-approve",
|
||||
reason: "View file with line numbers",
|
||||
group: "Reading files",
|
||||
runInSandbox: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { AppConfig } from "src/utils/config.js";
|
||||
|
||||
// Import the low‑level exec implementation so we can verify that AbortSignal
|
||||
// correctly terminates a spawned process. We bypass the higher‑level wrappers
|
||||
@@ -12,9 +13,13 @@ describe("exec cancellation", () => {
|
||||
// Spawn a node process that would normally run for 5 seconds before
|
||||
// printing anything. We should abort long before that happens.
|
||||
const cmd = ["node", "-e", "setTimeout(() => console.log('late'), 5000);"];
|
||||
|
||||
const config: AppConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test-instructions",
|
||||
};
|
||||
const start = Date.now();
|
||||
const promise = rawExec(cmd, {}, [], abortController.signal);
|
||||
|
||||
const promise = rawExec(cmd, {}, config, abortController.signal);
|
||||
|
||||
// Abort almost immediately.
|
||||
abortController.abort();
|
||||
@@ -36,9 +41,14 @@ describe("exec cancellation", () => {
|
||||
it("allows the process to finish when not aborted", async () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const config: AppConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test-instructions",
|
||||
};
|
||||
|
||||
const cmd = ["node", "-e", "console.log('finished')"];
|
||||
|
||||
const result = await rawExec(cmd, {}, [], abortController.signal);
|
||||
const result = await rawExec(cmd, {}, config, abortController.signal);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe("finished");
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
renderUpdateCommand,
|
||||
} from "../src/utils/check-updates";
|
||||
import { detectInstallerByPath } from "../src/utils/package-manager-detector";
|
||||
import { CLI_VERSION } from "../src/utils/session";
|
||||
import { CLI_VERSION } from "../src/version";
|
||||
|
||||
// In-memory FS mock
|
||||
let memfs: Record<string, string> = {};
|
||||
@@ -37,8 +37,8 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
|
||||
// Mock package name & CLI version
|
||||
const MOCK_PKG = "my-pkg";
|
||||
vi.mock("../src/version", () => ({ CLI_VERSION: "1.0.0" }));
|
||||
vi.mock("../package.json", () => ({ name: MOCK_PKG }));
|
||||
vi.mock("../src/utils/session", () => ({ CLI_VERSION: "1.0.0" }));
|
||||
vi.mock("../src/utils/package-manager-detector", async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("/clear command", () => {
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openDiffOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type * as fsType from "fs";
|
||||
|
||||
import { loadConfig, saveConfig } from "../src/utils/config.js"; // parent import first
|
||||
import {
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
DEFAULT_SHELL_MAX_BYTES,
|
||||
DEFAULT_SHELL_MAX_LINES,
|
||||
} from "../src/utils/config.js";
|
||||
import { AutoApprovalMode } from "../src/utils/auto-approval-mode.js";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
@@ -62,7 +67,7 @@ test("loads default config if files don't exist", () => {
|
||||
});
|
||||
// Keep the test focused on just checking that default model and instructions are loaded
|
||||
// so we need to make sure we check just these properties
|
||||
expect(config.model).toBe("o4-mini");
|
||||
expect(config.model).toBe("codex-mini-latest");
|
||||
expect(config.instructions).toBe("");
|
||||
});
|
||||
|
||||
@@ -275,3 +280,84 @@ test("handles empty user instructions when saving with project doc separator", (
|
||||
});
|
||||
expect(loadedConfig.instructions).toBe("");
|
||||
});
|
||||
|
||||
test("loads default shell config when not specified", () => {
|
||||
// Setup config without shell settings
|
||||
memfs[testConfigPath] = JSON.stringify(
|
||||
{
|
||||
model: "mymodel",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
memfs[testInstructionsPath] = "test instructions";
|
||||
|
||||
// Load config and verify default shell settings
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check shell settings were loaded with defaults
|
||||
expect(loadedConfig.tools).toBeDefined();
|
||||
expect(loadedConfig.tools?.shell).toBeDefined();
|
||||
expect(loadedConfig.tools?.shell?.maxBytes).toBe(DEFAULT_SHELL_MAX_BYTES);
|
||||
expect(loadedConfig.tools?.shell?.maxLines).toBe(DEFAULT_SHELL_MAX_LINES);
|
||||
});
|
||||
|
||||
test("loads and saves custom shell config", () => {
|
||||
// Setup config with custom shell settings
|
||||
const customMaxBytes = 12_410;
|
||||
const customMaxLines = 500;
|
||||
|
||||
memfs[testConfigPath] = JSON.stringify(
|
||||
{
|
||||
model: "mymodel",
|
||||
tools: {
|
||||
shell: {
|
||||
maxBytes: customMaxBytes,
|
||||
maxLines: customMaxLines,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
memfs[testInstructionsPath] = "test instructions";
|
||||
|
||||
// Load config and verify custom shell settings
|
||||
const loadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
// Check shell settings were loaded correctly
|
||||
expect(loadedConfig.tools?.shell?.maxBytes).toBe(customMaxBytes);
|
||||
expect(loadedConfig.tools?.shell?.maxLines).toBe(customMaxLines);
|
||||
|
||||
// Modify shell settings and save
|
||||
const updatedMaxBytes = 20_000;
|
||||
const updatedMaxLines = 1_000;
|
||||
|
||||
const updatedConfig = {
|
||||
...loadedConfig,
|
||||
tools: {
|
||||
shell: {
|
||||
maxBytes: updatedMaxBytes,
|
||||
maxLines: updatedMaxLines,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
saveConfig(updatedConfig, testConfigPath, testInstructionsPath);
|
||||
|
||||
// Verify saved config contains updated shell settings
|
||||
expect(memfs[testConfigPath]).toContain(`"maxBytes": ${updatedMaxBytes}`);
|
||||
expect(memfs[testConfigPath]).toContain(`"maxLines": ${updatedMaxLines}`);
|
||||
|
||||
// Load again and verify updated values
|
||||
const reloadedConfig = loadConfig(testConfigPath, testInstructionsPath, {
|
||||
disableProjectDoc: true,
|
||||
});
|
||||
|
||||
expect(reloadedConfig.tools?.shell?.maxBytes).toBe(updatedMaxBytes);
|
||||
expect(reloadedConfig.tools?.shell?.maxLines).toBe(updatedMaxLines);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ describe.each([
|
||||
])("AgentLoop with disableResponseStorage=%s", ({ flag, title }) => {
|
||||
/* build a fresh config for each case */
|
||||
const cfg: AppConfig = {
|
||||
model: "o4-mini",
|
||||
model: "codex-mini-latest",
|
||||
provider: "openai",
|
||||
instructions: "",
|
||||
disableResponseStorage: flag,
|
||||
|
||||
@@ -21,7 +21,10 @@ describe("disableResponseStorage persistence", () => {
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
// seed YAML with ZDR enabled
|
||||
writeFileSync(yamlPath, "model: o4-mini\ndisableResponseStorage: true\n");
|
||||
writeFileSync(
|
||||
yamlPath,
|
||||
"model: codex-mini-latest\ndisableResponseStorage: true\n",
|
||||
);
|
||||
});
|
||||
|
||||
afterAll((): void => {
|
||||
|
||||
@@ -36,8 +36,14 @@ describe("getFileSystemSuggestions", () => {
|
||||
|
||||
expect(mockFs.readdirSync).toHaveBeenCalledWith("/home/testuser");
|
||||
expect(result).toEqual([
|
||||
path.join("/home/testuser", "file1.txt"),
|
||||
path.join("/home/testuser", "docs" + path.sep),
|
||||
{
|
||||
path: path.join("/home/testuser", "file1.txt"),
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
path: path.join("/home/testuser", "docs" + path.sep),
|
||||
isDirectory: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -48,7 +54,16 @@ describe("getFileSystemSuggestions", () => {
|
||||
}));
|
||||
|
||||
const result = getFileSystemSuggestions("a");
|
||||
expect(result).toEqual(["abc.txt", "abd.txt/"]);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: "abc.txt",
|
||||
isDirectory: false,
|
||||
},
|
||||
{
|
||||
path: "abd.txt/",
|
||||
isDirectory: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles errors gracefully", () => {
|
||||
@@ -67,7 +82,11 @@ describe("getFileSystemSuggestions", () => {
|
||||
}));
|
||||
|
||||
const result = getFileSystemSuggestions("./");
|
||||
expect(result).toContain("foo/");
|
||||
expect(result).toContain("bar/");
|
||||
const paths = result.map((item) => item.path);
|
||||
const allDirectories = result.every((item) => item.isDirectory === true);
|
||||
|
||||
expect(paths).toContain("foo/");
|
||||
expect(paths).toContain("bar/");
|
||||
expect(allDirectories).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
240
codex-cli/tests/file-tag-utils.test.ts
Normal file
240
codex-cli/tests/file-tag-utils.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import {
|
||||
expandFileTags,
|
||||
collapseXmlBlocks,
|
||||
} from "../src/utils/file-tag-utils.js";
|
||||
|
||||
/**
|
||||
* Unit-tests for file tag utility functions:
|
||||
* - expandFileTags(): Replaces tokens like `@relative/path` with XML blocks containing file contents
|
||||
* - collapseXmlBlocks(): Reverses the expansion, converting XML blocks back to @path format
|
||||
*/
|
||||
|
||||
describe("expandFileTags", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-test-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
beforeAll(() => {
|
||||
// Run the test from within the temporary directory so that the helper
|
||||
// generates relative paths that are predictable and isolated.
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("replaces @file token with XML wrapped contents", async () => {
|
||||
const filename = "hello.txt";
|
||||
const fileContent = "Hello, world!";
|
||||
fs.writeFileSync(path.join(tmpDir, filename), fileContent);
|
||||
|
||||
const input = `Please read @${filename}`;
|
||||
const output = await expandFileTags(input);
|
||||
|
||||
expect(output).toContain(`<${filename}>`);
|
||||
expect(output).toContain(fileContent);
|
||||
expect(output).toContain(`</${filename}>`);
|
||||
});
|
||||
|
||||
it("leaves token unchanged when file does not exist", async () => {
|
||||
const input = "This refers to @nonexistent.file";
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toEqual(input);
|
||||
});
|
||||
|
||||
it("handles multiple @file tokens in one string", async () => {
|
||||
const fileA = "a.txt";
|
||||
const fileB = "b.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileA), "A content");
|
||||
fs.writeFileSync(path.join(tmpDir, fileB), "B content");
|
||||
const input = `@${fileA} and @${fileB}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain("A content");
|
||||
expect(output).toContain("B content");
|
||||
expect(output).toContain(`<${fileA}>`);
|
||||
expect(output).toContain(`<${fileB}>`);
|
||||
});
|
||||
|
||||
it("does not replace @dir if it's a directory", async () => {
|
||||
const dirName = "somedir";
|
||||
fs.mkdirSync(path.join(tmpDir, dirName));
|
||||
const input = `Check @${dirName}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain(`@${dirName}`);
|
||||
});
|
||||
|
||||
it("handles @file with special characters in name", async () => {
|
||||
const fileName = "weird-._~name.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "special chars");
|
||||
const input = `@${fileName}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain("special chars");
|
||||
expect(output).toContain(`<${fileName}>`);
|
||||
});
|
||||
|
||||
it("handles repeated @file tokens", async () => {
|
||||
const fileName = "repeat.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "repeat content");
|
||||
const input = `@${fileName} @${fileName}`;
|
||||
const output = await expandFileTags(input);
|
||||
// Both tags should be replaced
|
||||
expect(output.match(new RegExp(`<${fileName}>`, "g"))?.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles empty file", async () => {
|
||||
const fileName = "empty.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "");
|
||||
const input = `@${fileName}`;
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toContain(`<${fileName}>\n\n</${fileName}>`);
|
||||
});
|
||||
|
||||
it("handles string with no @file tokens", async () => {
|
||||
const input = "No tags here.";
|
||||
const output = await expandFileTags(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collapseXmlBlocks", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-collapse-test-"));
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
beforeAll(() => {
|
||||
// Run the test from within the temporary directory so that the helper
|
||||
// generates relative paths that are predictable and isolated.
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("collapses XML block to @path format for valid file", () => {
|
||||
// Create a real file
|
||||
const fileName = "valid-file.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileName), "file content");
|
||||
|
||||
const input = `<${fileName}>\nHello, world!\n</${fileName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${fileName}`);
|
||||
});
|
||||
|
||||
it("does not collapse XML block for unrelated xml block", () => {
|
||||
const xmlBlockName = "non-file-block";
|
||||
const input = `<${xmlBlockName}>\nContent here\n</${xmlBlockName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
// Should remain unchanged
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("does not collapse XML block for a directory", () => {
|
||||
// Create a directory
|
||||
const dirName = "test-dir";
|
||||
fs.mkdirSync(path.join(tmpDir, dirName), { recursive: true });
|
||||
|
||||
const input = `<${dirName}>\nThis is a directory\n</${dirName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
// Should remain unchanged
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("collapses multiple valid file XML blocks in one string", () => {
|
||||
// Create real files
|
||||
const fileA = "a.txt";
|
||||
const fileB = "b.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, fileA), "A content");
|
||||
fs.writeFileSync(path.join(tmpDir, fileB), "B content");
|
||||
|
||||
const input = `<${fileA}>\nA content\n</${fileA}> and <${fileB}>\nB content\n</${fileB}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${fileA} and @${fileB}`);
|
||||
});
|
||||
|
||||
it("only collapses valid file paths in mixed content", () => {
|
||||
// Create a real file
|
||||
const validFile = "valid.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, validFile), "valid content");
|
||||
const invalidFile = "invalid.txt";
|
||||
|
||||
const input = `<${validFile}>\nvalid content\n</${validFile}> and <${invalidFile}>\ninvalid content\n</${invalidFile}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(
|
||||
`@${validFile} and <${invalidFile}>\ninvalid content\n</${invalidFile}>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles paths with subdirectories for valid files", () => {
|
||||
// Create a nested file
|
||||
const nestedDir = "nested/path";
|
||||
const nestedFile = "nested/path/file.txt";
|
||||
fs.mkdirSync(path.join(tmpDir, nestedDir), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, nestedFile), "nested content");
|
||||
|
||||
const relPath = "nested/path/file.txt";
|
||||
const input = `<${relPath}>\nContent here\n</${relPath}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
const expectedPath = path.normalize(relPath);
|
||||
expect(output).toBe(`@${expectedPath}`);
|
||||
});
|
||||
|
||||
it("handles XML blocks with special characters in path for valid files", () => {
|
||||
// Create a file with special characters
|
||||
const specialFileName = "weird-._~name.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, specialFileName), "special chars");
|
||||
|
||||
const input = `<${specialFileName}>\nspecial chars\n</${specialFileName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${specialFileName}`);
|
||||
});
|
||||
|
||||
it("handles XML blocks with empty content for valid files", () => {
|
||||
// Create an empty file
|
||||
const emptyFileName = "empty.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, emptyFileName), "");
|
||||
|
||||
const input = `<${emptyFileName}>\n\n</${emptyFileName}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${emptyFileName}`);
|
||||
});
|
||||
|
||||
it("handles string with no XML blocks", () => {
|
||||
const input = "No tags here.";
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("handles adjacent XML blocks for valid files", () => {
|
||||
// Create real files
|
||||
const adjFile1 = "adj1.txt";
|
||||
const adjFile2 = "adj2.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, adjFile1), "adj1");
|
||||
fs.writeFileSync(path.join(tmpDir, adjFile2), "adj2");
|
||||
|
||||
const input = `<${adjFile1}>\nadj1\n</${adjFile1}><${adjFile2}>\nadj2\n</${adjFile2}>`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`@${adjFile1}@${adjFile2}`);
|
||||
});
|
||||
|
||||
it("ignores malformed XML blocks", () => {
|
||||
const input = "<incomplete>content without closing tag";
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("handles mixed content with valid file XML blocks and regular text", () => {
|
||||
// Create a real file
|
||||
const mixedFile = "mixed-file.txt";
|
||||
fs.writeFileSync(path.join(tmpDir, mixedFile), "file content");
|
||||
|
||||
const input = `This is <${mixedFile}>\nfile content\n</${mixedFile}> and some more text.`;
|
||||
const output = collapseXmlBlocks(input);
|
||||
expect(output).toBe(`This is @${mixedFile} and some more text.`);
|
||||
});
|
||||
});
|
||||
28
codex-cli/tests/get-diff-special-chars.test.ts
Normal file
28
codex-cli/tests/get-diff-special-chars.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { getGitDiff } from "../src/utils/get-diff.js";
|
||||
|
||||
describe("getGitDiff", () => {
|
||||
it("handles untracked files with special characters", () => {
|
||||
const repoDir = mkdtempSync(join(tmpdir(), "git-diff-test-"));
|
||||
const prevCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(repoDir);
|
||||
execSync("git init", { stdio: "ignore" });
|
||||
|
||||
const fileName = "a$b.txt";
|
||||
writeFileSync(join(repoDir, fileName), "hello\n");
|
||||
|
||||
const { isGitRepo, diff } = getGitDiff();
|
||||
expect(isGitRepo).toBe(true);
|
||||
expect(diff).toContain(fileName);
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,12 @@ import { describe, it, expect, vi } from "vitest";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
|
||||
|
||||
import type { AppConfig } from "../src/utils/config.js";
|
||||
describe("rawExec – invalid command handling", () => {
|
||||
it("resolves with non‑zero exit code when executable is missing", async () => {
|
||||
const cmd = ["definitely-not-a-command-1234567890"];
|
||||
|
||||
const result = await rawExec(cmd, {}, []);
|
||||
const config = { model: "any", instructions: "" } as AppConfig;
|
||||
const result = await rawExec(cmd, {}, config);
|
||||
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stderr.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -1,16 +1,172 @@
|
||||
import type { ColorSupportLevel } from "chalk";
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import { Markdown } from "../src/components/chat/terminal-chat-response-item.js";
|
||||
import React from "react";
|
||||
import { it, expect } from "vitest";
|
||||
import { describe, afterEach, beforeEach, it, expect, vi } from "vitest";
|
||||
import chalk from "chalk";
|
||||
|
||||
const BOLD = "\x1B[1m";
|
||||
const BOLD_OFF = "\x1B[22m";
|
||||
const ITALIC = "\x1B[3m";
|
||||
const ITALIC_OFF = "\x1B[23m";
|
||||
const LINK_ON = "\x1B[4m";
|
||||
const LINK_OFF = "\x1B[24m";
|
||||
const BLUE = "\x1B[34m";
|
||||
const GREEN = "\x1B[32m";
|
||||
const YELLOW = "\x1B[33m";
|
||||
const COLOR_OFF = "\x1B[39m";
|
||||
|
||||
/** Simple sanity check that the Markdown component renders bold/italic text.
|
||||
* We strip ANSI codes, so the output should contain the raw words. */
|
||||
it("renders basic markdown", () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<Markdown>**bold** _italic_</Markdown>,
|
||||
<Markdown fileOpener={undefined}>**bold** _italic_</Markdown>,
|
||||
);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("bold");
|
||||
expect(frame).toContain("italic");
|
||||
});
|
||||
|
||||
describe("ensure <Markdown> produces content with correct ANSI escape codes", () => {
|
||||
let chalkOriginalLevel: ColorSupportLevel = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
chalkOriginalLevel = chalk.level;
|
||||
chalk.level = 3;
|
||||
|
||||
vi.mock("supports-hyperlinks", () => ({
|
||||
default: {},
|
||||
supportsHyperlink: () => true,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
chalk.level = chalkOriginalLevel;
|
||||
});
|
||||
|
||||
it("renders basic markdown with ansi", () => {
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={undefined}>**bold** _italic_</Markdown>,
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBe(`${BOLD}bold${BOLD_OFF} ${ITALIC}italic${ITALIC_OFF}`);
|
||||
});
|
||||
|
||||
// We had to patch in https://github.com/mikaelbr/marked-terminal/pull/366 to
|
||||
// make this work.
|
||||
it("bold test in a bullet should be rendered correctly", () => {
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={undefined}>* **bold** text</Markdown>,
|
||||
);
|
||||
|
||||
const outputWithAnsi = lastFrame();
|
||||
expect(outputWithAnsi).toBe(`* ${BOLD}bold${BOLD_OFF} text`);
|
||||
});
|
||||
|
||||
it("ensure simple nested list works as expected", () => {
|
||||
// Empirically, if there is no text at all before the first list item,
|
||||
// it gets indented.
|
||||
const nestedList = `\
|
||||
Paragraph before bulleted list.
|
||||
|
||||
* item 1
|
||||
* subitem 1
|
||||
* subitem 2
|
||||
* item 2
|
||||
`;
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={undefined}>{nestedList}</Markdown>,
|
||||
);
|
||||
|
||||
const outputWithAnsi = lastFrame();
|
||||
const i4 = " ".repeat(4);
|
||||
const expectedNestedList = `\
|
||||
Paragraph before bulleted list.
|
||||
|
||||
${i4}* item 1
|
||||
${i4}${i4}* subitem 1
|
||||
${i4}${i4}* subitem 2
|
||||
${i4}* item 2`;
|
||||
expect(outputWithAnsi).toBe(expectedNestedList);
|
||||
});
|
||||
|
||||
// We had to patch in https://github.com/mikaelbr/marked-terminal/pull/367 to
|
||||
// make this work.
|
||||
it("ensure sequential subitems with styling to do not get extra newlines", () => {
|
||||
// This is a real-world example that exhibits many of the Markdown features
|
||||
// we care about. Though the original issue fix this was intended to verify
|
||||
// was that even though there is a single newline between the two subitems,
|
||||
// the stock version of marked-terminal@7.3.0 was adding an extra newline
|
||||
// in the output.
|
||||
const nestedList = `\
|
||||
## 🛠 Core CLI Logic
|
||||
|
||||
All of the TypeScript/React code lives under \`src/\`. The main entrypoint for argument parsing and orchestration is:
|
||||
|
||||
### \`src/cli.tsx\`
|
||||
- Uses **meow** for flags/subcommands and prints the built-in help/usage:
|
||||
【F:src/cli.tsx†L49-L53】【F:src/cli.tsx†L55-L60】
|
||||
- Handles special subcommands (e.g. \`codex completion …\`), \`--config\`, API-key validation, then either:
|
||||
- Spawns the **AgentLoop** for the normal multi-step prompting/edits flow, or
|
||||
- Runs **single-pass** mode if \`--full-context\` is set.
|
||||
|
||||
`;
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={"vscode"} cwd="/home/user/codex">
|
||||
{nestedList}
|
||||
</Markdown>,
|
||||
);
|
||||
|
||||
const outputWithAnsi = lastFrame();
|
||||
|
||||
// Note that the line with two citations gets split across two lines.
|
||||
// While the underlying ANSI content is long such that the split appears to
|
||||
// be merited, the rendered output is considerably shorter and ideally it
|
||||
// would be a single line.
|
||||
const expectedNestedList = `${GREEN}${BOLD}## 🛠 Core CLI Logic${BOLD_OFF}${COLOR_OFF}
|
||||
|
||||
All of the TypeScript/React code lives under ${YELLOW}src/${COLOR_OFF}. The main entrypoint for argument parsing and
|
||||
orchestration is:
|
||||
|
||||
${GREEN}${BOLD}### ${YELLOW}src/cli.tsx${COLOR_OFF}${BOLD_OFF}
|
||||
|
||||
* Uses ${BOLD}meow${BOLD_OFF} for flags/subcommands and prints the built-in help/usage:
|
||||
${BLUE}src/cli.tsx:49 (${LINK_ON}vscode://file/home/user/codex/src/cli.tsx:49${LINK_OFF})${COLOR_OFF} ${BLUE}src/cli.tsx:55 ${COLOR_OFF}
|
||||
${BLUE}(${LINK_ON}vscode://file/home/user/codex/src/cli.tsx:55${LINK_OFF})${COLOR_OFF}
|
||||
* Handles special subcommands (e.g. ${YELLOW}codex completion …${COLOR_OFF}), ${YELLOW}--config${COLOR_OFF}, API-key validation, then
|
||||
either:
|
||||
* Spawns the ${BOLD}AgentLoop${BOLD_OFF} for the normal multi-step prompting/edits flow, or
|
||||
* Runs ${BOLD}single-pass${BOLD_OFF} mode if ${YELLOW}--full-context${COLOR_OFF} is set.`;
|
||||
|
||||
expect(toDiffableString(outputWithAnsi)).toBe(
|
||||
toDiffableString(expectedNestedList),
|
||||
);
|
||||
});
|
||||
|
||||
it("citations should get converted to hyperlinks when stdout supports them", () => {
|
||||
const { lastFrame } = renderTui(
|
||||
<Markdown fileOpener={"vscode"} cwd="/foo/bar">
|
||||
File with TODO: 【F:src/approvals.ts†L40】
|
||||
</Markdown>,
|
||||
);
|
||||
|
||||
const expected = `File with TODO: ${BLUE}src/approvals.ts:40 (${LINK_ON}vscode://file/foo/bar/src/approvals.ts:40${LINK_OFF})${COLOR_OFF}`;
|
||||
const outputWithAnsi = lastFrame();
|
||||
expect(outputWithAnsi).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
function toDiffableString(str: string) {
|
||||
// The test harness is not able to handle ANSI codes, so we need to escape
|
||||
// them, but still give it line-based input so that it can diff the output.
|
||||
return str
|
||||
.split("\n")
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ describe("model-utils – offline resilience", () => {
|
||||
"../src/utils/model-utils.js"
|
||||
);
|
||||
|
||||
const supported = await isModelSupportedForResponses("openai", "o4-mini");
|
||||
const supported = await isModelSupportedForResponses(
|
||||
"openai",
|
||||
"codex-mini-latest",
|
||||
);
|
||||
expect(supported).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { exec as rawExec } from "../src/utils/agent/sandbox/raw-exec.js";
|
||||
import type { AppConfig } from "src/utils/config.js";
|
||||
|
||||
// Regression test: When cancelling an in‑flight `rawExec()` the implementation
|
||||
// must terminate *all* processes that belong to the spawned command – not just
|
||||
@@ -27,13 +28,17 @@ describe("rawExec – abort kills entire process group", () => {
|
||||
// Bash script: spawn `sleep 30` in background, print its PID, then wait.
|
||||
const script = "sleep 30 & pid=$!; echo $pid; wait $pid";
|
||||
const cmd = ["bash", "-c", script];
|
||||
const config: AppConfig = {
|
||||
model: "test-model",
|
||||
instructions: "test-instructions",
|
||||
};
|
||||
|
||||
// Start a bash shell that:
|
||||
// - spawns a background `sleep 30`
|
||||
// - prints the PID of the `sleep`
|
||||
// - waits for `sleep` to exit
|
||||
const { stdout, exitCode } = await (async () => {
|
||||
const p = rawExec(cmd, {}, [], abortController.signal);
|
||||
const p = rawExec(cmd, {}, config, abortController.signal);
|
||||
|
||||
// Give Bash a tiny bit of time to start and print the PID.
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
@@ -6,6 +6,7 @@ test("SLASH_COMMANDS includes expected commands", () => {
|
||||
expect(commands).toContain("/clear");
|
||||
expect(commands).toContain("/compact");
|
||||
expect(commands).toContain("/history");
|
||||
expect(commands).toContain("/sessions");
|
||||
expect(commands).toContain("/help");
|
||||
expect(commands).toContain("/model");
|
||||
expect(commands).toContain("/approval");
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("TerminalChatInput compact command", () => {
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import React from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Helper function for typing and flushing
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to reliably trigger file system suggestions in tests.
|
||||
*
|
||||
* This function simulates typing '@' followed by Tab to ensure suggestions appear.
|
||||
*
|
||||
* In real usage, simply typing '@' does trigger suggestions correctly.
|
||||
*/
|
||||
async function typeFileTag(
|
||||
stdin: NodeJS.WritableStream,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
// Type @ character
|
||||
stdin.write("@");
|
||||
await flush();
|
||||
|
||||
stdin.write("\t");
|
||||
await flush();
|
||||
}
|
||||
|
||||
// Mock the file system suggestions utility
|
||||
vi.mock("../src/utils/file-system-suggestions.js", () => ({
|
||||
FileSystemSuggestion: class {}, // Mock the interface
|
||||
getFileSystemSuggestions: vi.fn((pathPrefix: string) => {
|
||||
const normalizedPrefix = pathPrefix.startsWith("./")
|
||||
? pathPrefix.slice(2)
|
||||
: pathPrefix;
|
||||
const allItems = [
|
||||
{ path: "file1.txt", isDirectory: false },
|
||||
{ path: "file2.js", isDirectory: false },
|
||||
{ path: "directory1/", isDirectory: true },
|
||||
{ path: "directory2/", isDirectory: true },
|
||||
];
|
||||
return allItems.filter((item) => item.path.startsWith(normalizedPrefix));
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the createInputItem function to avoid filesystem operations
|
||||
vi.mock("../src/utils/input-utils.js", () => ({
|
||||
createInputItem: vi.fn(async (text: string) => ({
|
||||
role: "user",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text }],
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("TerminalChatInput file tag suggestions", () => {
|
||||
// Standard props for all tests
|
||||
const baseProps: ComponentProps<typeof TerminalChatInput> = {
|
||||
isNew: false,
|
||||
loading: false,
|
||||
submitInput: vi.fn().mockImplementation(() => {}),
|
||||
confirmationPrompt: null,
|
||||
explanation: undefined,
|
||||
submitConfirmation: vi.fn(),
|
||||
setLastResponseId: vi.fn(),
|
||||
setItems: vi.fn(),
|
||||
contextLeftPercent: 50,
|
||||
openOverlay: vi.fn(),
|
||||
openDiffOverlay: vi.fn(),
|
||||
openModelOverlay: vi.fn(),
|
||||
openApprovalOverlay: vi.fn(),
|
||||
openHelpOverlay: vi.fn(),
|
||||
openSessionsOverlay: vi.fn(),
|
||||
onCompact: vi.fn(),
|
||||
interruptAgent: vi.fn(),
|
||||
active: true,
|
||||
thinkingSeconds: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows file system suggestions when typing @ alone", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Check that current directory suggestions are shown
|
||||
const frame = lastFrameStripped();
|
||||
expect(frame).toContain("file1.txt");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("completes the selected file system suggestion with Tab", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Press Tab to select the first suggestion
|
||||
await type(stdin, "\t", flush);
|
||||
|
||||
// Check that the input has been completed with the selected suggestion
|
||||
const frameAfterTab = lastFrameStripped();
|
||||
expect(frameAfterTab).toContain("@file1.txt");
|
||||
// Check that the rest of the suggestions have collapsed
|
||||
expect(frameAfterTab).not.toContain("file2.txt");
|
||||
expect(frameAfterTab).not.toContain("directory2/");
|
||||
expect(frameAfterTab).not.toContain("directory1/");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("clears file system suggestions when typing a space", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Check that suggestions are shown
|
||||
let frame = lastFrameStripped();
|
||||
expect(frame).toContain("file1.txt");
|
||||
|
||||
// Type a space to clear suggestions
|
||||
await type(stdin, " ", flush);
|
||||
|
||||
// Check that suggestions are cleared
|
||||
frame = lastFrameStripped();
|
||||
expect(frame).not.toContain("file1.txt");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("selects and retains directory when pressing Enter on directory suggestion", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Navigate to directory suggestion (we need two down keys to get to the first directory)
|
||||
await type(stdin, "\u001B[B", flush); // Down arrow key - move to file2.js
|
||||
await type(stdin, "\u001B[B", flush); // Down arrow key - move to directory1/
|
||||
|
||||
// Check that the directory suggestion is selected
|
||||
let frame = lastFrameStripped();
|
||||
expect(frame).toContain("directory1/");
|
||||
|
||||
// Press Enter to select the directory
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
// Check that the input now contains the directory path
|
||||
frame = lastFrameStripped();
|
||||
expect(frame).toContain("@directory1/");
|
||||
|
||||
// Check that submitInput was NOT called (since we're only navigating, not submitting)
|
||||
expect(baseProps.submitInput).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("submits when pressing Enter on file suggestion", async () => {
|
||||
const { stdin, flush, cleanup } = renderTui(
|
||||
<TerminalChatInput {...baseProps} />,
|
||||
);
|
||||
|
||||
// Type @ and activate suggestions
|
||||
await typeFileTag(stdin, flush);
|
||||
|
||||
// Press Enter to select first suggestion (file1.txt)
|
||||
await type(stdin, "\r", flush);
|
||||
|
||||
// Check that submitInput was called
|
||||
expect(baseProps.submitInput).toHaveBeenCalled();
|
||||
|
||||
// Get the arguments passed to submitInput
|
||||
const submitArgs = (baseProps.submitInput as any).mock.calls[0][0];
|
||||
|
||||
// Verify the first argument is an array with at least one item
|
||||
expect(Array.isArray(submitArgs)).toBe(true);
|
||||
expect(submitArgs.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that the content includes the file path
|
||||
const content = submitArgs[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
expect(content[0].text).toContain("@file1.txt");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ describe("TerminalChatInput multiline functionality", () => {
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
@@ -93,6 +94,7 @@ describe("TerminalChatInput multiline functionality", () => {
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
openSessionsOverlay: () => {},
|
||||
onCompact: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
|
||||
@@ -38,7 +38,10 @@ function assistantMessage(text: string) {
|
||||
describe("TerminalChatResponseItem", () => {
|
||||
it("renders a user message", () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatResponseItem item={userMessage("Hello world")} />,
|
||||
<TerminalChatResponseItem
|
||||
item={userMessage("Hello world")}
|
||||
fileOpener={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
@@ -48,7 +51,10 @@ describe("TerminalChatResponseItem", () => {
|
||||
|
||||
it("renders an assistant message", () => {
|
||||
const { lastFrameStripped } = renderTui(
|
||||
<TerminalChatResponseItem item={assistantMessage("Sure thing")} />,
|
||||
<TerminalChatResponseItem
|
||||
item={assistantMessage("Sure thing")}
|
||||
fileOpener={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = lastFrameStripped();
|
||||
|
||||
@@ -43,18 +43,17 @@ describe("TextBuffer – word‑wise navigation & deletion", () => {
|
||||
expect(tb.getText()).toBe("foo bar ");
|
||||
});
|
||||
|
||||
test("Option/Alt+Delete deletes next word", () => {
|
||||
test("Option/Alt+Delete deletes previous word (matches shells)", () => {
|
||||
const tb = new TextBuffer("foo bar baz");
|
||||
const vp = { height: 10, width: 80 } as const;
|
||||
|
||||
// Move caret between first and second word (after space)
|
||||
tb.move("wordRight"); // after foo
|
||||
tb.move("right"); // skip space -> start of bar
|
||||
// Place caret at end so we can test backward deletion.
|
||||
tb.move("end");
|
||||
|
||||
// Option+Delete
|
||||
// Simulate Option+Delete (parsed as alt-modified Delete on some terminals)
|
||||
tb.handleInput(undefined, { delete: true, alt: true }, vp);
|
||||
|
||||
expect(tb.getText()).toBe("foo baz"); // note double space removed later maybe
|
||||
expect(tb.getText()).toBe("foo bar ");
|
||||
});
|
||||
|
||||
test("wordLeft eventually reaches column 0", () => {
|
||||
@@ -121,4 +120,18 @@ describe("TextBuffer – word‑wise navigation & deletion", () => {
|
||||
const [, col] = tb.getCursor();
|
||||
expect(col).toBe(6);
|
||||
});
|
||||
|
||||
test("Shift+Option/Alt+Delete deletes next word", () => {
|
||||
const tb = new TextBuffer("foo bar baz");
|
||||
const vp = { height: 10, width: 80 } as const;
|
||||
|
||||
// Move caret between first and second word (after space)
|
||||
tb.move("wordRight"); // after foo
|
||||
tb.move("right"); // skip space -> start of bar
|
||||
|
||||
// Shift+Option+Delete should now remove "bar "
|
||||
tb.handleInput(undefined, { delete: true, alt: true, shift: true }, vp);
|
||||
|
||||
expect(tb.getText()).toBe("foo baz");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,33 @@ describe("TextBuffer – basic editing parity with Rust suite", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor initialization", () => {
|
||||
it("initializes cursor to (0,0) by default", () => {
|
||||
const buf = new TextBuffer("hello\nworld");
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("sets cursor to valid position within line", () => {
|
||||
const buf = new TextBuffer("hello", 2);
|
||||
expect(buf.getCursor()).toEqual([0, 2]); // cursor at 'l'
|
||||
});
|
||||
|
||||
it("sets cursor to end of line", () => {
|
||||
const buf = new TextBuffer("hello", 5);
|
||||
expect(buf.getCursor()).toEqual([0, 5]); // cursor after 'o'
|
||||
});
|
||||
|
||||
it("sets cursor across multiple lines", () => {
|
||||
const buf = new TextBuffer("hello\nworld", 7);
|
||||
expect(buf.getCursor()).toEqual([1, 1]); // cursor at 'o' in 'world'
|
||||
});
|
||||
|
||||
it("defaults to position 0 for invalid index", () => {
|
||||
const buf = new TextBuffer("hello", 999);
|
||||
expect(buf.getCursor()).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Vertical cursor movement – we should preserve the preferred column */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Provide a stub Vite config in the CLI package to avoid resolving a parent-level vite.config.js
|
||||
export default defineConfig({});
|
||||
12
codex-cli/vitest.config.ts
Normal file
12
codex-cli/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
/**
|
||||
* Vitest configuration for the CLI package.
|
||||
* Disables worker threads to avoid pool recursion issues in sandbox.
|
||||
*/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
threads: false,
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
1145
codex-rs/Cargo.lock
generated
1145
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,31 @@ members = [
|
||||
"ansi-escape",
|
||||
"apply-patch",
|
||||
"cli",
|
||||
"common",
|
||||
"core",
|
||||
"exec",
|
||||
"execpolicy",
|
||||
"repl",
|
||||
"linux-sandbox",
|
||||
"mcp-client",
|
||||
"mcp-server",
|
||||
"mcp-types",
|
||||
"tui",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.2504292236"
|
||||
version = "0.0.0"
|
||||
# Track the edition for all workspace crates in one place. Individual
|
||||
# crates can still override this value, but keeping it here means new
|
||||
# crates created with `cargo new -w ...` automatically inherit the 2024
|
||||
# edition.
|
||||
edition = "2024"
|
||||
|
||||
[workspace.lints]
|
||||
rust = {}
|
||||
|
||||
[workspace.lints.clippy]
|
||||
expect_used = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
|
||||
@@ -10,7 +10,7 @@ To that end, we are moving forward with a Rust implementation of Codex CLI conta
|
||||
- Can make direct, native calls to [seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) and [landlock](https://man7.org/linux/man-pages/man7/landlock.7.html) in order to support sandboxing on Linux.
|
||||
- No runtime garbage collection, resulting in lower memory consumption and better, more predictable performance.
|
||||
|
||||
Currently, the Rust implementation is materially behind the TypeScript implementation in functionality, so continue to use the TypeScript implmentation for the time being. We will publish native executables via GitHub Releases as soon as we feel the Rust version is usable.
|
||||
Currently, the Rust implementation is materially behind the TypeScript implementation in functionality, so continue to use the TypeScript implementation for the time being. We will publish native executables via GitHub Releases as soon as we feel the Rust version is usable.
|
||||
|
||||
## Code Organization
|
||||
|
||||
@@ -19,5 +19,374 @@ This folder is the root of a Cargo workspace. It contains quite a bit of experim
|
||||
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this to be a library crate that is generally useful for building other Rust/native applications that use Codex.
|
||||
- [`exec/`](./exec) "headless" CLI for use in automation.
|
||||
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
|
||||
- [`repl/`](./repl) CLI that launches a lightweight REPL similar to the Python or Node.js REPL.
|
||||
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
|
||||
|
||||
## Config
|
||||
|
||||
The CLI can be configured via a file named `config.toml`. By default, configuration is read from `~/.codex/config.toml`, though the `CODEX_HOME` environment variable can be used to specify a directory other than `~/.codex`.
|
||||
|
||||
The `config.toml` file supports the following options:
|
||||
|
||||
### model
|
||||
|
||||
The model that Codex should use.
|
||||
|
||||
```toml
|
||||
model = "o3" # overrides the default of "o4-mini"
|
||||
```
|
||||
|
||||
### model_provider
|
||||
|
||||
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`.
|
||||
|
||||
For example, if you are running ollama with Mistral locally, then you would need to add the following to your config:
|
||||
|
||||
```toml
|
||||
model = "mistral"
|
||||
model_provider = "ollama"
|
||||
```
|
||||
|
||||
because the following definition for `ollama` is included in Codex:
|
||||
|
||||
```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:
|
||||
|
||||
```toml
|
||||
[model_providers.openai]
|
||||
name = "OpenAI"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
### 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 correspodning provider.
|
||||
|
||||
For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you
|
||||
|
||||
```toml
|
||||
# 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".
|
||||
wire_api = "chat"
|
||||
```
|
||||
|
||||
### approval_policy
|
||||
|
||||
Determines when the user should be prompted to approve whether Codex can execute a command:
|
||||
|
||||
```toml
|
||||
# This is analogous to --suggest in the TypeScript Codex CLI
|
||||
approval_policy = "unless-allow-listed"
|
||||
```
|
||||
|
||||
```toml
|
||||
# If the command fails when run in the sandbox, Codex asks for permission to
|
||||
# retry the command outside the sandbox.
|
||||
approval_policy = "on-failure"
|
||||
```
|
||||
|
||||
```toml
|
||||
# User is never prompted: if the command fails, Codex will automatically try
|
||||
# something out. Note the `exec` subcommand always uses this mode.
|
||||
approval_policy = "never"
|
||||
```
|
||||
|
||||
### profiles
|
||||
|
||||
A _profile_ is a collection of configuration values that can be set together. Multiple profiles can be defined in `config.toml` and you can specify the one you
|
||||
want to use at runtime via the `--profile` flag.
|
||||
|
||||
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
|
||||
# line, though the `--profile` flag can still be used to override this value.
|
||||
profile = "o3"
|
||||
|
||||
[model_providers.openai-chat-completions]
|
||||
name = "OpenAI using Chat Completions"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
wire_api = "chat"
|
||||
|
||||
[profiles.o3]
|
||||
model = "o3"
|
||||
model_provider = "openai"
|
||||
approval_policy = "never"
|
||||
|
||||
[profiles.gpt3]
|
||||
model = "gpt-3.5-turbo"
|
||||
model_provider = "openai-chat-completions"
|
||||
|
||||
[profiles.zdr]
|
||||
model = "o3"
|
||||
model_provider = "openai"
|
||||
approval_policy = "on-failure"
|
||||
disable_response_storage = true
|
||||
```
|
||||
|
||||
Users can specify config values at multiple levels. Order of precedence is as follows:
|
||||
|
||||
1. custom command-line argument, e.g., `--model o3`
|
||||
2. as part of a profile, where the `--profile` is specified via a CLI (or in the config file itself)
|
||||
3. as an entry in `config.toml`, e.g., `model = "o3"`
|
||||
4. the default value that comes with Codex CLI (i.e., Codex CLI defaults to `o4-mini`)
|
||||
|
||||
### sandbox_permissions
|
||||
|
||||
List of permissions to grant to the sandbox that Codex uses to execute untrusted commands:
|
||||
|
||||
```toml
|
||||
# This is comparable to --full-auto in the TypeScript Codex CLI, though
|
||||
# specifying `disk-write-platform-global-temp-folder` adds /tmp as a writable
|
||||
# folder in addition to $TMPDIR.
|
||||
sandbox_permissions = [
|
||||
"disk-full-read-access",
|
||||
"disk-write-platform-user-temp-folder",
|
||||
"disk-write-platform-global-temp-folder",
|
||||
"disk-write-cwd",
|
||||
]
|
||||
```
|
||||
|
||||
To add additional writable folders, use `disk-write-folder`, which takes a parameter (this can be specified multiple times):
|
||||
|
||||
```toml
|
||||
sandbox_permissions = [
|
||||
# ...
|
||||
"disk-write-folder=/Users/mbolin/.pyenv/shims",
|
||||
]
|
||||
```
|
||||
|
||||
### mcp_servers
|
||||
|
||||
Defines the list of MCP servers that Codex can consult for tool use. Currently, only servers that are launched by executing a program that communicate over stdio are supported. For servers that use the SSE transport, consider an adapter like [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy).
|
||||
|
||||
**Note:** Codex may cache the list of tools and resources from an MCP server so that Codex can include this information in context at startup without spawning all the servers. This is designed to save resources by loading MCP servers lazily.
|
||||
|
||||
This config option is comparable to how Claude and Cursor define `mcpServers` in their respective JSON config files, though because Codex uses TOML for its config language, the format is slightly different. For example, the following config in JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server-name": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-server"],
|
||||
"env": {
|
||||
"API_KEY": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Should be represented as follows in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
# IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`.
|
||||
[mcp_servers.server-name]
|
||||
command = "npx"
|
||||
args = ["-y", "mcp-server"]
|
||||
env = { "API_KEY" = "value" }
|
||||
```
|
||||
|
||||
### disable_response_storage
|
||||
|
||||
Currently, customers whose accounts are set to use Zero Data Retention (ZDR) must set `disable_response_storage` to `true` so that Codex uses an alternative to the Responses API that works with ZDR:
|
||||
|
||||
```toml
|
||||
disable_response_storage = true
|
||||
```
|
||||
|
||||
### shell_environment_policy
|
||||
|
||||
Codex spawns subprocesses (e.g. when executing a `local_shell` tool-call suggested by the assistant). By default it passes **only a minimal core subset** of your environment to those subprocesses to avoid leaking credentials. You can tune this behavior via the **`shell_environment_policy`** block in
|
||||
`config.toml`:
|
||||
|
||||
```toml
|
||||
[shell_environment_policy]
|
||||
# inherit can be "core" (default), "all", or "none"
|
||||
inherit = "core"
|
||||
# set to true to *skip* the filter for `"*KEY*"` and `"*TOKEN*"`
|
||||
ignore_default_excludes = false
|
||||
# exclude patterns (case-insensitive globs)
|
||||
exclude = ["AWS_*", "AZURE_*"]
|
||||
# force-set / override values
|
||||
set = { CI = "1" }
|
||||
# if provided, *only* vars matching these patterns are kept
|
||||
include_only = ["PATH", "HOME"]
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ------------------------- | -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `inherit` | string | `core` | Starting template for the environment:<br>`core` (`HOME`, `PATH`, `USER`, …), `all` (clone full parent env), or `none` (start empty). |
|
||||
| `ignore_default_excludes` | boolean | `false` | When `false`, Codex removes any var whose **name** contains `KEY`, `SECRET`, or `TOKEN` (case-insensitive) before other rules run. |
|
||||
| `exclude` | array<string> | `[]` | Case-insensitive glob patterns to drop after the default filter.<br>Examples: `"AWS_*"`, `"AZURE_*"`. |
|
||||
| `set` | table<string,string> | `{}` | Explicit key/value overrides or additions – always win over inherited values. |
|
||||
| `include_only` | array<string> | `[]` | If non-empty, a whitelist of patterns; only variables that match _one_ pattern survive the final step. (Generally used with `inherit = "all"`.) |
|
||||
|
||||
The patterns are **glob style**, not full regular expressions: `*` matches any
|
||||
number of characters, `?` matches exactly one, and character classes like
|
||||
`[A-Z]`/`[^0-9]` are supported. Matching is always **case-insensitive**. This
|
||||
syntax is documented in code as `EnvironmentVariablePattern` (see
|
||||
`core/src/config_types.rs`).
|
||||
|
||||
If you just need a clean slate with a few custom entries you can write:
|
||||
|
||||
```toml
|
||||
[shell_environment_policy]
|
||||
inherit = "none"
|
||||
set = { PATH = "/usr/bin", MY_FLAG = "1" }
|
||||
```
|
||||
|
||||
Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable.
|
||||
|
||||
### notify
|
||||
|
||||
Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "agent-turn-complete",
|
||||
"turn-id": "12345",
|
||||
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
|
||||
"last-assistant-message": "Rename complete and verified `cargo build` succeeds."
|
||||
}
|
||||
```
|
||||
|
||||
The `"type"` property will always be set. Currently, `"agent-turn-complete"` is the only notification type that is supported.
|
||||
|
||||
As an example, here is a Python script that parses the JSON and decides whether to show a desktop push notification using [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: notify.py <NOTIFICATION_JSON>")
|
||||
return 1
|
||||
|
||||
try:
|
||||
notification = json.loads(sys.argv[1])
|
||||
except json.JSONDecodeError:
|
||||
return 1
|
||||
|
||||
match notification_type := notification.get("type"):
|
||||
case "agent-turn-complete":
|
||||
assistant_message = notification.get("last-assistant-message")
|
||||
if assistant_message:
|
||||
title = f"Codex: {assistant_message}"
|
||||
else:
|
||||
title = "Codex: Turn Complete!"
|
||||
input_messages = notification.get("input_messages", [])
|
||||
message = " ".join(input_messages)
|
||||
title += message
|
||||
case _:
|
||||
print(f"not sending a push notification for: {notification_type}")
|
||||
return 0
|
||||
|
||||
subprocess.check_output(
|
||||
[
|
||||
"terminal-notifier",
|
||||
"-title",
|
||||
title,
|
||||
"-message",
|
||||
message,
|
||||
"-group",
|
||||
"codex",
|
||||
"-ignoreDnD",
|
||||
"-activate",
|
||||
"com.googlecode.iterm2",
|
||||
]
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
To have Codex use this script for notifications, you would configure it via `notify` in `~/.codex/config.toml` using the appropriate path to `notify.py` on your computer:
|
||||
|
||||
```toml
|
||||
notify = ["python3", "/Users/mbolin/.codex/notify.py"]
|
||||
```
|
||||
|
||||
### history
|
||||
|
||||
By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner.
|
||||
|
||||
To disable this behavior, configure `[history]` as follows:
|
||||
|
||||
```toml
|
||||
[history]
|
||||
persistence = "none" # "save-all" is the default value
|
||||
```
|
||||
|
||||
### file_opener
|
||||
|
||||
Identifies the editor/URI scheme to use for hyperlinking citations in model output. If set, citations to files in the model output will be hyperlinked using the specified URI scheme so they can be ctrl/cmd-clicked from the terminal to open them.
|
||||
|
||||
For example, if the model output includes a reference such as `【F:/home/user/project/main.py†L42-L50】`, then this would be rewritten to link to the URI `vscode://file/home/user/project/main.py:42`.
|
||||
|
||||
Note this is **not** a general editor setting (like `$EDITOR`), as it only accepts a fixed set of values:
|
||||
|
||||
- `"vscode"` (default)
|
||||
- `"vscode-insiders"`
|
||||
- `"windsurf"`
|
||||
- `"cursor"`
|
||||
- `"none"` to explicitly disable this feature
|
||||
|
||||
Currently, `"vscode"` is the default, though Codex does not verify VS Code is installed. As such, `file_opener` may default to `"none"` or something else in the future.
|
||||
|
||||
### 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.
|
||||
|
||||
### tui
|
||||
|
||||
Options that are specific to the TUI.
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
# This will make it so that Codex does not try to process mouse events, which
|
||||
# means your Terminal's native drag-to-text to text selection and copy/paste
|
||||
# should work. The tradeoff is that Codex will not receive any mouse events, so
|
||||
# it will not be possible to use the mouse to scroll conversation history.
|
||||
#
|
||||
# Note that most terminals support holding down a modifier key when using the
|
||||
# mouse to support text selection. For example, even if Codex mouse capture is
|
||||
# enabled (i.e., this is set to `false`), you can still hold down alt while
|
||||
# dragging the mouse to select text.
|
||||
disable_mouse_capture = true # defaults to `false`
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "codex-ansi-escape"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "codex_ansi_escape"
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
[package]
|
||||
name = "codex-apply-patch"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "codex_apply_patch"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
regex = "1.11.1"
|
||||
|
||||
@@ -4,21 +4,22 @@ mod seek_sequence;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::str::Utf8Error;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Error;
|
||||
use anyhow::Result;
|
||||
pub use parser::parse_patch;
|
||||
pub use parser::Hunk;
|
||||
pub use parser::ParseError;
|
||||
use parser::ParseError::*;
|
||||
use parser::UpdateFileChunk;
|
||||
pub use parser::parse_patch;
|
||||
use similar::TextDiff;
|
||||
use thiserror::Error;
|
||||
use tree_sitter::LanguageError;
|
||||
use tree_sitter::Parser;
|
||||
use tree_sitter_bash::LANGUAGE as BASH;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ApplyPatchError {
|
||||
#[error(transparent)]
|
||||
ParseError(#[from] ParseError),
|
||||
@@ -46,10 +47,16 @@ pub struct IoError {
|
||||
source: std::io::Error,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
impl PartialEq for IoError {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.context == other.context && self.source.to_string() == other.source.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum MaybeApplyPatch {
|
||||
Body(Vec<Hunk>),
|
||||
ShellParseError(Error),
|
||||
ShellParseError(ExtractHeredocError),
|
||||
PatchParseError(ParseError),
|
||||
NotApplyPatch,
|
||||
}
|
||||
@@ -77,7 +84,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ApplyPatchFileChange {
|
||||
Add {
|
||||
content: String,
|
||||
@@ -91,14 +98,14 @@ pub enum ApplyPatchFileChange {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum MaybeApplyPatchVerified {
|
||||
/// `argv` corresponded to an `apply_patch` invocation, and these are the
|
||||
/// resulting proposed file changes.
|
||||
Body(HashMap<PathBuf, ApplyPatchFileChange>),
|
||||
Body(ApplyPatchAction),
|
||||
/// `argv` could not be parsed to determine whether it corresponds to an
|
||||
/// `apply_patch` invocation.
|
||||
ShellParseError(Error),
|
||||
ShellParseError(ExtractHeredocError),
|
||||
/// `argv` corresponded to an `apply_patch` invocation, but it could not
|
||||
/// be fulfilled due to the specified error.
|
||||
CorrectnessError(ApplyPatchError),
|
||||
@@ -106,27 +113,52 @@ pub enum MaybeApplyPatchVerified {
|
||||
NotApplyPatch,
|
||||
}
|
||||
|
||||
pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerified {
|
||||
#[derive(Debug, PartialEq)]
|
||||
/// ApplyPatchAction is the result of parsing an `apply_patch` command. By
|
||||
/// construction, all paths should be absolute paths.
|
||||
pub struct ApplyPatchAction {
|
||||
changes: HashMap<PathBuf, ApplyPatchFileChange>,
|
||||
}
|
||||
|
||||
impl ApplyPatchAction {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty()
|
||||
}
|
||||
|
||||
/// Returns the changes that would be made by applying the patch.
|
||||
pub fn changes(&self) -> &HashMap<PathBuf, ApplyPatchFileChange> {
|
||||
&self.changes
|
||||
}
|
||||
|
||||
/// Should be used exclusively for testing. (Not worth the overhead of
|
||||
/// creating a feature flag for this.)
|
||||
pub fn new_add_for_test(path: &Path, content: String) -> Self {
|
||||
if !path.is_absolute() {
|
||||
panic!("path must be absolute");
|
||||
}
|
||||
|
||||
let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]);
|
||||
Self { changes }
|
||||
}
|
||||
}
|
||||
|
||||
/// cwd must be an absolute path so that we can resolve relative paths in the
|
||||
/// patch.
|
||||
pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApplyPatchVerified {
|
||||
match maybe_parse_apply_patch(argv) {
|
||||
MaybeApplyPatch::Body(hunks) => {
|
||||
let mut changes = HashMap::new();
|
||||
for hunk in hunks {
|
||||
let path = hunk.resolve_path(cwd);
|
||||
match hunk {
|
||||
Hunk::AddFile { path, contents } => {
|
||||
changes.insert(
|
||||
path,
|
||||
ApplyPatchFileChange::Add {
|
||||
content: contents.clone(),
|
||||
},
|
||||
);
|
||||
Hunk::AddFile { contents, .. } => {
|
||||
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
|
||||
}
|
||||
Hunk::DeleteFile { path } => {
|
||||
Hunk::DeleteFile { .. } => {
|
||||
changes.insert(path, ApplyPatchFileChange::Delete);
|
||||
}
|
||||
Hunk::UpdateFile {
|
||||
path,
|
||||
move_path,
|
||||
chunks,
|
||||
move_path, chunks, ..
|
||||
} => {
|
||||
let ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
@@ -138,17 +170,17 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
|
||||
}
|
||||
};
|
||||
changes.insert(
|
||||
path.clone(),
|
||||
path,
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
move_path: move_path.map(|p| cwd.join(p)),
|
||||
new_content: contents,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
MaybeApplyPatchVerified::Body(changes)
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction { changes })
|
||||
}
|
||||
MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e),
|
||||
MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()),
|
||||
@@ -174,17 +206,21 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
|
||||
/// * `Ok(String)` - The heredoc body if the extraction is successful.
|
||||
/// * `Err(anyhow::Error)` - An error if the extraction fails.
|
||||
///
|
||||
fn extract_heredoc_body_from_apply_patch_command(src: &str) -> anyhow::Result<String> {
|
||||
fn extract_heredoc_body_from_apply_patch_command(
|
||||
src: &str,
|
||||
) -> std::result::Result<String, ExtractHeredocError> {
|
||||
if !src.trim_start().starts_with("apply_patch") {
|
||||
anyhow::bail!("expected command to start with 'apply_patch'");
|
||||
return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch);
|
||||
}
|
||||
|
||||
let lang = BASH.into();
|
||||
let mut parser = Parser::new();
|
||||
parser.set_language(&lang).expect("load bash grammar");
|
||||
parser
|
||||
.set_language(&lang)
|
||||
.map_err(ExtractHeredocError::FailedToLoadBashGrammar)?;
|
||||
let tree = parser
|
||||
.parse(src, None)
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to parse patch into AST"))?;
|
||||
.ok_or(ExtractHeredocError::FailedToParsePatchIntoAst)?;
|
||||
|
||||
let bytes = src.as_bytes();
|
||||
let mut c = tree.root_node().walk();
|
||||
@@ -192,7 +228,9 @@ fn extract_heredoc_body_from_apply_patch_command(src: &str) -> anyhow::Result<St
|
||||
loop {
|
||||
let node = c.node();
|
||||
if node.kind() == "heredoc_body" {
|
||||
let text = node.utf8_text(bytes).unwrap();
|
||||
let text = node
|
||||
.utf8_text(bytes)
|
||||
.map_err(ExtractHeredocError::HeredocNotUtf8)?;
|
||||
return Ok(text.trim_end_matches('\n').to_owned());
|
||||
}
|
||||
|
||||
@@ -201,12 +239,21 @@ fn extract_heredoc_body_from_apply_patch_command(src: &str) -> anyhow::Result<St
|
||||
}
|
||||
while !c.goto_next_sibling() {
|
||||
if !c.goto_parent() {
|
||||
anyhow::bail!("expected to find heredoc_body in patch candidate");
|
||||
return Err(ExtractHeredocError::FailedToFindHeredocBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ExtractHeredocError {
|
||||
CommandDidNotStartWithApplyPatch,
|
||||
FailedToLoadBashGrammar(LanguageError),
|
||||
HeredocNotUtf8(Utf8Error),
|
||||
FailedToParsePatchIntoAst,
|
||||
FailedToFindHeredocBody,
|
||||
}
|
||||
|
||||
/// Applies the patch and prints the result to stdout/stderr.
|
||||
pub fn apply_patch(
|
||||
patch: &str,
|
||||
@@ -378,7 +425,7 @@ fn derive_new_contents_from_chunks(
|
||||
return Err(ApplyPatchError::IoError(IoError {
|
||||
context: format!("Failed to read file to update {}", path.display()),
|
||||
source: err,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -574,6 +621,8 @@ pub fn print_summary(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
@@ -1100,4 +1149,48 @@ g
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_should_resolve_absolute_paths_in_cwd() {
|
||||
let session_dir = tempdir().unwrap();
|
||||
let relative_path = "source.txt";
|
||||
|
||||
// Note that we need this file to exist for the patch to be "verified"
|
||||
// and parsed correctly.
|
||||
let session_file_path = session_dir.path().join(relative_path);
|
||||
fs::write(&session_file_path, "session directory content\n").unwrap();
|
||||
|
||||
let argv = vec![
|
||||
"apply_patch".to_string(),
|
||||
r#"*** Begin Patch
|
||||
*** Update File: source.txt
|
||||
@@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
*** End Patch"#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
|
||||
|
||||
// Verify the patch contents - as otherwise we may have pulled contents
|
||||
// from the wrong file (as we're using relative paths)
|
||||
assert_eq!(
|
||||
result,
|
||||
MaybeApplyPatchVerified::Body(ApplyPatchAction {
|
||||
changes: HashMap::from([(
|
||||
session_dir.path().join(relative_path),
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: r#"@@ -1 +1 @@
|
||||
-session directory content
|
||||
+updated session directory content
|
||||
"#
|
||||
.to_string(),
|
||||
move_path: None,
|
||||
new_content: "updated session directory content\n".to_string(),
|
||||
},
|
||||
)]),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//!
|
||||
//! The parser below is a little more lenient than the explicit spec and allows for
|
||||
//! leading/trailing whitespace around patch markers.
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
@@ -64,6 +65,17 @@ pub enum Hunk {
|
||||
chunks: Vec<UpdateFileChunk>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
pub fn resolve_path(&self, cwd: &Path) -> PathBuf {
|
||||
match self {
|
||||
Hunk::AddFile { path, .. } => cwd.join(path),
|
||||
Hunk::DeleteFile { path } => cwd.join(path),
|
||||
Hunk::UpdateFile { path, .. } => cwd.join(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use Hunk::*;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -196,7 +208,12 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
|
||||
));
|
||||
}
|
||||
|
||||
Err(InvalidHunkError { message: format!("'{first_line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"), line_number })
|
||||
Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"'{first_line}' is not a valid hunk header. Valid hunk headers: '*** Add File: {{path}}', '*** Delete File: {{path}}', '*** Update File: {{path}}'"
|
||||
),
|
||||
line_number,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_update_file_chunk(
|
||||
@@ -273,7 +290,12 @@ fn parse_update_file_chunk(
|
||||
}
|
||||
_ => {
|
||||
if parsed_lines == 0 {
|
||||
return Err(InvalidHunkError { message: format!("Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"), line_number: line_number + 1 });
|
||||
return Err(InvalidHunkError {
|
||||
message: format!(
|
||||
"Unexpected line found in update hunk: '{line_contents}'. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line)"
|
||||
),
|
||||
line_number: line_number + 1,
|
||||
});
|
||||
}
|
||||
// Assume this is the start of the next hunk.
|
||||
break;
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
[package]
|
||||
name = "codex-cli"
|
||||
version = { workspace = true }
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "codex"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codex-linux-sandbox"
|
||||
path = "src/linux-sandbox/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "codex_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-exec = { path = "../exec" }
|
||||
codex-repl = { path = "../repl" }
|
||||
codex-linux-sandbox = { path = "../linux-sandbox" }
|
||||
codex-mcp-server = { path = "../mcp-server" }
|
||||
codex-tui = { path = "../tui" }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = [
|
||||
|
||||
122
codex-rs/cli/src/debug_sandbox.rs
Normal file
122
codex-rs/cli/src/debug_sandbox.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
use codex_core::exec::spawn_command_under_linux_sandbox;
|
||||
use codex_core::exec::spawn_command_under_seatbelt;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
|
||||
use crate::LandlockCommand;
|
||||
use crate::SeatbeltCommand;
|
||||
use crate::exit_status::handle_exit_status;
|
||||
|
||||
pub async fn run_command_under_seatbelt(
|
||||
command: SeatbeltCommand,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
) -> 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,
|
||||
SandboxType::Seatbelt,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_command_under_landlock(
|
||||
command: LandlockCommand,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
) -> 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,
|
||||
SandboxType::Landlock,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
enum SandboxType {
|
||||
Seatbelt,
|
||||
Landlock,
|
||||
}
|
||||
|
||||
async fn run_command_under_sandbox(
|
||||
full_auto: bool,
|
||||
sandbox: SandboxPermissionOption,
|
||||
command: Vec<String>,
|
||||
config_overrides: CliConfigOverrides,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
sandbox_type: SandboxType,
|
||||
) -> anyhow::Result<()> {
|
||||
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
|
||||
let cwd = std::env::current_dir()?;
|
||||
let config = Config::load_with_cli_overrides(
|
||||
config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
ConfigOverrides {
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
codex_linux_sandbox_exe,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
let stdio_policy = StdioPolicy::Inherit;
|
||||
let env = create_env(&config.shell_environment_policy);
|
||||
|
||||
let mut child = match sandbox_type {
|
||||
SandboxType::Seatbelt => {
|
||||
spawn_command_under_seatbelt(command, &config.sandbox_policy, cwd, stdio_policy, env)
|
||||
.await?
|
||||
}
|
||||
SandboxType::Landlock => {
|
||||
#[expect(clippy::expect_used)]
|
||||
let codex_linux_sandbox_exe = config
|
||||
.codex_linux_sandbox_exe
|
||||
.expect("codex-linux-sandbox executable not found");
|
||||
spawn_command_under_linux_sandbox(
|
||||
codex_linux_sandbox_exe,
|
||||
command,
|
||||
&config.sandbox_policy,
|
||||
cwd,
|
||||
stdio_policy,
|
||||
env,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let status = child.wait().await?;
|
||||
|
||||
handle_exit_status(status);
|
||||
}
|
||||
|
||||
pub fn create_sandbox_policy(full_auto: bool, sandbox: SandboxPermissionOption) -> SandboxPolicy {
|
||||
if full_auto {
|
||||
SandboxPolicy::new_full_auto_policy()
|
||||
} else {
|
||||
match sandbox.permissions.map(Into::into) {
|
||||
Some(sandbox_policy) => sandbox_policy,
|
||||
None => SandboxPolicy::new_read_only_policy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
23
codex-rs/cli/src/exit_status.rs
Normal file
23
codex-rs/cli/src/exit_status.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn handle_exit_status(status: std::process::ExitStatus) -> ! {
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
|
||||
// Use ExitStatus to derive the exit code.
|
||||
if let Some(code) = status.code() {
|
||||
std::process::exit(code);
|
||||
} else if let Some(signal) = status.signal() {
|
||||
std::process::exit(128 + signal);
|
||||
} else {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn handle_exit_status(status: std::process::ExitStatus) -> ! {
|
||||
if let Some(code) = status.code() {
|
||||
std::process::exit(code);
|
||||
} else {
|
||||
// Rare on Windows, but if it happens: use fallback code.
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! `debug landlock` implementation for the Codex CLI.
|
||||
//!
|
||||
//! On Linux the command is executed inside a Landlock + seccomp sandbox by
|
||||
//! calling the low-level `exec_linux` helper from `codex_core::linux`.
|
||||
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::process;
|
||||
use std::process::Command;
|
||||
use std::process::ExitStatus;
|
||||
|
||||
/// Execute `command` in a Linux sandbox (Landlock + seccomp) the way Codex
|
||||
/// would.
|
||||
pub fn run_landlock(command: Vec<String>, sandbox_policy: SandboxPolicy) -> anyhow::Result<()> {
|
||||
if command.is_empty() {
|
||||
anyhow::bail!("command args are empty");
|
||||
}
|
||||
|
||||
// Spawn a new thread and apply the sandbox policies there.
|
||||
let handle = std::thread::spawn(move || -> anyhow::Result<ExitStatus> {
|
||||
codex_core::linux::apply_sandbox_policy_to_current_thread(sandbox_policy)?;
|
||||
let status = Command::new(&command[0]).args(&command[1..]).status()?;
|
||||
Ok(status)
|
||||
});
|
||||
let status = handle
|
||||
.join()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to join thread: {e:?}"))??;
|
||||
|
||||
// Use ExitStatus to derive the exit code.
|
||||
if let Some(code) = status.code() {
|
||||
process::exit(code);
|
||||
} else if let Some(signal) = status.signal() {
|
||||
process::exit(128 + signal);
|
||||
} else {
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod landlock;
|
||||
pub mod debug_sandbox;
|
||||
mod exit_status;
|
||||
pub mod proto;
|
||||
pub mod seatbelt;
|
||||
|
||||
use clap::Parser;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::SandboxPermissionOption;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_common::SandboxPermissionOption;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct SeatbeltCommand {
|
||||
@@ -16,6 +15,9 @@ pub struct SeatbeltCommand {
|
||||
#[clap(flatten)]
|
||||
pub sandbox: SandboxPermissionOption,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
/// Full command args to run under seatbelt.
|
||||
#[arg(trailing_var_arg = true)]
|
||||
pub command: Vec<String>,
|
||||
@@ -30,18 +32,10 @@ pub struct LandlockCommand {
|
||||
#[clap(flatten)]
|
||||
pub sandbox: SandboxPermissionOption,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
/// Full command args to run under landlock.
|
||||
#[arg(trailing_var_arg = true)]
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn create_sandbox_policy(full_auto: bool, sandbox: SandboxPermissionOption) -> SandboxPolicy {
|
||||
if full_auto {
|
||||
SandboxPolicy::new_full_auto_policy()
|
||||
} else {
|
||||
match sandbox.permissions.map(Into::into) {
|
||||
Some(sandbox_policy) => sandbox_policy,
|
||||
None => SandboxPolicy::new_read_only_policy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() -> anyhow::Result<()> {
|
||||
eprintln!("codex-linux-sandbox is not supported on this platform.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() -> anyhow::Result<()> {
|
||||
use clap::Parser;
|
||||
use codex_cli::create_sandbox_policy;
|
||||
use codex_cli::landlock;
|
||||
use codex_cli::LandlockCommand;
|
||||
|
||||
let LandlockCommand {
|
||||
full_auto,
|
||||
sandbox,
|
||||
command,
|
||||
} = LandlockCommand::parse();
|
||||
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
|
||||
landlock::run_landlock(command, sandbox_policy)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
use clap::Parser;
|
||||
use codex_cli::create_sandbox_policy;
|
||||
use codex_cli::proto;
|
||||
use codex_cli::seatbelt;
|
||||
use codex_cli::LandlockCommand;
|
||||
use codex_cli::SeatbeltCommand;
|
||||
use codex_cli::proto;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_exec::Cli as ExecCli;
|
||||
use codex_repl::Cli as ReplCli;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::proto::ProtoCli;
|
||||
|
||||
@@ -21,6 +20,9 @@ use crate::proto::ProtoCli;
|
||||
subcommand_negates_reqs = true
|
||||
)]
|
||||
struct MultitoolCli {
|
||||
#[clap(flatten)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
#[clap(flatten)]
|
||||
interactive: TuiCli,
|
||||
|
||||
@@ -34,9 +36,8 @@ enum Subcommand {
|
||||
#[clap(visible_alias = "e")]
|
||||
Exec(ExecCli),
|
||||
|
||||
/// Run the REPL.
|
||||
#[clap(visible_alias = "r")]
|
||||
Repl(ReplCli),
|
||||
/// Experimental: run Codex as an MCP server.
|
||||
Mcp,
|
||||
|
||||
/// Run the Protocol stream via stdin/stdout
|
||||
#[clap(visible_alias = "p")]
|
||||
@@ -64,47 +65,63 @@ enum DebugCommand {
|
||||
#[derive(Debug, Parser)]
|
||||
struct ReplProto {}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
fn main() -> anyhow::Result<()> {
|
||||
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
|
||||
cli_main(codex_linux_sandbox_exe).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let cli = MultitoolCli::parse();
|
||||
|
||||
match cli.subcommand {
|
||||
None => {
|
||||
codex_tui::run_main(cli.interactive)?;
|
||||
let mut tui_cli = cli.interactive;
|
||||
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
||||
codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
|
||||
}
|
||||
Some(Subcommand::Exec(exec_cli)) => {
|
||||
codex_exec::run_main(exec_cli).await?;
|
||||
Some(Subcommand::Exec(mut exec_cli)) => {
|
||||
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
|
||||
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::Repl(repl_cli)) => {
|
||||
codex_repl::run_main(repl_cli).await?;
|
||||
Some(Subcommand::Mcp) => {
|
||||
codex_mcp_server::run_main(codex_linux_sandbox_exe).await?;
|
||||
}
|
||||
Some(Subcommand::Proto(proto_cli)) => {
|
||||
Some(Subcommand::Proto(mut proto_cli)) => {
|
||||
prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides);
|
||||
proto::run_main(proto_cli).await?;
|
||||
}
|
||||
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
|
||||
DebugCommand::Seatbelt(SeatbeltCommand {
|
||||
command,
|
||||
sandbox,
|
||||
full_auto,
|
||||
}) => {
|
||||
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
|
||||
seatbelt::run_seatbelt(command, sandbox_policy).await?;
|
||||
DebugCommand::Seatbelt(mut seatbelt_cli) => {
|
||||
prepend_config_flags(&mut seatbelt_cli.config_overrides, cli.config_overrides);
|
||||
codex_cli::debug_sandbox::run_command_under_seatbelt(
|
||||
seatbelt_cli,
|
||||
codex_linux_sandbox_exe,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
DebugCommand::Landlock(LandlockCommand {
|
||||
command,
|
||||
sandbox,
|
||||
full_auto,
|
||||
}) => {
|
||||
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
|
||||
codex_cli::landlock::run_landlock(command, sandbox_policy)?;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
DebugCommand::Landlock(_) => {
|
||||
anyhow::bail!("Landlock is only supported on Linux.");
|
||||
DebugCommand::Landlock(mut landlock_cli) => {
|
||||
prepend_config_flags(&mut landlock_cli.config_overrides, cli.config_overrides);
|
||||
codex_cli::debug_sandbox::run_command_under_landlock(
|
||||
landlock_cli,
|
||||
codex_linux_sandbox_exe,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepend root-level overrides so they have lower precedence than
|
||||
/// CLI-specific ones specified after the subcommand (if any).
|
||||
fn prepend_config_flags(
|
||||
subcommand_config_overrides: &mut CliConfigOverrides,
|
||||
cli_config_overrides: CliConfigOverrides,
|
||||
) {
|
||||
subcommand_config_overrides
|
||||
.raw_overrides
|
||||
.splice(0..0, cli_config_overrides.raw_overrides);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
use std::io::IsTerminal;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::Codex;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_core::util::notify_on_sigint;
|
||||
use codex_core::Codex;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct ProtoCli {}
|
||||
pub struct ProtoCli {
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
}
|
||||
|
||||
pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> {
|
||||
pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
|
||||
if std::io::stdin().is_terminal() {
|
||||
anyhow::bail!("Protocol mode expects stdin to be a pipe, not a terminal");
|
||||
}
|
||||
@@ -21,8 +28,15 @@ pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> {
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let ProtoCli { config_overrides } = opts;
|
||||
let overrides_vec = config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
|
||||
let ctrl_c = notify_on_sigint();
|
||||
let codex = Codex::spawn(ctrl_c.clone())?;
|
||||
let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await?;
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
// Task that reads JSON lines from stdin and forwards to Submission Queue
|
||||
let sq_fut = {
|
||||
@@ -48,7 +62,7 @@ pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> {
|
||||
}
|
||||
match serde_json::from_str::<Submission>(line) {
|
||||
Ok(sub) => {
|
||||
if let Err(e) = codex.submit(sub).await {
|
||||
if let Err(e) = codex.submit_with_id(sub).await {
|
||||
error!("{e:#}");
|
||||
break;
|
||||
}
|
||||
@@ -76,8 +90,13 @@ pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> {
|
||||
};
|
||||
match event {
|
||||
Ok(event) => {
|
||||
let event_str =
|
||||
serde_json::to_string(&event).expect("JSON serialization failed");
|
||||
let event_str = match serde_json::to_string(&event) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to serialize event: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
println!("{event_str}");
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
use codex_core::exec::create_seatbelt_command;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
|
||||
pub async fn run_seatbelt(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
) -> anyhow::Result<()> {
|
||||
let seatbelt_command = create_seatbelt_command(command, &sandbox_policy);
|
||||
let status = tokio::process::Command::new(seatbelt_command[0].clone())
|
||||
.args(&seatbelt_command[1..])
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn command: {}", e))?
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to wait for command: {}", e))?;
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user