mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
5 Commits
dev/cc/new
...
fix-bazel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e194a169f | ||
|
|
6c1b2c448a | ||
|
|
0a04f4dd69 | ||
|
|
06e61a6e59 | ||
|
|
b582daf2d9 |
15
.bazelrc
15
.bazelrc
@@ -1,7 +1,5 @@
|
||||
common --repo_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1
|
||||
common --repo_env=BAZEL_NO_APPLE_CPP_TOOLCHAIN=1
|
||||
# Dummy xcode config so we don't need to build xcode_locator in repo rule.
|
||||
common --xcode_version_config=//:disable_xcode
|
||||
|
||||
common --disk_cache=~/.cache/bazel-disk-cache
|
||||
common --repo_contents_cache=~/.cache/bazel-repo-contents-cache
|
||||
@@ -11,9 +9,6 @@ startup --experimental_remote_repo_contents_cache
|
||||
|
||||
common --experimental_platform_in_output_dir
|
||||
|
||||
# Runfiles strategy rationale: codex-rs/utils/cargo-bin/README.md
|
||||
common --noenable_runfiles
|
||||
|
||||
common --enable_platform_specific_config
|
||||
# TODO(zbarsky): We need to untangle these libc constraints to get linux remote builds working.
|
||||
common:linux --host_platform=//:local
|
||||
@@ -28,7 +23,14 @@ common:windows --@rules_rust//rust/settings:experimental_use_sh_toolchain_for_bo
|
||||
|
||||
common --incompatible_strict_action_env
|
||||
# Not ideal, but We need to allow dotslash to be found
|
||||
common --test_env=PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
common:linux --test_env=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
common:macos --test_env=PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
|
||||
# Pass through some env vars Windows needs to use powershell?
|
||||
common:windows --test_env=PATH
|
||||
common:windows --test_env=SYSTEMROOT
|
||||
common:windows --test_env=COMSPEC
|
||||
common:windows --test_env=WINDIR
|
||||
|
||||
common --test_output=errors
|
||||
common --bes_results_url=https://app.buildbuddy.io/invocation/
|
||||
@@ -49,3 +51,4 @@ common --jobs=30
|
||||
common:remote --extra_execution_platforms=//:rbe
|
||||
common:remote --remote_executor=grpcs://remote.buildbuddy.io
|
||||
common:remote --jobs=800
|
||||
|
||||
|
||||
4
.github/codex/labels/codex-rust-review.md
vendored
4
.github/codex/labels/codex-rust-review.md
vendored
@@ -15,10 +15,10 @@ Things to look out for when doing the review:
|
||||
|
||||
## Code Organization
|
||||
|
||||
- Each crate in the Cargo workspace in `codex-rs` has a specific purpose: make a note if you believe new code is not introduced in the correct crate.
|
||||
- Each create in the Cargo workspace in `codex-rs` has a specific purpose: make a note if you believe new code is not introduced in the correct crate.
|
||||
- When possible, try to keep the `core` crate as small as possible. Non-core but shared logic is often a good candidate for `codex-rs/common`.
|
||||
- Be wary of large files and offer suggestions for how to break things into more reasonably-sized files.
|
||||
- Rust files should generally be organized such that the public parts of the API appear near the top of the file and helper functions go below. This is analogous to the "inverted pyramid" structure that is favored in journalism.
|
||||
- Rust files should generally be organized such that the public parts of the API appear near the top of the file and helper functions go below. This is analagous to the "inverted pyramid" structure that is favored in journalism.
|
||||
|
||||
## Assertions in Tests
|
||||
|
||||
|
||||
14
.github/workflows/bazel.yml
vendored
14
.github/workflows/bazel.yml
vendored
@@ -10,11 +10,6 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# Cancel previous actions from the same PR or branch except 'main' branch.
|
||||
# See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info.
|
||||
group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}}
|
||||
cancel-in-progress: ${{ github.ref_name != 'main' }}
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
@@ -36,9 +31,10 @@ jobs:
|
||||
target: aarch64-unknown-linux-musl
|
||||
- os: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
# TODO: Enable Windows once we fix the toolchain issues there.
|
||||
#- os: windows-latest
|
||||
# target: x86_64-pc-windows-gnullvm
|
||||
|
||||
# Windows
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-gnullvm
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
# Configure a human readable name for each job
|
||||
@@ -95,7 +91,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Use a very short path to reduce argv/path length issues.
|
||||
"BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"BAZEL_STARTUP_ARGS=--output_user_root=D:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: bazel test //...
|
||||
env:
|
||||
|
||||
13
.github/workflows/ci.bazelrc
vendored
13
.github/workflows/ci.bazelrc
vendored
@@ -1,6 +1,13 @@
|
||||
common --remote_download_minimal
|
||||
common --nobuild_runfile_links
|
||||
common --keep_going
|
||||
common --verbose_failures
|
||||
|
||||
# Disable disk cache since we have remote one and aren't using persistent workers.
|
||||
common --disk_cache=
|
||||
|
||||
# Rearrange caches on Windows so they're on the same volume as the checkout.
|
||||
common:windows --repo_contents_cache=D:/a/.cache/bazel-repo-contents-cache
|
||||
common:windows --repository_cache=D:/a/.cache/bazel-repo-cache
|
||||
|
||||
# We prefer to run the build actions entirely remotely so we can dial up the concurrency.
|
||||
# We have platform-specific tests, so we want to execute the tests on all platforms using the strongest sandboxing available on each platform.
|
||||
@@ -16,5 +23,5 @@ common:macos --config=remote
|
||||
common:macos --strategy=remote
|
||||
common:macos --strategy=TestRunner=darwin-sandbox,local
|
||||
|
||||
common:windows --strategy=TestRunner=local
|
||||
|
||||
# On windows we can not cross-build the tests but run them locally due to what appears to be a Bazel bug
|
||||
# (windows vs unix path confusion)
|
||||
|
||||
6
.github/workflows/shell-tool-mcp.yml
vendored
6
.github/workflows/shell-tool-mcp.yml
vendored
@@ -436,6 +436,11 @@ jobs:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -443,7 +448,6 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
scope: "@openai"
|
||||
|
||||
# Trusted publishing requires npm CLI version 11.5.1 or later.
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
load("@apple_support//xcode:xcode_config.bzl", "xcode_config")
|
||||
|
||||
xcode_config(name = "disable_xcode")
|
||||
|
||||
# We mark the local platform as glibc-compatible so that rust can grab a toolchain for us.
|
||||
# TODO(zbarsky): Upstream a better libc constraint into rules_rust.
|
||||
# We only enable this on linux though for sanity, and because it breaks remote execution.
|
||||
|
||||
11
MODULE.bazel
11
MODULE.bazel
@@ -27,8 +27,6 @@ register_toolchains(
|
||||
"@toolchains_llvm_bootstrapped//toolchain:all",
|
||||
)
|
||||
|
||||
# Needed to disable xcode...
|
||||
bazel_dep(name = "apple_support", version = "2.1.0")
|
||||
bazel_dep(name = "rules_cc", version = "0.2.16")
|
||||
bazel_dep(name = "rules_platform", version = "0.1.0")
|
||||
bazel_dep(name = "rules_rust", version = "0.68.1")
|
||||
@@ -39,6 +37,7 @@ single_version_override(
|
||||
"//patches:rules_rust.patch",
|
||||
"//patches:rules_rust_windows_gnu.patch",
|
||||
"//patches:rules_rust_musl.patch",
|
||||
"//patches:rules_rust_direct_deps.patch",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -55,7 +54,8 @@ rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
|
||||
rust.toolchain(
|
||||
edition = "2024",
|
||||
extra_target_triples = RUST_TRIPLES,
|
||||
versions = ["1.93.0"],
|
||||
# TODO(zbarsky): bump to 1.93 after fixing mingw
|
||||
versions = ["1.92.0"],
|
||||
)
|
||||
use_repo(rust, "rust_toolchains")
|
||||
|
||||
@@ -92,11 +92,6 @@ crate.annotation(
|
||||
|
||||
inject_repo(crate, "openssl")
|
||||
|
||||
crate.annotation(
|
||||
crate = "runfiles",
|
||||
workspace_cargo_toml = "rust/runfiles/Cargo.toml",
|
||||
)
|
||||
|
||||
# Fix readme inclusions
|
||||
crate.annotation(
|
||||
crate = "windows-link",
|
||||
|
||||
4
MODULE.bazel.lock
generated
4
MODULE.bazel.lock
generated
@@ -21,8 +21,7 @@
|
||||
"https://bcr.bazel.build/modules/apple_support/1.23.0/MODULE.bazel": "317d47e3f65b580e7fb4221c160797fda48e32f07d2dfff63d754ef2316dcd25",
|
||||
"https://bcr.bazel.build/modules/apple_support/1.24.1/MODULE.bazel": "f46e8ddad60aef170ee92b2f3d00ef66c147ceafea68b6877cb45bd91737f5f8",
|
||||
"https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4",
|
||||
"https://bcr.bazel.build/modules/apple_support/2.1.0/MODULE.bazel": "b15c125dabed01b6803c129cd384de4997759f02f8ec90dc5136bcf6dfc5086a",
|
||||
"https://bcr.bazel.build/modules/apple_support/2.1.0/source.json": "78064cfefe18dee4faaf51893661e0d403784f3efe88671d727cdcdc67ed8fb3",
|
||||
"https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
|
||||
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
|
||||
@@ -791,7 +790,6 @@
|
||||
"gimli_0.31.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"fallible-iterator\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"test-assembler\",\"req\":\"^0.1.3\"}],\"features\":{\"default\":[\"read-all\",\"write\"],\"endian-reader\":[\"read\",\"dep:stable_deref_trait\"],\"fallible-iterator\":[\"dep:fallible-iterator\"],\"read\":[\"read-core\"],\"read-all\":[\"read\",\"std\",\"fallible-iterator\",\"endian-reader\"],\"read-core\":[],\"rustc-dep-of-std\":[\"dep:core\",\"dep:alloc\",\"dep:compiler_builtins\"],\"std\":[\"fallible-iterator?/std\",\"stable_deref_trait?/std\"],\"write\":[\"dep:indexmap\"]}}",
|
||||
"git+https://github.com/JakkuSakura/tokio-tungstenite?rev=2ae536b0de793f3ddf31fc2f22d445bf1ef2023d#2ae536b0de793f3ddf31fc2f22d445bf1ef2023d_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}",
|
||||
"git+https://github.com/JakkuSakura/tungstenite-rs?rev=f514de8644821113e5d18a027d6d28a5c8cc0a6e#f514de8644821113e5d18a027d6d28a5c8cc0a6e_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"handshake\":[\"data-encoding\",\"http\",\"httparse\",\"sha1\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}",
|
||||
"git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81_runfiles": "{\"dependencies\":[],\"features\":{},\"strip_prefix\":\"\"}",
|
||||
"git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"nucleo-matcher\",\"optional\":false},{\"default_features\":true,\"features\":[\"send_guard\",\"arc_lock\"],\"name\":\"parking_lot\",\"optional\":false},{\"name\":\"rayon\"}],\"features\":{},\"strip_prefix\":\"\"}",
|
||||
"git+https://github.com/helix-editor/nucleo.git?rev=4253de9faabb4e5c6d81d946a5e35a90f87347ee#4253de9faabb4e5c6d81d946a5e35a90f87347ee_nucleo-matcher": "{\"dependencies\":[{\"name\":\"memchr\"},{\"default_features\":true,\"features\":[],\"name\":\"unicode-segmentation\",\"optional\":true}],\"features\":{\"default\":[\"unicode-normalization\",\"unicode-casefold\",\"unicode-segmentation\"],\"unicode-casefold\":[],\"unicode-normalization\":[],\"unicode-segmentation\":[\"dep:unicode-segmentation\"]},\"strip_prefix\":\"matcher\"}",
|
||||
"git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}",
|
||||
|
||||
70
PNPM.md
Normal file
70
PNPM.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Migration to pnpm
|
||||
|
||||
This project has been migrated from npm to pnpm to improve dependency management and developer experience.
|
||||
|
||||
## Why pnpm?
|
||||
|
||||
- **Faster installation**: pnpm is significantly faster than npm and yarn
|
||||
- **Disk space savings**: pnpm uses a content-addressable store to avoid duplication
|
||||
- **Phantom dependency prevention**: pnpm creates a strict node_modules structure
|
||||
- **Native workspaces support**: simplified monorepo management
|
||||
|
||||
## How to use pnpm
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Global installation of pnpm
|
||||
npm install -g pnpm@10.28.2
|
||||
|
||||
# Or with corepack (available with Node.js 22+)
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.8.1 --activate
|
||||
```
|
||||
|
||||
### Common commands
|
||||
|
||||
| npm command | pnpm equivalent |
|
||||
| --------------- | ---------------- |
|
||||
| `npm install` | `pnpm install` |
|
||||
| `npm run build` | `pnpm run build` |
|
||||
| `npm test` | `pnpm test` |
|
||||
| `npm run lint` | `pnpm run lint` |
|
||||
|
||||
### Workspace-specific commands
|
||||
|
||||
| Action | Command |
|
||||
| ------------------------------------------ | ---------------------------------------- |
|
||||
| Run a command in a specific package | `pnpm --filter @openai/codex run build` |
|
||||
| Install a dependency in a specific package | `pnpm --filter @openai/codex add lodash` |
|
||||
| Run a command in all packages | `pnpm -r run test` |
|
||||
|
||||
## Monorepo structure
|
||||
|
||||
```
|
||||
codex/
|
||||
├── pnpm-workspace.yaml # Workspace configuration
|
||||
├── .npmrc # pnpm configuration
|
||||
├── package.json # Root dependencies and scripts
|
||||
├── codex-cli/ # Main package
|
||||
│ └── package.json # codex-cli specific dependencies
|
||||
└── docs/ # Documentation (future package)
|
||||
```
|
||||
|
||||
## Configuration files
|
||||
|
||||
- **pnpm-workspace.yaml**: Defines the packages included in the monorepo
|
||||
- **.npmrc**: Configures pnpm behavior
|
||||
- **Root package.json**: Contains shared scripts and dependencies
|
||||
|
||||
## CI/CD
|
||||
|
||||
CI/CD workflows have been updated to use pnpm instead of npm. Make sure your CI environments use pnpm 10.28.2 or higher.
|
||||
|
||||
## Known issues
|
||||
|
||||
If you encounter issues with pnpm, try the following solutions:
|
||||
|
||||
1. Remove the `node_modules` folder and `pnpm-lock.yaml` file, then run `pnpm install`
|
||||
2. Make sure you're using pnpm 10.28.2 or higher
|
||||
3. Verify that Node.js 22 or higher is installed
|
||||
@@ -1,7 +1,7 @@
|
||||
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install --cask codex</code></p>
|
||||
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.
|
||||
<p align="center">
|
||||
<img src="https://github.com/openai/codex/blob/main/.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
|
||||
<img src="./.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
|
||||
</p>
|
||||
</br>
|
||||
If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="https://developers.openai.com/codex/ide">install in your IDE.</a>
|
||||
|
||||
@@ -14,4 +14,4 @@ target_app = "cli"
|
||||
[[announcements]]
|
||||
content = "This is a test announcement"
|
||||
version_regex = "^0\\.0\\.0$"
|
||||
to_date = "2026-05-10"
|
||||
to_date = "2026-01-10"
|
||||
|
||||
18
codex-cli/package-lock.json
generated
Normal file
18
codex-cli/package-lock.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openai/codex",
|
||||
"version": "0.0.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.0.0-dev",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,5 @@
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openai/codex.git",
|
||||
"directory": "codex-cli"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264"
|
||||
}
|
||||
}
|
||||
|
||||
57
codex-rs/Cargo.lock
generated
57
codex-rs/Cargo.lock
generated
@@ -339,7 +339,6 @@ dependencies = [
|
||||
"core_test_support",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"wiremock",
|
||||
@@ -1080,7 +1079,6 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"app_test_support",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -1088,7 +1086,6 @@ dependencies = [
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-chatgpt",
|
||||
"codex-cloud-requirements",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-execpolicy",
|
||||
@@ -1107,7 +1104,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -1222,12 +1218,10 @@ dependencies = [
|
||||
"codex-core",
|
||||
"codex-git",
|
||||
"codex-utils-cargo-bin",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1293,24 +1287,6 @@ dependencies = [
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-cloud-requirements"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"codex-backend-client",
|
||||
"codex-core",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"pretty_assertions",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.9.5",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-cloud-tasks"
|
||||
version = "0.0.0"
|
||||
@@ -1401,7 +1377,6 @@ dependencies = [
|
||||
"codex-state",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-home-dir",
|
||||
"codex-utils-pty",
|
||||
"codex-utils-readiness",
|
||||
"codex-utils-string",
|
||||
@@ -1409,6 +1384,7 @@ dependencies = [
|
||||
"core-foundation 0.9.4",
|
||||
"core_test_support",
|
||||
"ctor 0.6.3",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"env-flags",
|
||||
@@ -1427,7 +1403,6 @@ dependencies = [
|
||||
"multimap",
|
||||
"once_cell",
|
||||
"openssl-sys",
|
||||
"opentelemetry_sdk",
|
||||
"os_info",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
@@ -1452,7 +1427,6 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"toml 0.9.5",
|
||||
"toml_edit 0.24.0+spec-1.1.0",
|
||||
@@ -1490,7 +1464,6 @@ dependencies = [
|
||||
"assert_cmd",
|
||||
"clap",
|
||||
"codex-arg0",
|
||||
"codex-cloud-requirements",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-protocol",
|
||||
@@ -1780,7 +1753,6 @@ dependencies = [
|
||||
"strum_macros 0.27.2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
@@ -1847,7 +1819,7 @@ dependencies = [
|
||||
"codex-keyring-store",
|
||||
"codex-protocol",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-home-dir",
|
||||
"dirs",
|
||||
"futures",
|
||||
"keyring",
|
||||
"mcp-types",
|
||||
@@ -1875,18 +1847,14 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"dirs",
|
||||
"owo-colors",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1916,9 +1884,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-chatgpt",
|
||||
"codex-cli",
|
||||
"codex-cloud-requirements",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-feedback",
|
||||
@@ -1926,13 +1892,13 @@ dependencies = [
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-protocol",
|
||||
"codex-state",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-pty",
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"ctor 0.6.3",
|
||||
"derive_more 2.1.1",
|
||||
"diffy",
|
||||
"dirs",
|
||||
@@ -2008,19 +1974,10 @@ name = "codex-utils-cargo-bin"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"assert_cmd",
|
||||
"runfiles",
|
||||
"path-absolutize",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-home-dir"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"pretty_assertions",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-image"
|
||||
version = "0.0.0"
|
||||
@@ -4686,7 +4643,6 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tokio",
|
||||
"wiremock",
|
||||
]
|
||||
@@ -6634,11 +6590,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runfiles"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.25"
|
||||
|
||||
@@ -11,7 +11,6 @@ members = [
|
||||
"arg0",
|
||||
"feedback",
|
||||
"codex-backend-openapi-models",
|
||||
"cloud-requirements",
|
||||
"cloud-tasks",
|
||||
"cloud-tasks-client",
|
||||
"cli",
|
||||
@@ -43,7 +42,6 @@ members = [
|
||||
"utils/cache",
|
||||
"utils/image",
|
||||
"utils/json-to-toml",
|
||||
"utils/home-dir",
|
||||
"utils/pty",
|
||||
"utils/readiness",
|
||||
"utils/string",
|
||||
@@ -73,7 +71,6 @@ codex-apply-patch = { path = "apply-patch" }
|
||||
codex-arg0 = { path = "arg0" }
|
||||
codex-async-utils = { path = "async-utils" }
|
||||
codex-backend-client = { path = "backend-client" }
|
||||
codex-cloud-requirements = { path = "cloud-requirements" }
|
||||
codex-chatgpt = { path = "chatgpt" }
|
||||
codex-cli = { path = "cli"}
|
||||
codex-client = { path = "codex-client" }
|
||||
@@ -103,7 +100,6 @@ codex-utils-cache = { path = "utils/cache" }
|
||||
codex-utils-cargo-bin = { path = "utils/cargo-bin" }
|
||||
codex-utils-image = { path = "utils/image" }
|
||||
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
|
||||
codex-utils-home-dir = { path = "utils/home-dir" }
|
||||
codex-utils-pty = { path = "utils/pty" }
|
||||
codex-utils-readiness = { path = "utils/readiness" }
|
||||
codex-utils-string = { path = "utils/string" }
|
||||
@@ -190,7 +186,6 @@ regex = "1.12.2"
|
||||
regex-lite = "0.1.8"
|
||||
reqwest = "0.12"
|
||||
rmcp = { version = "0.12.0", default-features = false }
|
||||
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
|
||||
schemars = "0.8.22"
|
||||
seccompiler = "0.5.0"
|
||||
sentry = "0.46.0"
|
||||
|
||||
@@ -23,22 +23,11 @@ impl GitSha {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication mode for OpenAI-backed providers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMode {
|
||||
/// OpenAI API key provided by the caller and stored by Codex.
|
||||
ApiKey,
|
||||
/// ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).
|
||||
Chatgpt,
|
||||
/// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.
|
||||
///
|
||||
/// ChatGPT auth tokens are supplied by an external host app and are only
|
||||
/// stored in memory. Token refresh must be handled by the external host app.
|
||||
#[serde(rename = "chatgptAuthTokens")]
|
||||
#[ts(rename = "chatgptAuthTokens")]
|
||||
#[strum(serialize = "chatgptAuthTokens")]
|
||||
ChatgptAuthTokens,
|
||||
ChatGPT,
|
||||
}
|
||||
|
||||
/// Generates an `enum ClientRequest` where each variant is a request that the
|
||||
@@ -128,10 +117,6 @@ client_request_definitions! {
|
||||
params: v2::ThreadArchiveParams,
|
||||
response: v2::ThreadArchiveResponse,
|
||||
},
|
||||
ThreadSetName => "thread/name/set" {
|
||||
params: v2::ThreadSetNameParams,
|
||||
response: v2::ThreadSetNameResponse,
|
||||
},
|
||||
ThreadUnarchive => "thread/unarchive" {
|
||||
params: v2::ThreadUnarchiveParams,
|
||||
response: v2::ThreadUnarchiveResponse,
|
||||
@@ -549,11 +534,6 @@ server_request_definitions! {
|
||||
response: v2::DynamicToolCallResponse,
|
||||
},
|
||||
|
||||
ChatgptAuthTokensRefresh => "account/chatgptAuthTokens/refresh" {
|
||||
params: v2::ChatgptAuthTokensRefreshParams,
|
||||
response: v2::ChatgptAuthTokensRefreshResponse,
|
||||
},
|
||||
|
||||
/// DEPRECATED APIs below
|
||||
/// Request to approve a patch.
|
||||
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
||||
@@ -598,7 +578,6 @@ server_notification_definitions! {
|
||||
/// NEW NOTIFICATIONS
|
||||
Error => "error" (v2::ErrorNotification),
|
||||
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
|
||||
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
|
||||
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
|
||||
TurnStarted => "turn/started" (v2::TurnStartedNotification),
|
||||
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
|
||||
@@ -609,8 +588,6 @@ server_notification_definitions! {
|
||||
/// This event is internal-only. Used by Codex Cloud.
|
||||
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
|
||||
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
||||
/// EXPERIMENTAL - proposed plan streaming deltas for plan items.
|
||||
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
|
||||
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
||||
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
||||
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
|
||||
@@ -776,29 +753,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_chatgpt_auth_tokens_refresh_request() -> Result<()> {
|
||||
let request = ServerRequest::ChatgptAuthTokensRefresh {
|
||||
request_id: RequestId::Integer(8),
|
||||
params: v2::ChatgptAuthTokensRefreshParams {
|
||||
reason: v2::ChatgptAuthTokensRefreshReason::Unauthorized,
|
||||
previous_account_id: Some("org-123".to_string()),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "account/chatgptAuthTokens/refresh",
|
||||
"id": 8,
|
||||
"params": {
|
||||
"reason": "unauthorized",
|
||||
"previousAccountId": "org-123"
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_get_account_rate_limits() -> Result<()> {
|
||||
let request = ClientRequest::GetAccountRateLimits {
|
||||
@@ -888,34 +842,10 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> {
|
||||
let request = ClientRequest::LoginAccount {
|
||||
request_id: RequestId::Integer(5),
|
||||
params: v2::LoginAccountParams::ChatgptAuthTokens {
|
||||
access_token: "access-token".to_string(),
|
||||
id_token: "id-token".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "account/login/start",
|
||||
"id": 5,
|
||||
"params": {
|
||||
"type": "chatgptAuthTokens",
|
||||
"accessToken": "access-token",
|
||||
"idToken": "id-token"
|
||||
}
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_get_account() -> Result<()> {
|
||||
let request = ClientRequest::GetAccount {
|
||||
request_id: RequestId::Integer(6),
|
||||
request_id: RequestId::Integer(5),
|
||||
params: v2::GetAccountParams {
|
||||
refresh_token: false,
|
||||
},
|
||||
@@ -923,7 +853,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "account/read",
|
||||
"id": 6,
|
||||
"id": 5,
|
||||
"params": {
|
||||
"refreshToken": false
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::protocol::v2::UserInput;
|
||||
use codex_protocol::protocol::AgentReasoningEvent;
|
||||
use codex_protocol::protocol::AgentReasoningRawContentEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ItemCompletedEvent;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
@@ -56,7 +55,6 @@ impl ThreadHistoryBuilder {
|
||||
EventMsg::AgentReasoningRawContent(payload) => {
|
||||
self.handle_agent_reasoning_raw_content(payload)
|
||||
}
|
||||
EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload),
|
||||
EventMsg::TokenCount(_) => {}
|
||||
EventMsg::EnteredReviewMode(_) => {}
|
||||
EventMsg::ExitedReviewMode(_) => {}
|
||||
@@ -127,19 +125,6 @@ impl ThreadHistoryBuilder {
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) {
|
||||
if let codex_protocol::items::TurnItem::Plan(plan) = &payload.item {
|
||||
if plan.text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let id = self.next_item_id();
|
||||
self.ensure_turn().items.push(ThreadItem::Plan {
|
||||
id,
|
||||
text: plan.text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_turn_aborted(&mut self, _payload: &TurnAbortedEvent) {
|
||||
let Some(turn) = self.current_turn.as_mut() else {
|
||||
return;
|
||||
|
||||
@@ -86,10 +86,6 @@ macro_rules! v2_enum_from_core {
|
||||
pub enum CodexErrorInfo {
|
||||
ContextWindowExceeded,
|
||||
UsageLimitExceeded,
|
||||
ModelCap {
|
||||
model: String,
|
||||
reset_after_seconds: Option<u64>,
|
||||
},
|
||||
HttpConnectionFailed {
|
||||
#[serde(rename = "httpStatusCode")]
|
||||
#[ts(rename = "httpStatusCode")]
|
||||
@@ -126,13 +122,6 @@ impl From<CoreCodexErrorInfo> for CodexErrorInfo {
|
||||
match value {
|
||||
CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
|
||||
CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CoreCodexErrorInfo::ModelCap {
|
||||
model,
|
||||
reset_after_seconds,
|
||||
} => CodexErrorInfo::ModelCap {
|
||||
model,
|
||||
reset_after_seconds,
|
||||
},
|
||||
CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => {
|
||||
CodexErrorInfo::HttpConnectionFailed { http_status_code }
|
||||
}
|
||||
@@ -497,14 +486,6 @@ pub struct ConfigReadResponse {
|
||||
pub struct ConfigRequirements {
|
||||
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
|
||||
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ResidencyRequirement {
|
||||
Us,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -843,24 +824,6 @@ pub enum LoginAccountParams {
|
||||
#[serde(rename = "chatgpt")]
|
||||
#[ts(rename = "chatgpt")]
|
||||
Chatgpt,
|
||||
/// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.
|
||||
/// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.
|
||||
#[serde(rename = "chatgptAuthTokens")]
|
||||
#[ts(rename = "chatgptAuthTokens")]
|
||||
ChatgptAuthTokens {
|
||||
/// ID token (JWT) supplied by the client.
|
||||
///
|
||||
/// This token is used for identity and account metadata (email, plan type,
|
||||
/// workspace id).
|
||||
#[serde(rename = "idToken")]
|
||||
#[ts(rename = "idToken")]
|
||||
id_token: String,
|
||||
/// Access token (JWT) supplied by the client.
|
||||
/// This token is used for backend API requests.
|
||||
#[serde(rename = "accessToken")]
|
||||
#[ts(rename = "accessToken")]
|
||||
access_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -880,9 +843,6 @@ pub enum LoginAccountResponse {
|
||||
/// URL the client should open in a browser to initiate the OAuth flow.
|
||||
auth_url: String,
|
||||
},
|
||||
#[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")]
|
||||
#[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")]
|
||||
ChatgptAuthTokens {},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -913,37 +873,6 @@ pub struct CancelLoginAccountResponse {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct LogoutAccountResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ChatgptAuthTokensRefreshReason {
|
||||
/// Codex attempted a backend request and received `401 Unauthorized`.
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ChatgptAuthTokensRefreshParams {
|
||||
pub reason: ChatgptAuthTokensRefreshReason,
|
||||
/// Workspace/account identifier that Codex was previously using.
|
||||
///
|
||||
/// Clients that manage multiple accounts/workspaces can use this as a hint
|
||||
/// to refresh the token for the correct workspace.
|
||||
///
|
||||
/// This may be `null` when the prior ID token did not include a workspace
|
||||
/// identifier (`chatgpt_account_id`) or when the token could not be parsed.
|
||||
pub previous_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ChatgptAuthTokensRefreshResponse {
|
||||
pub id_token: String,
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -955,11 +884,6 @@ pub struct GetAccountRateLimitsResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GetAccountParams {
|
||||
/// When `true`, requests a proactive token refresh before returning.
|
||||
///
|
||||
/// In managed auth mode this triggers the normal refresh-token flow. In
|
||||
/// external auth mode this flag is ignored. Clients should refresh tokens
|
||||
/// themselves and call `account/login/start` with `chatgptAuthTokens`.
|
||||
#[serde(default)]
|
||||
pub refresh_token: bool,
|
||||
}
|
||||
@@ -1079,8 +1003,6 @@ pub struct AppInfo {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
pub distribution_channel: Option<String>,
|
||||
pub install_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_accessible: bool,
|
||||
@@ -1303,14 +1225,6 @@ pub struct ThreadArchiveParams {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadArchiveResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadSetNameParams {
|
||||
pub thread_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1318,11 +1232,6 @@ pub struct ThreadUnarchiveParams {
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadSetNameResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -1988,10 +1897,6 @@ pub enum UserInput {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
Mention {
|
||||
name: String,
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl UserInput {
|
||||
@@ -2007,7 +1912,6 @@ impl UserInput {
|
||||
UserInput::Image { url } => CoreUserInput::Image { image_url: url },
|
||||
UserInput::LocalImage { path } => CoreUserInput::LocalImage { path },
|
||||
UserInput::Skill { name, path } => CoreUserInput::Skill { name, path },
|
||||
UserInput::Mention { name, path } => CoreUserInput::Mention { name, path },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2025,7 +1929,6 @@ impl From<CoreUserInput> for UserInput {
|
||||
CoreUserInput::Image { image_url } => UserInput::Image { url: image_url },
|
||||
CoreUserInput::LocalImage { path } => UserInput::LocalImage { path },
|
||||
CoreUserInput::Skill { name, path } => UserInput::Skill { name, path },
|
||||
CoreUserInput::Mention { name, path } => UserInput::Mention { name, path },
|
||||
_ => unreachable!("unsupported user input variant"),
|
||||
}
|
||||
}
|
||||
@@ -2044,11 +1947,6 @@ pub enum ThreadItem {
|
||||
AgentMessage { id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
/// EXPERIMENTAL - proposed plan item content. The completed plan item is
|
||||
/// authoritative and may not match the concatenation of `PlanDelta` text.
|
||||
Plan { id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Reasoning {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
@@ -2121,11 +2019,7 @@ pub enum ThreadItem {
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
WebSearch {
|
||||
id: String,
|
||||
query: String,
|
||||
action: Option<WebSearchAction>,
|
||||
},
|
||||
WebSearch { id: String, query: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
ImageView { id: String, path: String },
|
||||
@@ -2140,42 +2034,6 @@ pub enum ThreadItem {
|
||||
ContextCompaction { id: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type", rename_all = "camelCase")]
|
||||
pub enum WebSearchAction {
|
||||
Search {
|
||||
query: Option<String>,
|
||||
queries: Option<Vec<String>>,
|
||||
},
|
||||
OpenPage {
|
||||
url: Option<String>,
|
||||
},
|
||||
FindInPage {
|
||||
url: Option<String>,
|
||||
pattern: Option<String>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<codex_protocol::models::WebSearchAction> for WebSearchAction {
|
||||
fn from(value: codex_protocol::models::WebSearchAction) -> Self {
|
||||
match value {
|
||||
codex_protocol::models::WebSearchAction::Search { query, queries } => {
|
||||
WebSearchAction::Search { query, queries }
|
||||
}
|
||||
codex_protocol::models::WebSearchAction::OpenPage { url } => {
|
||||
WebSearchAction::OpenPage { url }
|
||||
}
|
||||
codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => {
|
||||
WebSearchAction::FindInPage { url, pattern }
|
||||
}
|
||||
codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreTurnItem> for ThreadItem {
|
||||
fn from(value: CoreTurnItem) -> Self {
|
||||
match value {
|
||||
@@ -2193,10 +2051,6 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
.collect::<String>();
|
||||
ThreadItem::AgentMessage { id: agent.id, text }
|
||||
}
|
||||
CoreTurnItem::Plan(plan) => ThreadItem::Plan {
|
||||
id: plan.id,
|
||||
text: plan.text,
|
||||
},
|
||||
CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning {
|
||||
id: reasoning.id,
|
||||
summary: reasoning.summary_text,
|
||||
@@ -2205,7 +2059,6 @@ impl From<CoreTurnItem> for ThreadItem {
|
||||
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch {
|
||||
id: search.id,
|
||||
query: search.query,
|
||||
action: Some(WebSearchAction::from(search.action)),
|
||||
},
|
||||
CoreTurnItem::ContextCompaction(compaction) => {
|
||||
ThreadItem::ContextCompaction { id: compaction.id }
|
||||
@@ -2356,16 +2209,6 @@ pub struct ThreadStartedNotification {
|
||||
pub thread: Thread,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadNameUpdatedNotification {
|
||||
pub thread_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub thread_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2486,18 +2329,6 @@ pub struct AgentMessageDeltaNotification {
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should
|
||||
/// not assume concatenated deltas match the completed plan item content.
|
||||
pub struct PlanDeltaNotification {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -2686,15 +2517,13 @@ pub struct ToolRequestUserInputOption {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
/// EXPERIMENTAL. Represents one request_user_input question and its required options.
|
||||
/// EXPERIMENTAL. Represents one request_user_input question and its optional options.
|
||||
pub struct ToolRequestUserInputQuestion {
|
||||
pub id: String,
|
||||
pub header: String,
|
||||
pub question: String,
|
||||
#[serde(default)]
|
||||
pub is_other: bool,
|
||||
#[serde(default)]
|
||||
pub is_secret: bool,
|
||||
pub options: Option<Vec<ToolRequestUserInputOption>>,
|
||||
}
|
||||
|
||||
@@ -2859,7 +2688,7 @@ mod tests {
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
|
||||
use codex_protocol::user_input::UserInput as CoreUserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -2903,10 +2732,6 @@ mod tests {
|
||||
name: "skill-creator".to_string(),
|
||||
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
|
||||
},
|
||||
CoreUserInput::Mention {
|
||||
name: "Demo App".to_string(),
|
||||
path: "app://demo-app".to_string(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2929,10 +2754,6 @@ mod tests {
|
||||
name: "skill-creator".to_string(),
|
||||
path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"),
|
||||
},
|
||||
UserInput::Mention {
|
||||
name: "Demo App".to_string(),
|
||||
path: "app://demo-app".to_string(),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
@@ -2975,9 +2796,8 @@ mod tests {
|
||||
let search_item = TurnItem::WebSearch(WebSearchItem {
|
||||
id: "search-1".to_string(),
|
||||
query: "docs".to_string(),
|
||||
action: CoreWebSearchAction::Search {
|
||||
action: WebSearchAction::Search {
|
||||
query: Some("docs".to_string()),
|
||||
queries: None,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2986,10 +2806,6 @@ mod tests {
|
||||
ThreadItem::WebSearch {
|
||||
id: "search-1".to_string(),
|
||||
query: "docs".to_string(),
|
||||
action: Some(WebSearchAction::Search {
|
||||
query: Some("docs".to_string()),
|
||||
queries: None,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-cloud-requirements = { workspace = true }
|
||||
codex-common = { workspace = true, features = ["cli"] }
|
||||
codex-core = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
@@ -69,4 +67,3 @@ rmcp = { workspace = true, default-features = false, features = [
|
||||
] }
|
||||
serial_test = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
- [Events](#events)
|
||||
- [Approvals](#approvals)
|
||||
- [Skills](#skills)
|
||||
- [Apps](#apps)
|
||||
- [Auth endpoints](#auth-endpoints)
|
||||
|
||||
## Protocol
|
||||
@@ -82,7 +81,6 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`.
|
||||
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success.
|
||||
- `thread/name/set` — set or update a thread’s user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
|
||||
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
|
||||
@@ -103,7 +101,7 @@ Example (from OpenAI's official VSCode extension):
|
||||
- `config/read` — fetch the effective config on disk after resolving config layering.
|
||||
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
|
||||
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk.
|
||||
- `configRequirements/read` — fetch the loaded requirements allow-lists and `enforceResidency` from `requirements.toml` and/or MDM (or `null` if none are configured).
|
||||
- `configRequirements/read` — fetch the loaded requirements allow-lists from `requirements.toml` and/or MDM (or `null` if none are configured).
|
||||
|
||||
### Example: Start or resume a thread
|
||||
|
||||
@@ -298,26 +296,6 @@ Invoke a skill explicitly by including `$<skill-name>` in the text input and add
|
||||
} } }
|
||||
```
|
||||
|
||||
### Example: Start a turn (invoke an app)
|
||||
|
||||
Invoke an app by including `$<app-slug>` in the text input and adding a `mention` input item with the app id in `app://<connector-id>` form.
|
||||
|
||||
```json
|
||||
{ "method": "turn/start", "id": 34, "params": {
|
||||
"threadId": "thr_123",
|
||||
"input": [
|
||||
{ "type": "text", "text": "$demo-app Summarize the latest updates." },
|
||||
{ "type": "mention", "name": "Demo App", "path": "app://demo-app" }
|
||||
]
|
||||
} }
|
||||
{ "id": 34, "result": { "turn": {
|
||||
"id": "turn_458",
|
||||
"status": "inProgress",
|
||||
"items": [],
|
||||
"error": null
|
||||
} } }
|
||||
```
|
||||
|
||||
### Example: Interrupt an active turn
|
||||
|
||||
You can cancel a running Turn with `turn/interrupt`.
|
||||
@@ -444,13 +422,12 @@ Today both notifications carry an empty `items` array even when item events were
|
||||
|
||||
- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`).
|
||||
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
|
||||
- `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental).
|
||||
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
|
||||
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
|
||||
- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
|
||||
- `webSearch` — `{id, query}` for a web search request issued by the agent.
|
||||
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
|
||||
- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description.
|
||||
- `exitedReviewMode` — `{id, review}` emitted when the reviewer finishes; `review` is the full plain-text review (usually, overall notes plus bullet point findings).
|
||||
@@ -468,10 +445,6 @@ There are additional item-specific events:
|
||||
|
||||
- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply.
|
||||
|
||||
#### plan
|
||||
|
||||
- `item/plan/delta` — streams proposed plan content for plan items (experimental); concatenate `delta` values for the same plan `itemId`. These deltas correspond to the `<proposed_plan>` block.
|
||||
|
||||
#### reasoning
|
||||
|
||||
- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens.
|
||||
@@ -610,72 +583,14 @@ To enable or disable a skill by path:
|
||||
}
|
||||
```
|
||||
|
||||
## Apps
|
||||
|
||||
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible.
|
||||
|
||||
```json
|
||||
{ "method": "app/list", "id": 50, "params": {
|
||||
"cursor": null,
|
||||
"limit": 50
|
||||
} }
|
||||
{ "id": 50, "result": {
|
||||
"data": [
|
||||
{
|
||||
"id": "demo-app",
|
||||
"name": "Demo App",
|
||||
"description": "Example connector for documentation.",
|
||||
"logoUrl": "https://example.com/demo-app.png",
|
||||
"logoUrlDark": null,
|
||||
"distributionChannel": null,
|
||||
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
|
||||
"isAccessible": true
|
||||
}
|
||||
],
|
||||
"nextCursor": null
|
||||
} }
|
||||
```
|
||||
|
||||
Invoke an app by inserting `$<app-slug>` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://<connector-id>` path rather than guessing by name.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
$demo-app Pull the latest updates from the team.
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "turn/start",
|
||||
"id": 51,
|
||||
"params": {
|
||||
"threadId": "thread-1",
|
||||
"input": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "$demo-app Pull the latest updates from the team."
|
||||
},
|
||||
{ "type": "mention", "name": "Demo App", "path": "app://demo-app" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth endpoints
|
||||
|
||||
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
|
||||
|
||||
### Authentication modes
|
||||
|
||||
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`) and can be inferred from `account/read`.
|
||||
|
||||
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
|
||||
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"`; Codex persists tokens to disk and refreshes them automatically.
|
||||
|
||||
### API Overview
|
||||
|
||||
- `account/read` — fetch current account info; optionally refresh tokens.
|
||||
- `account/login/start` — begin login (`apiKey`, `chatgpt`).
|
||||
- `account/login/start` — begin login (`apiKey` or `chatgpt`).
|
||||
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
|
||||
- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`.
|
||||
- `account/logout` — sign out; triggers `account/updated`.
|
||||
|
||||
@@ -44,7 +44,6 @@ use codex_app_server_protocol::McpToolCallResult;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
|
||||
use codex_app_server_protocol::PlanDeltaNotification;
|
||||
use codex_app_server_protocol::RawResponseItemCompletedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
|
||||
@@ -53,7 +52,6 @@ use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::TerminalInteractionNotification;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadNameUpdatedNotification;
|
||||
use codex_app_server_protocol::ThreadRollbackResponse;
|
||||
use codex_app_server_protocol::ThreadTokenUsage;
|
||||
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
|
||||
@@ -119,7 +117,6 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
msg,
|
||||
} = event;
|
||||
match msg {
|
||||
EventMsg::TurnStarted(_) => {}
|
||||
EventMsg::TurnComplete(_ev) => {
|
||||
handle_turn_complete(
|
||||
conversation_id,
|
||||
@@ -282,7 +279,6 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
is_other: question.is_other,
|
||||
is_secret: question.is_secret,
|
||||
options: question.options.map(|options| {
|
||||
options
|
||||
.into_iter()
|
||||
@@ -595,27 +591,14 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
EventMsg::AgentMessageContentDelta(event) => {
|
||||
let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } =
|
||||
event;
|
||||
let notification = AgentMessageDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id,
|
||||
delta,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::AgentMessageDelta(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::PlanDelta(event) => {
|
||||
let notification = PlanDeltaNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
item_id: event.item_id,
|
||||
delta: event.delta,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::PlanDelta(notification))
|
||||
.send_server_notification(ServerNotification::AgentMessageDelta(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ContextCompacted(..) => {
|
||||
@@ -1113,17 +1096,6 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
outgoing.send_response(request_id, response).await;
|
||||
}
|
||||
}
|
||||
EventMsg::ThreadNameUpdated(thread_name_event) => {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = ThreadNameUpdatedNotification {
|
||||
thread_id: thread_name_event.thread_id.to_string(),
|
||||
thread_name: thread_name_event.thread_name,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ThreadNameUpdated(notification))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::TurnDiff(turn_diff_event) => {
|
||||
handle_turn_diff(
|
||||
conversation_id,
|
||||
@@ -1175,7 +1147,6 @@ async fn handle_turn_plan_update(
|
||||
api_version: ApiVersion,
|
||||
outgoing: &OutgoingMessageSender,
|
||||
) {
|
||||
// `update_plan` is a todo/checklist tool; it is not related to plan-mode updates
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = TurnPlanUpdatedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AddConversationListenerParams;
|
||||
use codex_app_server_protocol::AddConversationSubscriptionResponse;
|
||||
use codex_app_server_protocol::AppInfo as ApiAppInfo;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::ArchiveConversationParams;
|
||||
@@ -57,7 +58,6 @@ use codex_app_server_protocol::ListConversationsResponse;
|
||||
use codex_app_server_protocol::ListMcpServerStatusParams;
|
||||
use codex_app_server_protocol::ListMcpServerStatusResponse;
|
||||
use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::LoginApiKeyParams;
|
||||
use codex_app_server_protocol::LoginApiKeyResponse;
|
||||
use codex_app_server_protocol::LoginChatGptCompleteNotification;
|
||||
@@ -110,8 +110,6 @@ use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadRollbackParams;
|
||||
use codex_app_server_protocol::ThreadSetNameParams;
|
||||
use codex_app_server_protocol::ThreadSetNameResponse;
|
||||
use codex_app_server_protocol::ThreadSortKey;
|
||||
use codex_app_server_protocol::ThreadSourceKind;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
@@ -133,7 +131,6 @@ use codex_app_server_protocol::build_turns_from_event_msgs;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::Cursor as RolloutCursor;
|
||||
use codex_core::InitialHistory;
|
||||
@@ -145,14 +142,12 @@ use codex_core::ThreadManager;
|
||||
use codex_core::ThreadSortKey as CoreThreadSortKey;
|
||||
use codex_core::auth::CLIENT_ID;
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_core::auth::login_with_chatgpt_auth_tokens;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigService;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_core::error::CodexErr;
|
||||
use codex_core::exec::ExecParams;
|
||||
@@ -174,8 +169,7 @@ use codex_core::read_head_for_summary;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::rollout_date_parts;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_core::state_db::get_state_db;
|
||||
use codex_core::token_data::parse_id_token;
|
||||
use codex_core::state_db::{self};
|
||||
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_login::ServerOptions as LoginServerOptions;
|
||||
@@ -202,8 +196,6 @@ use codex_utils_json_to_toml::json_to_toml;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Error as IoError;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -211,7 +203,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -267,7 +258,6 @@ pub(crate) struct CodexMessageProcessor {
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
config: Arc<Config>,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
|
||||
listener_thread_ids_by_subscription: HashMap<Uuid, ThreadId>,
|
||||
active_login: Arc<Mutex<Option<ActiveLogin>>>,
|
||||
@@ -286,17 +276,6 @@ pub(crate) enum ApiVersion {
|
||||
V2,
|
||||
}
|
||||
|
||||
pub(crate) struct CodexMessageProcessorArgs {
|
||||
pub(crate) auth_manager: Arc<AuthManager>,
|
||||
pub(crate) thread_manager: Arc<ThreadManager>,
|
||||
pub(crate) outgoing: Arc<OutgoingMessageSender>,
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
|
||||
pub(crate) cloud_requirements: CloudRequirementsLoader,
|
||||
pub(crate) feedback: CodexFeedback,
|
||||
}
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
async fn load_thread(
|
||||
&self,
|
||||
@@ -321,17 +300,15 @@ impl CodexMessageProcessor {
|
||||
|
||||
Ok((thread_id, thread))
|
||||
}
|
||||
pub fn new(args: CodexMessageProcessorArgs) -> Self {
|
||||
let CodexMessageProcessorArgs {
|
||||
auth_manager,
|
||||
thread_manager,
|
||||
outgoing,
|
||||
codex_linux_sandbox_exe,
|
||||
config,
|
||||
cli_overrides,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
} = args;
|
||||
pub fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
config: Arc<Config>,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
feedback: CodexFeedback,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
thread_manager,
|
||||
@@ -339,7 +316,6 @@ impl CodexMessageProcessor {
|
||||
codex_linux_sandbox_exe,
|
||||
config,
|
||||
cli_overrides,
|
||||
cloud_requirements,
|
||||
conversation_listeners: HashMap::new(),
|
||||
listener_thread_ids_by_subscription: HashMap::new(),
|
||||
active_login: Arc::new(Mutex::new(None)),
|
||||
@@ -352,10 +328,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn load_latest_config(&self) -> Result<Config, JSONRPCErrorError> {
|
||||
codex_core::config::ConfigBuilder::default()
|
||||
.cli_overrides(self.cli_overrides.clone())
|
||||
.cloud_requirements(self.cloud_requirements.clone())
|
||||
.build()
|
||||
Config::load_with_cli_overrides(self.cli_overrides.clone())
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
@@ -441,9 +414,6 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::ThreadArchive { request_id, params } => {
|
||||
self.thread_archive(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ThreadSetName { request_id, params } => {
|
||||
self.thread_set_name(request_id, params).await;
|
||||
}
|
||||
ClientRequest::ThreadUnarchive { request_id, params } => {
|
||||
self.thread_unarchive(request_id, params).await;
|
||||
}
|
||||
@@ -638,22 +608,6 @@ impl CodexMessageProcessor {
|
||||
LoginAccountParams::Chatgpt => {
|
||||
self.login_chatgpt_v2(request_id).await;
|
||||
}
|
||||
LoginAccountParams::ChatgptAuthTokens {
|
||||
id_token,
|
||||
access_token,
|
||||
} => {
|
||||
self.login_chatgpt_auth_tokens(request_id, id_token, access_token)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn external_auth_active_error(&self) -> JSONRPCErrorError {
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it."
|
||||
.to_string(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,10 +615,6 @@ impl CodexMessageProcessor {
|
||||
&mut self,
|
||||
params: &LoginApiKeyParams,
|
||||
) -> std::result::Result<(), JSONRPCErrorError> {
|
||||
if self.auth_manager.is_external_auth_active() {
|
||||
return Err(self.external_auth_active_error());
|
||||
}
|
||||
|
||||
if matches!(
|
||||
self.config.forced_login_method,
|
||||
Some(ForcedLoginMethod::Chatgpt)
|
||||
@@ -709,11 +659,7 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: self
|
||||
.auth_manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.map(CodexAuth::api_auth_mode),
|
||||
auth_method: self.auth_manager.auth_cached().map(|auth| auth.mode),
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
@@ -743,11 +689,7 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_mode: self
|
||||
.auth_manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.map(CodexAuth::api_auth_mode),
|
||||
auth_mode: self.auth_manager.auth_cached().map(|auth| auth.mode),
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
|
||||
@@ -765,10 +707,6 @@ impl CodexMessageProcessor {
|
||||
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
|
||||
let config = self.config.as_ref();
|
||||
|
||||
if self.auth_manager.is_external_auth_active() {
|
||||
return Err(self.external_auth_active_error());
|
||||
}
|
||||
|
||||
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
@@ -843,10 +781,7 @@ impl CodexMessageProcessor {
|
||||
auth_manager.reload();
|
||||
|
||||
// Notify clients with the actual current auth mode.
|
||||
let current_auth_method = auth_manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.map(CodexAuth::api_auth_mode);
|
||||
let current_auth_method = auth_manager.auth_cached().map(|a| a.mode);
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: current_auth_method,
|
||||
};
|
||||
@@ -936,10 +871,7 @@ impl CodexMessageProcessor {
|
||||
auth_manager.reload();
|
||||
|
||||
// Notify clients with the actual current auth mode.
|
||||
let current_auth_method = auth_manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.map(CodexAuth::api_auth_mode);
|
||||
let current_auth_method = auth_manager.auth_cached().map(|a| a.mode);
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_mode: current_auth_method,
|
||||
};
|
||||
@@ -1033,98 +965,6 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_chatgpt_auth_tokens(
|
||||
&mut self,
|
||||
request_id: RequestId,
|
||||
id_token: String,
|
||||
access_token: String,
|
||||
) {
|
||||
if matches!(
|
||||
self.config.forced_login_method,
|
||||
Some(ForcedLoginMethod::Api)
|
||||
) {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "External ChatGPT auth is disabled. Use API key login instead."
|
||||
.to_string(),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any active login attempt to avoid persisting managed auth state.
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active) = guard.take() {
|
||||
drop(active);
|
||||
}
|
||||
}
|
||||
|
||||
let id_token_info = match parse_id_token(&id_token) {
|
||||
Ok(info) => info,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid id token: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref()
|
||||
&& id_token_info.chatgpt_account_id.as_deref() != Some(expected_workspace)
|
||||
{
|
||||
let account_id = id_token_info.chatgpt_account_id;
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!(
|
||||
"External auth must use workspace {expected_workspace}, but received {account_id:?}."
|
||||
),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) =
|
||||
login_with_chatgpt_auth_tokens(&self.config.codex_home, &id_token, &access_token)
|
||||
{
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to set external auth: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
self.auth_manager.reload();
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {})
|
||||
.await;
|
||||
|
||||
let payload_login_completed = AccountLoginCompletedNotification {
|
||||
login_id: None,
|
||||
success: true,
|
||||
error: None,
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountLoginCompleted(
|
||||
payload_login_completed,
|
||||
))
|
||||
.await;
|
||||
|
||||
let payload_v2 = AccountUpdatedNotification {
|
||||
auth_mode: self.auth_manager.get_auth_mode(),
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn logout_common(&mut self) -> std::result::Result<Option<AuthMode>, JSONRPCErrorError> {
|
||||
// Cancel any active login attempt.
|
||||
{
|
||||
@@ -1143,11 +983,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
// Reflect the current auth method after logout (likely None).
|
||||
Ok(self
|
||||
.auth_manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.map(CodexAuth::api_auth_mode))
|
||||
Ok(self.auth_manager.auth_cached().map(|auth| auth.mode))
|
||||
}
|
||||
|
||||
async fn logout_v1(&mut self, request_id: RequestId) {
|
||||
@@ -1191,9 +1027,6 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn refresh_token_if_requested(&self, do_refresh: bool) {
|
||||
if self.auth_manager.is_external_auth_active() {
|
||||
return;
|
||||
}
|
||||
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
|
||||
tracing::warn!("failed to refresh token while getting account: {err}");
|
||||
}
|
||||
@@ -1219,7 +1052,7 @@ impl CodexMessageProcessor {
|
||||
} else {
|
||||
match self.auth_manager.auth().await {
|
||||
Some(auth) => {
|
||||
let auth_mode = auth.api_auth_mode();
|
||||
let auth_mode = auth.mode;
|
||||
let (reported_auth_method, token_opt) = match auth.get_token() {
|
||||
Ok(token) if !token.is_empty() => {
|
||||
let tok = if include_token { Some(token) } else { None };
|
||||
@@ -1266,9 +1099,9 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
let account = match self.auth_manager.auth_cached() {
|
||||
Some(auth) => Some(match auth {
|
||||
CodexAuth::ApiKey(_) => Account::ApiKey {},
|
||||
CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
|
||||
Some(auth) => Some(match auth.mode {
|
||||
AuthMode::ApiKey => Account::ApiKey {},
|
||||
AuthMode::ChatGPT => {
|
||||
let email = auth.get_account_email();
|
||||
let plan_type = auth.account_plan_type();
|
||||
|
||||
@@ -1327,7 +1160,7 @@ impl CodexMessageProcessor {
|
||||
});
|
||||
};
|
||||
|
||||
if !auth.is_chatgpt_auth() {
|
||||
if auth.mode != AuthMode::ChatGPT {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "chatgpt authentication required to read rate limits".to_string(),
|
||||
@@ -1541,7 +1374,6 @@ impl CodexMessageProcessor {
|
||||
&self.cli_overrides,
|
||||
Some(request_overrides),
|
||||
typesafe_overrides,
|
||||
&self.cloud_requirements,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1626,7 +1458,6 @@ impl CodexMessageProcessor {
|
||||
&self.cli_overrides,
|
||||
config,
|
||||
typesafe_overrides,
|
||||
&self.cloud_requirements,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1773,7 +1604,7 @@ impl CodexMessageProcessor {
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
personality,
|
||||
model_personality: personality,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -1828,36 +1659,6 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn thread_set_name(&self, request_id: RequestId, params: ThreadSetNameParams) {
|
||||
let ThreadSetNameParams { thread_id, name } = params;
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&name) else {
|
||||
self.send_invalid_request_error(
|
||||
request_id,
|
||||
"thread name must not be empty".to_string(),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let (_, thread) = match self.load_thread(&thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = thread.submit(Op::SetThreadName { name }).await {
|
||||
self.send_internal_error(request_id, format!("failed to set thread name: {err}"))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
self.outgoing
|
||||
.send_response(request_id, ThreadSetNameResponse {})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn thread_unarchive(&mut self, request_id: RequestId, params: ThreadUnarchiveParams) {
|
||||
// TODO(jif) mostly rewrite this using sqlite after phase 1
|
||||
let thread_id = match ThreadId::from_string(¶ms.thread_id) {
|
||||
@@ -1902,7 +1703,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
let rollout_path_display = archived_path.display().to_string();
|
||||
let fallback_provider = self.config.model_provider_id.clone();
|
||||
let state_db_ctx = get_state_db(&self.config, None).await;
|
||||
let state_db_ctx = state_db::init_if_enabled(&self.config, None).await;
|
||||
let archived_folder = self
|
||||
.config
|
||||
.codex_home
|
||||
@@ -1981,28 +1782,6 @@ impl CodexMessageProcessor {
|
||||
message: format!("failed to unarchive thread: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
tokio::task::spawn_blocking({
|
||||
let restored_path = restored_path.clone();
|
||||
move || -> std::io::Result<()> {
|
||||
let times = FileTimes::new().set_modified(SystemTime::now());
|
||||
OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&restored_path)?
|
||||
.set_times(times)?;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to update unarchived thread timestamp: {err}"),
|
||||
data: None,
|
||||
})?
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to update unarchived thread timestamp: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
if let Some(ctx) = state_db_ctx {
|
||||
let _ = ctx
|
||||
.mark_unarchived(thread_id, restored_path.as_path())
|
||||
@@ -2396,7 +2175,6 @@ impl CodexMessageProcessor {
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&self.cloud_requirements,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2589,7 +2367,6 @@ impl CodexMessageProcessor {
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&self.cloud_requirements,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3384,7 +3161,6 @@ impl CodexMessageProcessor {
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&self.cloud_requirements,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3573,7 +3349,6 @@ impl CodexMessageProcessor {
|
||||
request_overrides,
|
||||
typesafe_overrides,
|
||||
history_cwd,
|
||||
&self.cloud_requirements,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3797,7 +3572,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
if state_db_ctx.is_none() {
|
||||
state_db_ctx = get_state_db(&self.config, None).await;
|
||||
state_db_ctx = state_db::init_if_enabled(&self.config, None).await;
|
||||
}
|
||||
|
||||
// Move the rollout file to archived.
|
||||
@@ -3937,7 +3712,7 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
if !config.features.enabled(Feature::Connectors) {
|
||||
self.outgoing
|
||||
.send_response(
|
||||
request_id,
|
||||
@@ -4000,7 +3775,18 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
let end = start.saturating_add(effective_limit).min(total);
|
||||
let data = connectors[start..end].to_vec();
|
||||
let data = connectors[start..end]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|connector| ApiAppInfo {
|
||||
id: connector.connector_id,
|
||||
name: connector.connector_name,
|
||||
description: connector.connector_description,
|
||||
logo_url: connector.logo_url,
|
||||
install_url: connector.install_url,
|
||||
is_accessible: connector.is_accessible,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let next_cursor = if end < total {
|
||||
Some(end.to_string())
|
||||
@@ -4844,7 +4630,6 @@ async fn derive_config_from_params(
|
||||
cli_overrides: &[(String, TomlValue)],
|
||||
request_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
cloud_requirements: &CloudRequirementsLoader,
|
||||
) -> std::io::Result<Config> {
|
||||
let merged_cli_overrides = cli_overrides
|
||||
.iter()
|
||||
@@ -4857,11 +4642,7 @@ async fn derive_config_from_params(
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
codex_core::config::ConfigBuilder::default()
|
||||
.cli_overrides(merged_cli_overrides)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
Config::load_with_cli_overrides_and_harness_overrides(merged_cli_overrides, typesafe_overrides)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -4870,7 +4651,6 @@ async fn derive_config_for_cwd(
|
||||
request_overrides: Option<HashMap<String, serde_json::Value>>,
|
||||
typesafe_overrides: ConfigOverrides,
|
||||
cwd: Option<PathBuf>,
|
||||
cloud_requirements: &CloudRequirementsLoader,
|
||||
) -> std::io::Result<Config> {
|
||||
let merged_cli_overrides = cli_overrides
|
||||
.iter()
|
||||
@@ -4887,7 +4667,6 @@ async fn derive_config_for_cwd(
|
||||
.cli_overrides(merged_cli_overrides)
|
||||
.harness_overrides(typesafe_overrides)
|
||||
.fallback_cwd(cwd)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -12,10 +12,8 @@ use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_core::config::ConfigService;
|
||||
use codex_core::config::ConfigServiceError;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::ConfigRequirementsToml;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
|
||||
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
@@ -31,15 +29,9 @@ impl ConfigApi {
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
) -> Self {
|
||||
Self {
|
||||
service: ConfigService::new(
|
||||
codex_home,
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
),
|
||||
service: ConfigService::new(codex_home, cli_overrides, loader_overrides),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +84,6 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
|
||||
.filter_map(map_sandbox_mode_requirement_to_api)
|
||||
.collect()
|
||||
}),
|
||||
enforce_residency: requirements
|
||||
.enforce_residency
|
||||
.map(map_residency_requirement_to_api),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +96,6 @@ fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Opti
|
||||
}
|
||||
}
|
||||
|
||||
fn map_residency_requirement_to_api(
|
||||
residency: CoreResidencyRequirement,
|
||||
) -> codex_app_server_protocol::ResidencyRequirement {
|
||||
match residency {
|
||||
CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_error(err: ConfigServiceError) -> JSONRPCErrorError {
|
||||
if let Some(code) = err.write_error_code() {
|
||||
return config_write_error(code, err.to_string());
|
||||
@@ -155,8 +136,6 @@ mod tests {
|
||||
CoreSandboxModeRequirement::ExternalSandbox,
|
||||
]),
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(CoreResidencyRequirement::Us),
|
||||
};
|
||||
|
||||
let mapped = map_requirements_toml_to_api(requirements);
|
||||
@@ -172,9 +151,5 @@ mod tests {
|
||||
mapped.allowed_sandbox_modes,
|
||||
Some(vec![SandboxMode::ReadOnly]),
|
||||
);
|
||||
assert_eq!(
|
||||
mapped.enforce_residency,
|
||||
Some(codex_app_server_protocol::ResidencyRequirement::Us),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
use std::num::NonZero;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use codex_app_server_protocol::FuzzyFileSearchResult;
|
||||
use codex_file_search as file_search;
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::warn;
|
||||
|
||||
const MATCH_LIMIT: usize = 50;
|
||||
const LIMIT_PER_ROOT: usize = 50;
|
||||
const MAX_THREADS: usize = 12;
|
||||
const COMPUTE_INDICES: bool = true;
|
||||
|
||||
pub(crate) async fn run_fuzzy_file_search(
|
||||
query: String,
|
||||
@@ -20,54 +23,64 @@ pub(crate) async fn run_fuzzy_file_search(
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
let limit = NonZero::new(MATCH_LIMIT).expect("MATCH_LIMIT should be a valid non-zero usize");
|
||||
let limit_per_root =
|
||||
NonZero::new(LIMIT_PER_ROOT).expect("LIMIT_PER_ROOT should be a valid non-zero usize");
|
||||
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(std::num::NonZero::get)
|
||||
.unwrap_or(1);
|
||||
let threads = cores.min(MAX_THREADS);
|
||||
#[expect(clippy::expect_used)]
|
||||
let threads = NonZero::new(threads.max(1)).expect("threads should be non-zero");
|
||||
let search_dirs: Vec<PathBuf> = roots.iter().map(PathBuf::from).collect();
|
||||
let threads_per_root = (threads / roots.len()).max(1);
|
||||
let threads = NonZero::new(threads_per_root).unwrap_or(NonZeroUsize::MIN);
|
||||
|
||||
let mut files = match tokio::task::spawn_blocking(move || {
|
||||
file_search::run(
|
||||
query.as_str(),
|
||||
search_dirs,
|
||||
file_search::FileSearchOptions {
|
||||
limit,
|
||||
let mut files: Vec<FuzzyFileSearchResult> = Vec::new();
|
||||
let mut join_set = JoinSet::new();
|
||||
|
||||
for root in roots {
|
||||
let search_dir = PathBuf::from(&root);
|
||||
let query = query.clone();
|
||||
let cancel_flag = cancellation_flag.clone();
|
||||
join_set.spawn_blocking(move || {
|
||||
match file_search::run(
|
||||
query.as_str(),
|
||||
limit_per_root,
|
||||
&search_dir,
|
||||
Vec::new(),
|
||||
threads,
|
||||
compute_indices: true,
|
||||
..Default::default()
|
||||
},
|
||||
Some(cancellation_flag),
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(res)) => res
|
||||
.matches
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let file_name = m.path.file_name().unwrap_or_default();
|
||||
FuzzyFileSearchResult {
|
||||
root: m.root.to_string_lossy().to_string(),
|
||||
path: m.path.to_string_lossy().to_string(),
|
||||
file_name: file_name.to_string_lossy().to_string(),
|
||||
score: m.score,
|
||||
indices: m.indices,
|
||||
cancel_flag,
|
||||
COMPUTE_INDICES,
|
||||
true,
|
||||
) {
|
||||
Ok(res) => Ok((root, res)),
|
||||
Err(err) => Err((root, err)),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(res) = join_set.join_next().await {
|
||||
match res {
|
||||
Ok(Ok((root, res))) => {
|
||||
for m in res.matches {
|
||||
let path = m.path;
|
||||
let file_name = file_search::file_name_from_path(&path);
|
||||
let result = FuzzyFileSearchResult {
|
||||
root: root.clone(),
|
||||
path,
|
||||
file_name,
|
||||
score: m.score,
|
||||
indices: m.indices,
|
||||
};
|
||||
files.push(result);
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
Ok(Err(err)) => {
|
||||
warn!("fuzzy-file-search failed: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
Ok(Err((root, err))) => {
|
||||
warn!("fuzzy-file-search in dir '{root}' failed: {err}");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("fuzzy-file-search join_next failed: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("fuzzy-file-search join failed: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
files.sort_by(file_search::cmp_by_score_desc_then_path_asc::<
|
||||
FuzzyFileSearchResult,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use codex_cloud_requirements::cloud_requirements_loader;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use std::io::ErrorKind;
|
||||
@@ -13,7 +10,6 @@ use std::io::Result as IoResult;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::message_processor::MessageProcessor;
|
||||
use crate::message_processor::MessageProcessorArgs;
|
||||
use crate::outgoing_message::OutgoingMessage;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -208,49 +204,11 @@ pub async fn run_main(
|
||||
format!("error parsing -c overrides: {e}"),
|
||||
)
|
||||
})?;
|
||||
let cloud_requirements = match ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides.clone())
|
||||
.loader_overrides(loader_overrides.clone())
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
let effective_toml = config.config_layer_stack.effective_config();
|
||||
match effective_toml.try_into() {
|
||||
Ok(config_toml) => {
|
||||
if let Err(err) = codex_core::personality_migration::maybe_migrate_personality(
|
||||
&config.codex_home,
|
||||
&config_toml,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(error = %err, "Failed to run personality migration");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to deserialize config for personality migration");
|
||||
}
|
||||
}
|
||||
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
cloud_requirements_loader(auth_manager, config.chatgpt_base_url)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to preload config for cloud requirements");
|
||||
// TODO(gt): Make cloud requirements preload failures blocking once we can fail-closed.
|
||||
CloudRequirementsLoader::default()
|
||||
}
|
||||
};
|
||||
let loader_overrides_for_config_api = loader_overrides.clone();
|
||||
let mut config_warnings = Vec::new();
|
||||
let config = match ConfigBuilder::default()
|
||||
.cli_overrides(cli_kv_overrides.clone())
|
||||
.loader_overrides(loader_overrides)
|
||||
.cloud_requirements(cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
@@ -332,16 +290,15 @@ pub async fn run_main(
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone();
|
||||
let loader_overrides = loader_overrides_for_config_api;
|
||||
let mut processor = MessageProcessor::new(MessageProcessorArgs {
|
||||
outgoing: outgoing_message_sender,
|
||||
let mut processor = MessageProcessor::new(
|
||||
outgoing_message_sender,
|
||||
codex_linux_sandbox_exe,
|
||||
config: std::sync::Arc::new(config),
|
||||
std::sync::Arc::new(config),
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements: cloud_requirements.clone(),
|
||||
feedback: feedback.clone(),
|
||||
feedback.clone(),
|
||||
config_warnings,
|
||||
});
|
||||
);
|
||||
let mut thread_created_rx = processor.thread_created_receiver();
|
||||
async move {
|
||||
let mut listen_for_threads = true;
|
||||
@@ -355,7 +312,7 @@ pub async fn run_main(
|
||||
JSONRPCMessage::Request(r) => processor.process_request(r).await,
|
||||
JSONRPCMessage::Response(r) => processor.process_response(r).await,
|
||||
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
|
||||
JSONRPCMessage::Error(e) => processor.process_error(e).await,
|
||||
JSONRPCMessage::Error(e) => processor.process_error(e),
|
||||
}
|
||||
}
|
||||
created = thread_created_rx.recv(), if listen_for_threads => {
|
||||
|
||||
@@ -2,14 +2,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex_message_processor::CodexMessageProcessor;
|
||||
use crate::codex_message_processor::CodexMessageProcessorArgs;
|
||||
use crate::config_api::ConfigApi;
|
||||
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
|
||||
use crate::outgoing_message::OutgoingMessageSender;
|
||||
use async_trait::async_trait;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ConfigBatchWriteParams;
|
||||
@@ -24,154 +19,66 @@ use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::auth::ExternalAuthRefreshContext;
|
||||
use codex_core::auth::ExternalAuthRefreshReason;
|
||||
use codex_core::auth::ExternalAuthRefresher;
|
||||
use codex_core::auth::ExternalAuthTokens;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::LoaderOverrides;
|
||||
use codex_core::default_client::SetOriginatorError;
|
||||
use codex_core::default_client::USER_AGENT_SUFFIX;
|
||||
use codex_core::default_client::get_codex_user_agent;
|
||||
use codex_core::default_client::set_default_client_residency_requirement;
|
||||
use codex_core::default_client::set_default_originator;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExternalAuthRefreshBridge {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
}
|
||||
|
||||
impl ExternalAuthRefreshBridge {
|
||||
fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason {
|
||||
match reason {
|
||||
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExternalAuthRefresher for ExternalAuthRefreshBridge {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: ExternalAuthRefreshContext,
|
||||
) -> std::io::Result<ExternalAuthTokens> {
|
||||
let params = ChatgptAuthTokensRefreshParams {
|
||||
reason: Self::map_reason(context.reason),
|
||||
previous_account_id: context.previous_account_id,
|
||||
};
|
||||
|
||||
let (request_id, rx) = self
|
||||
.outgoing
|
||||
.send_request_with_id(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
|
||||
.await;
|
||||
|
||||
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
|
||||
Ok(result) => result.map_err(|err| {
|
||||
std::io::Error::other(format!("auth refresh request canceled: {err}"))
|
||||
})?,
|
||||
Err(_) => {
|
||||
let _canceled = self.outgoing.cancel_request(&request_id).await;
|
||||
return Err(std::io::Error::other(format!(
|
||||
"auth refresh request timed out after {}s",
|
||||
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let response: ChatgptAuthTokensRefreshResponse =
|
||||
serde_json::from_value(result).map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(ExternalAuthTokens {
|
||||
access_token: response.access_token,
|
||||
id_token: response.id_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MessageProcessor {
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
codex_message_processor: CodexMessageProcessor,
|
||||
config_api: ConfigApi,
|
||||
config: Arc<Config>,
|
||||
initialized: bool,
|
||||
config_warnings: Vec<ConfigWarningNotification>,
|
||||
}
|
||||
|
||||
pub(crate) struct MessageProcessorArgs {
|
||||
pub(crate) outgoing: OutgoingMessageSender,
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) config: Arc<Config>,
|
||||
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
|
||||
pub(crate) loader_overrides: LoaderOverrides,
|
||||
pub(crate) cloud_requirements: CloudRequirementsLoader,
|
||||
pub(crate) feedback: CodexFeedback,
|
||||
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
|
||||
}
|
||||
|
||||
impl MessageProcessor {
|
||||
/// Create a new `MessageProcessor`, retaining a handle to the outgoing
|
||||
/// `Sender` so handlers can enqueue messages to be written to stdout.
|
||||
pub(crate) fn new(args: MessageProcessorArgs) -> Self {
|
||||
let MessageProcessorArgs {
|
||||
outgoing,
|
||||
codex_linux_sandbox_exe,
|
||||
config,
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
feedback,
|
||||
config_warnings,
|
||||
} = args;
|
||||
pub(crate) fn new(
|
||||
outgoing: OutgoingMessageSender,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
config: Arc<Config>,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
feedback: CodexFeedback,
|
||||
config_warnings: Vec<ConfigWarningNotification>,
|
||||
) -> Self {
|
||||
let outgoing = Arc::new(outgoing);
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone());
|
||||
auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge {
|
||||
outgoing: outgoing.clone(),
|
||||
}));
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
config.codex_home.clone(),
|
||||
auth_manager.clone(),
|
||||
SessionSource::VSCode,
|
||||
));
|
||||
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
|
||||
let codex_message_processor = CodexMessageProcessor::new(
|
||||
auth_manager,
|
||||
thread_manager,
|
||||
outgoing: outgoing.clone(),
|
||||
outgoing.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
config: Arc::clone(&config),
|
||||
cli_overrides: cli_overrides.clone(),
|
||||
cloud_requirements: cloud_requirements.clone(),
|
||||
Arc::clone(&config),
|
||||
cli_overrides.clone(),
|
||||
feedback,
|
||||
});
|
||||
let config_api = ConfigApi::new(
|
||||
config.codex_home.clone(),
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
);
|
||||
let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides, loader_overrides);
|
||||
|
||||
Self {
|
||||
outgoing,
|
||||
codex_message_processor,
|
||||
config_api,
|
||||
config,
|
||||
initialized: false,
|
||||
config_warnings,
|
||||
}
|
||||
@@ -244,7 +151,6 @@ impl MessageProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
set_default_client_residency_requirement(self.config.enforce_residency.value());
|
||||
let user_agent_suffix = format!("{name}; {version}");
|
||||
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
|
||||
*suffix = Some(user_agent_suffix);
|
||||
@@ -330,9 +236,8 @@ impl MessageProcessor {
|
||||
}
|
||||
|
||||
/// Handle an error object received from the peer.
|
||||
pub(crate) async fn process_error(&mut self, err: JSONRPCError) {
|
||||
pub(crate) fn process_error(&mut self, err: JSONRPCError) {
|
||||
tracing::error!("<- error: {:?}", err);
|
||||
self.outgoing.notify_client_error(err.id, err.error).await;
|
||||
}
|
||||
|
||||
async fn handle_config_read(&self, request_id: RequestId, params: ConfigReadParams) {
|
||||
|
||||
@@ -39,14 +39,6 @@ impl OutgoingMessageSender {
|
||||
&self,
|
||||
request: ServerRequestPayload,
|
||||
) -> oneshot::Receiver<Result> {
|
||||
let (_id, rx) = self.send_request_with_id(request).await;
|
||||
rx
|
||||
}
|
||||
|
||||
pub(crate) async fn send_request_with_id(
|
||||
&self,
|
||||
request: ServerRequestPayload,
|
||||
) -> (RequestId, oneshot::Receiver<Result>) {
|
||||
let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed));
|
||||
let outgoing_message_id = id.clone();
|
||||
let (tx_approve, rx_approve) = oneshot::channel();
|
||||
@@ -62,7 +54,7 @@ impl OutgoingMessageSender {
|
||||
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
|
||||
request_id_to_callback.remove(&outgoing_message_id);
|
||||
}
|
||||
(outgoing_message_id, rx_approve)
|
||||
rx_approve
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_client_response(&self, id: RequestId, result: Result) {
|
||||
@@ -83,30 +75,6 @@ impl OutgoingMessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_client_error(&self, id: RequestId, error: JSONRPCErrorError) {
|
||||
let entry = {
|
||||
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
|
||||
request_id_to_callback.remove_entry(&id)
|
||||
};
|
||||
|
||||
match entry {
|
||||
Some((id, _sender)) => {
|
||||
warn!("client responded with error for {id:?}: {error:?}");
|
||||
}
|
||||
None => {
|
||||
warn!("could not find callback for {id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool {
|
||||
let entry = {
|
||||
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
|
||||
request_id_to_callback.remove_entry(id)
|
||||
};
|
||||
entry.is_some()
|
||||
}
|
||||
|
||||
pub(crate) async fn send_response<T: Serialize>(&self, id: RequestId, response: T) {
|
||||
match serde_json::to_value(response) {
|
||||
Ok(result) => {
|
||||
|
||||
@@ -26,4 +26,3 @@ tokio = { workspace = true, features = [
|
||||
uuid = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
core_test_support = { path = "../../../core/tests/common" }
|
||||
shlex = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,6 @@ use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::AuthDotJson;
|
||||
use codex_core::auth::save_auth;
|
||||
@@ -159,7 +158,6 @@ pub fn write_chatgpt_auth(
|
||||
let last_refresh = fixture.last_refresh.unwrap_or_else(|| Some(Utc::now()));
|
||||
|
||||
let auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(tokens),
|
||||
last_refresh,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
use codex_core::features::FEATURES;
|
||||
use codex_core::features::Feature;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn write_mock_responses_config_toml(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
feature_flags: &BTreeMap<Feature, bool>,
|
||||
auto_compact_limit: i64,
|
||||
requires_openai_auth: Option<bool>,
|
||||
model_provider_id: &str,
|
||||
compact_prompt: &str,
|
||||
) -> std::io::Result<()> {
|
||||
// Phase 1: build the features block for config.toml.
|
||||
let mut features = BTreeMap::from([(Feature::RemoteModels, false)]);
|
||||
for (feature, enabled) in feature_flags {
|
||||
features.insert(*feature, *enabled);
|
||||
}
|
||||
let feature_entries = features
|
||||
.into_iter()
|
||||
.map(|(feature, enabled)| {
|
||||
let key = FEATURES
|
||||
.iter()
|
||||
.find(|spec| spec.id == feature)
|
||||
.map(|spec| spec.key)
|
||||
.unwrap_or_else(|| panic!("missing feature key for {feature:?}"));
|
||||
format!("{key} = {enabled}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// Phase 2: build provider-specific config bits.
|
||||
let requires_line = match requires_openai_auth {
|
||||
Some(true) => "requires_openai_auth = true\n".to_string(),
|
||||
Some(false) | None => String::new(),
|
||||
};
|
||||
let provider_block = if model_provider_id == "openai" {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
{requires_line}
|
||||
"#
|
||||
)
|
||||
};
|
||||
// Phase 3: write the final config file.
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
compact_prompt = "{compact_prompt}"
|
||||
model_auto_compact_token_limit = {auto_compact_limit}
|
||||
|
||||
model_provider = "{model_provider_id}"
|
||||
|
||||
[features]
|
||||
{feature_entries}
|
||||
{provider_block}
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
mod auth_fixtures;
|
||||
mod config;
|
||||
mod mcp_process;
|
||||
mod mock_model_server;
|
||||
mod models_cache;
|
||||
@@ -11,7 +10,6 @@ pub use auth_fixtures::ChatGptIdTokenClaims;
|
||||
pub use auth_fixtures::encode_id_token;
|
||||
pub use auth_fixtures::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
pub use config::write_mock_responses_config_toml;
|
||||
pub use core_test_support::format_with_current_shell;
|
||||
pub use core_test_support::format_with_current_shell_display;
|
||||
pub use core_test_support::format_with_current_shell_display_non_login;
|
||||
|
||||
@@ -29,13 +29,11 @@ use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::InterruptConversationParams;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::ListConversationsParams;
|
||||
use codex_app_server_protocol::LoginAccountParams;
|
||||
use codex_app_server_protocol::LoginApiKeyParams;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::NewConversationParams;
|
||||
@@ -300,20 +298,6 @@ impl McpProcess {
|
||||
self.send_request("account/read", params).await
|
||||
}
|
||||
|
||||
/// Send an `account/login/start` JSON-RPC request with ChatGPT auth tokens.
|
||||
pub async fn send_chatgpt_auth_tokens_login_request(
|
||||
&mut self,
|
||||
id_token: String,
|
||||
access_token: String,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = LoginAccountParams::ChatgptAuthTokens {
|
||||
id_token,
|
||||
access_token,
|
||||
};
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("account/login/start", params).await
|
||||
}
|
||||
|
||||
/// Send a `feedback/upload` JSON-RPC request.
|
||||
pub async fn send_feedback_upload_request(
|
||||
&mut self,
|
||||
@@ -624,15 +608,6 @@ impl McpProcess {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_error(
|
||||
&mut self,
|
||||
id: RequestId,
|
||||
error: JSONRPCErrorError,
|
||||
) -> anyhow::Result<()> {
|
||||
self.send_jsonrpc_message(JSONRPCMessage::Error(JSONRPCError { id, error }))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_notification(
|
||||
&mut self,
|
||||
notification: ClientNotification,
|
||||
@@ -736,10 +711,6 @@ impl McpProcess {
|
||||
Ok(notification)
|
||||
}
|
||||
|
||||
pub async fn read_next_message(&mut self) -> anyhow::Result<JSONRPCMessage> {
|
||||
self.read_stream_until_message(|_| true).await
|
||||
}
|
||||
|
||||
/// Clears any buffered messages so future reads only consider new stream items.
|
||||
///
|
||||
/// We call this when e.g. we want to validate against the next turn and no longer care about
|
||||
|
||||
@@ -27,7 +27,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
priority,
|
||||
upgrade: preset.upgrade.as_ref().map(|u| u.into()),
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use core_test_support::join_shell_command_args;
|
||||
use core_test_support::responses;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
@@ -9,7 +10,7 @@ pub fn create_shell_command_sse_response(
|
||||
call_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
// The `arguments` for the `shell_command` tool is a serialized JSON object.
|
||||
let command_str = shlex::try_join(command.iter().map(String::as_str))?;
|
||||
let command_str = join_shell_command_args(&command);
|
||||
let tool_call_arguments = serde_json::to_string(&json!({
|
||||
"command": command_str,
|
||||
"workdir": workdir.map(|w| w.to_string_lossy()),
|
||||
@@ -67,6 +68,7 @@ pub fn create_request_user_input_sse_response(call_id: &str) -> anyhow::Result<S
|
||||
"id": "confirm_path",
|
||||
"header": "Confirm",
|
||||
"question": "Proceed with the plan?",
|
||||
"isOther": false,
|
||||
"options": [{
|
||||
"label": "Yes (Recommended)",
|
||||
"description": "Continue the current plan."
|
||||
|
||||
@@ -81,7 +81,6 @@ pub fn create_fake_rollout_with_source(
|
||||
source,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
};
|
||||
let payload = serde_json::to_value(SessionMetaLine {
|
||||
meta,
|
||||
@@ -160,7 +159,6 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
source: SessionSource::Cli,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
};
|
||||
let payload = serde_json::to_value(SessionMetaLine {
|
||||
meta,
|
||||
|
||||
@@ -21,13 +21,13 @@ use codex_app_server_protocol::SendUserMessageResponse;
|
||||
use codex_app_server_protocol::SendUserTurnParams;
|
||||
use codex_app_server_protocol::SendUserTurnResponse;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_core::parse_command::parse_command;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol_config_types::ReasoningSummary;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -202,25 +202,32 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
|
||||
let working_directory = tmp.path().join("workdir");
|
||||
std::fs::create_dir(&working_directory)?;
|
||||
|
||||
// Mock server will request a python shell call for the first and second turn, then finish.
|
||||
let responses = vec![
|
||||
create_shell_command_sse_response(
|
||||
let (shell_command, expected_shell_command) = if cfg!(windows) {
|
||||
let command = "Get-Process".to_string();
|
||||
(vec![command.clone()], format_with_current_shell(&command))
|
||||
} else {
|
||||
let command = "python3 -c 'print(42)'";
|
||||
(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
format_with_current_shell(command),
|
||||
)
|
||||
};
|
||||
|
||||
// Mock server will request an untrusted shell call for the first and second turn, then finish.
|
||||
let responses = vec![
|
||||
create_shell_command_sse_response(
|
||||
shell_command.clone(),
|
||||
Some(&working_directory),
|
||||
Some(5000),
|
||||
"call1",
|
||||
)?,
|
||||
create_final_assistant_message_sse_response("done 1")?,
|
||||
create_shell_command_sse_response(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
shell_command.clone(),
|
||||
Some(&working_directory),
|
||||
Some(5000),
|
||||
"call2",
|
||||
@@ -293,16 +300,16 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> {
|
||||
panic!("expected ExecCommandApproval request, got: {request:?}");
|
||||
};
|
||||
|
||||
let parsed_cmd = parse_command(&expected_shell_command);
|
||||
|
||||
assert_eq!(
|
||||
ExecCommandApprovalParams {
|
||||
conversation_id,
|
||||
call_id: "call1".to_string(),
|
||||
command: format_with_current_shell("python3 -c 'print(42)'"),
|
||||
command: expected_shell_command,
|
||||
cwd: working_directory.clone(),
|
||||
reason: None,
|
||||
parsed_cmd: vec![ParsedCommand::Unknown {
|
||||
cmd: "python3 -c 'print(42)'".to_string()
|
||||
}],
|
||||
parsed_cmd,
|
||||
},
|
||||
params
|
||||
);
|
||||
|
||||
@@ -4,43 +4,28 @@ use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::encode_id_token;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_models_cache;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
use codex_app_server_protocol::CancelLoginAccountResponse;
|
||||
use codex_app_server_protocol::CancelLoginAccountStatus;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::LogoutAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use core_test_support::responses;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use serial_test::serial;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
@@ -50,14 +35,10 @@ struct CreateConfigTomlParams {
|
||||
forced_method: Option<String>,
|
||||
forced_workspace_id: Option<String>,
|
||||
requires_openai_auth: Option<bool>,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
let base_url = params
|
||||
.base_url
|
||||
.unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string());
|
||||
let forced_line = if let Some(method) = params.forced_method {
|
||||
format!("forced_login_method = \"{method}\"\n")
|
||||
} else {
|
||||
@@ -85,7 +66,7 @@ model_provider = "mock_provider"
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{base_url}"
|
||||
base_url = "http://127.0.0.1:0/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
@@ -152,627 +133,6 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("embedded@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-embedded"),
|
||||
)?;
|
||||
let access_token = "access-embedded".to_string();
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(id_token.clone(), access_token)
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
|
||||
let note = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = note.try_into()?;
|
||||
let ServerNotification::AccountUpdated(payload) = parsed else {
|
||||
bail!("unexpected notification: {parsed:?}");
|
||||
};
|
||||
assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens));
|
||||
|
||||
let get_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
let get_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
|
||||
)
|
||||
.await??;
|
||||
let account: GetAccountResponse = to_response(get_resp)?;
|
||||
assert_eq!(
|
||||
account,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "embedded@example.com".to_string(),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("embedded@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-embedded"),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(id_token, "access-embedded".to_string())
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
let _updated = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let get_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: true,
|
||||
})
|
||||
.await?;
|
||||
let get_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
|
||||
)
|
||||
.await??;
|
||||
let account: GetAccountResponse = to_response(get_resp)?;
|
||||
assert_eq!(
|
||||
account,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "embedded@example.com".to_string(),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
|
||||
let refresh_request = timeout(
|
||||
Duration::from_millis(250),
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
refresh_request.is_err(),
|
||||
"external mode should not emit account/chatgptAuthTokens/refresh for refreshToken=true"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn respond_to_refresh_request(
|
||||
mcp: &mut McpProcess,
|
||||
access_token: &str,
|
||||
id_token: &str,
|
||||
) -> Result<()> {
|
||||
let refresh_req: ServerRequest = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = refresh_req else {
|
||||
bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");
|
||||
};
|
||||
assert_eq!(params.reason, ChatgptAuthTokensRefreshReason::Unauthorized);
|
||||
let response = ChatgptAuthTokensRefreshResponse {
|
||||
access_token: access_token.to_string(),
|
||||
id_token: id_token.to_string(),
|
||||
};
|
||||
mcp.send_response(request_id, serde_json::to_value(response)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// 401 response triggers account/chatgptAuthTokens/refresh and retries with new tokens.
|
||||
async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let success_sse = responses::sse(vec![
|
||||
responses::ev_response_created("resp-turn"),
|
||||
responses::ev_assistant_message("msg-turn", "turn ok"),
|
||||
responses::ev_completed("resp-turn"),
|
||||
]);
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
}));
|
||||
let responses_mock = responses::mount_response_sequence(
|
||||
&mock_server,
|
||||
vec![unauthorized, responses::sse_response(success_sse)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let initial_id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("initial@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-initial"),
|
||||
)?;
|
||||
let refreshed_id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("refreshed@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-refreshed"),
|
||||
)?;
|
||||
let initial_access_token = "access-initial".to_string();
|
||||
let refreshed_access_token = "access-refreshed".to_string();
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(
|
||||
initial_id_token.clone(),
|
||||
initial_access_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
let _updated = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
|
||||
thread_id: thread.thread.id,
|
||||
input: vec![codex_app_server_protocol::UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
respond_to_refresh_request(&mut mcp, &refreshed_access_token, &refreshed_id_token).await?;
|
||||
let _turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn_completed = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = responses_mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0].header("authorization"),
|
||||
Some(format!("Bearer {initial_access_token}"))
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1].header("authorization"),
|
||||
Some(format!("Bearer {refreshed_access_token}"))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// Client returns JSON-RPC error to refresh; turn fails.
|
||||
async fn external_auth_refresh_error_fails_turn() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
}));
|
||||
let _responses_mock =
|
||||
responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
|
||||
|
||||
let initial_id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("initial@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-initial"),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(initial_id_token, "access-initial".to_string())
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
let _updated = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
|
||||
thread_id: thread.thread.id.clone(),
|
||||
input: vec![codex_app_server_protocol::UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let refresh_req: ServerRequest = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else {
|
||||
bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");
|
||||
};
|
||||
|
||||
mcp.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: -32_000,
|
||||
message: "refresh failed".to_string(),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let completed_notif: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
completed_notif
|
||||
.params
|
||||
.expect("turn/completed params must be present"),
|
||||
)?;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Failed);
|
||||
assert!(completed.turn.error.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// Refresh returns tokens for the wrong workspace; turn fails.
|
||||
async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
forced_workspace_id: Some("org-expected".to_string()),
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
}));
|
||||
let _responses_mock =
|
||||
responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
|
||||
|
||||
let initial_id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("initial@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-expected"),
|
||||
)?;
|
||||
let refreshed_id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("refreshed@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-other"),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(initial_id_token, "access-initial".to_string())
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
let _updated = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
|
||||
thread_id: thread.thread.id.clone(),
|
||||
input: vec![codex_app_server_protocol::UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let refresh_req: ServerRequest = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else {
|
||||
bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");
|
||||
};
|
||||
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(ChatgptAuthTokensRefreshResponse {
|
||||
access_token: "access-refreshed".to_string(),
|
||||
id_token: refreshed_id_token,
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let completed_notif: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
completed_notif
|
||||
.params
|
||||
.expect("turn/completed params must be present"),
|
||||
)?;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Failed);
|
||||
assert!(completed.turn.error.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// Refresh returns a malformed id_token; turn fails.
|
||||
async fn external_auth_refresh_invalid_id_token_fails_turn() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mock_server = MockServer::start().await;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
base_url: Some(format!("{}/v1", mock_server.uri())),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
}));
|
||||
let _responses_mock =
|
||||
responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
|
||||
|
||||
let initial_id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("initial@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-initial"),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(initial_id_token, "access-initial".to_string())
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
let _updated = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(codex_app_server_protocol::ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(codex_app_server_protocol::TurnStartParams {
|
||||
thread_id: thread.thread.id.clone(),
|
||||
input: vec![codex_app_server_protocol::UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let refresh_req: ServerRequest = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else {
|
||||
bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");
|
||||
};
|
||||
|
||||
mcp.send_response(
|
||||
request_id,
|
||||
serde_json::to_value(ChatgptAuthTokensRefreshResponse {
|
||||
access_token: "access-refreshed".to_string(),
|
||||
id_token: "not-a-jwt".to_string(),
|
||||
})?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let completed_notif: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification = serde_json::from_value(
|
||||
completed_notif
|
||||
.params
|
||||
.expect("turn/completed params must be present"),
|
||||
)?;
|
||||
assert_eq!(completed.turn.status, TurnStatus::Failed);
|
||||
assert!(completed.turn.error.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -944,71 +304,6 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// Serialize tests that launch the login server since it binds to a fixed port.
|
||||
#[serial(login_port)]
|
||||
async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
// Initiate the ChatGPT login flow
|
||||
let request_id = mcp.send_login_account_chatgpt_request().await?;
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let login: LoginAccountResponse = to_response(resp)?;
|
||||
let LoginAccountResponse::Chatgpt { login_id, .. } = login else {
|
||||
bail!("unexpected login response: {login:?}");
|
||||
};
|
||||
|
||||
let id_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("embedded@example.com")
|
||||
.plan_type("pro")
|
||||
.chatgpt_account_id("org-embedded"),
|
||||
)?;
|
||||
// Set an external auth token instead of completing the ChatGPT login flow.
|
||||
// This should cancel the active login attempt.
|
||||
let set_id = mcp
|
||||
.send_chatgpt_auth_tokens_login_request(id_token, "access-embedded".to_string())
|
||||
.await?;
|
||||
let set_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: LoginAccountResponse = to_response(set_resp)?;
|
||||
assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
let _updated = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("account/updated"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
// Verify that the active login attempt was cancelled.
|
||||
// We check this by trying to cancel it and expecting a not found error.
|
||||
let cancel_id = mcp
|
||||
.send_cancel_login_account_request(CancelLoginAccountParams {
|
||||
login_id: login_id.clone(),
|
||||
})
|
||||
.await?;
|
||||
let cancel_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),
|
||||
)
|
||||
.await??;
|
||||
let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?;
|
||||
assert_eq!(cancel.status, CancelLoginAccountStatus::NotFound);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
// Serialize tests that launch the login server since it binds to a fixed port.
|
||||
#[serial(login_port)]
|
||||
|
||||
@@ -13,13 +13,14 @@ use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use axum::routing::post;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::connectors::ConnectorInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
use rmcp::model::JsonObject;
|
||||
@@ -70,23 +71,19 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> {
|
||||
#[tokio::test]
|
||||
async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
let connectors = vec![
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
ConnectorInfo {
|
||||
connector_id: "alpha".to_string(),
|
||||
connector_name: "Alpha".to_string(),
|
||||
connector_description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "beta".to_string(),
|
||||
description: None,
|
||||
ConnectorInfo {
|
||||
connector_id: "beta".to_string(),
|
||||
connector_name: "beta".to_string(),
|
||||
connector_description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
},
|
||||
@@ -130,8 +127,6 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
name: "Beta App".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
},
|
||||
@@ -140,8 +135,6 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
},
|
||||
@@ -157,23 +150,19 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
|
||||
#[tokio::test]
|
||||
async fn list_apps_paginates_results() -> Result<()> {
|
||||
let connectors = vec![
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
ConnectorInfo {
|
||||
connector_id: "alpha".to_string(),
|
||||
connector_name: "Alpha".to_string(),
|
||||
connector_description: Some("Alpha connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "beta".to_string(),
|
||||
description: None,
|
||||
ConnectorInfo {
|
||||
connector_id: "beta".to_string(),
|
||||
connector_name: "beta".to_string(),
|
||||
connector_description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
},
|
||||
@@ -217,8 +206,6 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
name: "Beta App".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
|
||||
is_accessible: true,
|
||||
}];
|
||||
@@ -247,8 +234,6 @@ async fn list_apps_paginates_results() -> Result<()> {
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
is_accessible: false,
|
||||
}];
|
||||
@@ -304,13 +289,13 @@ impl ServerHandler for AppListMcpServer {
|
||||
}
|
||||
|
||||
async fn start_apps_server(
|
||||
connectors: Vec<AppInfo>,
|
||||
connectors: Vec<ConnectorInfo>,
|
||||
tools: Vec<Tool>,
|
||||
) -> Result<(String, JoinHandle<()>)> {
|
||||
let state = AppsServerState {
|
||||
expected_bearer: "Bearer chatgpt-token".to_string(),
|
||||
expected_account_id: "account-123".to_string(),
|
||||
response: json!({ "apps": connectors, "next_token": null }),
|
||||
response: json!({ "connectors": connectors }),
|
||||
};
|
||||
let state = Arc::new(state);
|
||||
let tools = Arc::new(tools);
|
||||
@@ -328,11 +313,7 @@ async fn start_apps_server(
|
||||
);
|
||||
|
||||
let router = Router::new()
|
||||
.route("/connectors/directory/list", get(list_directory_connectors))
|
||||
.route(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.route("/aip/connectors/list_accessible", post(list_connectors))
|
||||
.with_state(state)
|
||||
.nest_service("/api/codex/apps", mcp_service);
|
||||
|
||||
@@ -343,7 +324,7 @@ async fn start_apps_server(
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
async fn list_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
//! End-to-end compaction flow tests.
|
||||
//!
|
||||
//! Phases:
|
||||
//! 1) Arrange: mock responses/compact endpoints + config.
|
||||
//! 2) Act: start a thread and submit multiple turns to trigger auto-compaction.
|
||||
//! 3) Assert: verify item/started + item/completed notifications for context compaction.
|
||||
|
||||
#![expect(clippy::expect_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_mock_responses_config_toml;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const AUTO_COMPACT_LIMIT: i64 = 1_000;
|
||||
const COMPACT_PROMPT: &str = "Summarize the conversation.";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let sse1 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m1", "FIRST_REPLY"),
|
||||
responses::ev_completed_with_tokens("r1", 70_000),
|
||||
]);
|
||||
let sse2 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m2", "SECOND_REPLY"),
|
||||
responses::ev_completed_with_tokens("r2", 330_000),
|
||||
]);
|
||||
let sse3 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m3", "LOCAL_SUMMARY"),
|
||||
responses::ev_completed_with_tokens("r3", 200),
|
||||
]);
|
||||
let sse4 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m4", "FINAL_REPLY"),
|
||||
responses::ev_completed_with_tokens("r4", 120),
|
||||
]);
|
||||
responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3, sse4]).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&BTreeMap::default(),
|
||||
AUTO_COMPACT_LIMIT,
|
||||
None,
|
||||
"mock_provider",
|
||||
COMPACT_PROMPT,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_id = start_thread(&mut mcp).await?;
|
||||
for message in ["first", "second", "third"] {
|
||||
send_turn_and_wait(&mut mcp, &thread_id, message).await?;
|
||||
}
|
||||
|
||||
let started = wait_for_context_compaction_started(&mut mcp).await?;
|
||||
let completed = wait_for_context_compaction_completed(&mut mcp).await?;
|
||||
|
||||
let ThreadItem::ContextCompaction { id: started_id } = started.item else {
|
||||
unreachable!("started item should be context compaction");
|
||||
};
|
||||
let ThreadItem::ContextCompaction { id: completed_id } = completed.item else {
|
||||
unreachable!("completed item should be context compaction");
|
||||
};
|
||||
|
||||
assert_eq!(started.thread_id, thread_id);
|
||||
assert_eq!(completed.thread_id, thread_id);
|
||||
assert_eq!(started_id, completed_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let sse1 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m1", "FIRST_REPLY"),
|
||||
responses::ev_completed_with_tokens("r1", 70_000),
|
||||
]);
|
||||
let sse2 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m2", "SECOND_REPLY"),
|
||||
responses::ev_completed_with_tokens("r2", 330_000),
|
||||
]);
|
||||
let sse3 = responses::sse(vec![
|
||||
responses::ev_assistant_message("m3", "FINAL_REPLY"),
|
||||
responses::ev_completed_with_tokens("r3", 120),
|
||||
]);
|
||||
let responses_log = responses::mount_sse_sequence(&server, vec![sse1, sse2, sse3]).await;
|
||||
|
||||
let compacted_history = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "REMOTE_COMPACT_SUMMARY".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
},
|
||||
ResponseItem::Compaction {
|
||||
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
|
||||
},
|
||||
];
|
||||
let compact_mock = responses::mount_compact_json_once(
|
||||
&server,
|
||||
serde_json::json!({ "output": compacted_history }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut features = BTreeMap::default();
|
||||
features.insert(Feature::RemoteCompaction, true);
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&features,
|
||||
AUTO_COMPACT_LIMIT,
|
||||
Some(true),
|
||||
"openai",
|
||||
COMPACT_PROMPT,
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server_base_url = format!("{}/v1", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_BASE_URL", Some(server_base_url.as_str())),
|
||||
("OPENAI_API_KEY", None),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_id = start_thread(&mut mcp).await?;
|
||||
for message in ["first", "second", "third"] {
|
||||
send_turn_and_wait(&mut mcp, &thread_id, message).await?;
|
||||
}
|
||||
|
||||
let started = wait_for_context_compaction_started(&mut mcp).await?;
|
||||
let completed = wait_for_context_compaction_completed(&mut mcp).await?;
|
||||
|
||||
let ThreadItem::ContextCompaction { id: started_id } = started.item else {
|
||||
unreachable!("started item should be context compaction");
|
||||
};
|
||||
let ThreadItem::ContextCompaction { id: completed_id } = completed.item else {
|
||||
unreachable!("completed item should be context compaction");
|
||||
};
|
||||
|
||||
assert_eq!(started.thread_id, thread_id);
|
||||
assert_eq!(completed.thread_id, thread_id);
|
||||
assert_eq!(started_id, completed_id);
|
||||
|
||||
let compact_requests = compact_mock.requests();
|
||||
assert_eq!(compact_requests.len(), 1);
|
||||
assert_eq!(compact_requests[0].path(), "/v1/responses/compact");
|
||||
|
||||
let response_requests = responses_log.requests();
|
||||
assert_eq!(response_requests.len(), 3);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_thread(mcp: &mut McpProcess) -> Result<String> {
|
||||
let thread_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
Ok(thread.id)
|
||||
}
|
||||
|
||||
async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) -> Result<String> {
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
wait_for_turn_completed(mcp, &turn.id).await?;
|
||||
Ok(turn.id)
|
||||
}
|
||||
|
||||
async fn wait_for_turn_completed(mcp: &mut McpProcess, turn_id: &str) -> Result<()> {
|
||||
loop {
|
||||
let notification: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: TurnCompletedNotification =
|
||||
serde_json::from_value(notification.params.clone().expect("turn/completed params"))?;
|
||||
if completed.turn.id == turn_id {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_context_compaction_started(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<ItemStartedNotification> {
|
||||
loop {
|
||||
let notification: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/started"),
|
||||
)
|
||||
.await??;
|
||||
let started: ItemStartedNotification =
|
||||
serde_json::from_value(notification.params.clone().expect("item/started params"))?;
|
||||
if let ThreadItem::ContextCompaction { .. } = started.item {
|
||||
return Ok(started);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_context_compaction_completed(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<ItemCompletedNotification> {
|
||||
loop {
|
||||
let notification: JSONRPCNotification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("item/completed"),
|
||||
)
|
||||
.await??;
|
||||
let completed: ItemCompletedNotification =
|
||||
serde_json::from_value(notification.params.clone().expect("item/completed params"))?;
|
||||
if let ThreadItem::ContextCompaction { .. } = completed.item {
|
||||
return Ok(completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ mod account;
|
||||
mod analytics;
|
||||
mod app_list;
|
||||
mod collaboration_mode_list;
|
||||
mod compaction;
|
||||
mod config_rpc;
|
||||
mod dynamic_tools;
|
||||
mod initialize;
|
||||
mod model_list;
|
||||
mod output_schema;
|
||||
mod plan_item;
|
||||
mod rate_limits;
|
||||
mod request_user_input;
|
||||
mod review;
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::PlanDeltaNotification;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_core::features::FEATURES;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let plan_block = "<proposed_plan>\n# Final plan\n- first\n- second\n</proposed_plan>\n";
|
||||
let full_message = format!("Preface\n{plan_block}Postscript");
|
||||
let responses = vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_message_item_added("msg-1", ""),
|
||||
responses::ev_output_text_delta(&full_message),
|
||||
responses::ev_assistant_message("msg-1", &full_message),
|
||||
responses::ev_completed("resp-1"),
|
||||
])];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let turn = start_plan_mode_turn(&mut mcp).await?;
|
||||
let (_, completed_items, plan_deltas, turn_completed) =
|
||||
collect_turn_notifications(&mut mcp).await?;
|
||||
|
||||
assert_eq!(turn_completed.turn.id, turn.id);
|
||||
assert_eq!(turn_completed.turn.status, TurnStatus::Completed);
|
||||
|
||||
let expected_plan = ThreadItem::Plan {
|
||||
id: format!("{}-plan", turn.id),
|
||||
text: "# Final plan\n- first\n- second\n".to_string(),
|
||||
};
|
||||
let expected_plan_id = format!("{}-plan", turn.id);
|
||||
let streamed_plan = plan_deltas
|
||||
.iter()
|
||||
.map(|delta| delta.delta.as_str())
|
||||
.collect::<String>();
|
||||
assert_eq!(streamed_plan, "# Final plan\n- first\n- second\n");
|
||||
assert!(
|
||||
plan_deltas
|
||||
.iter()
|
||||
.all(|delta| delta.item_id == expected_plan_id)
|
||||
);
|
||||
let plan_items = completed_items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ThreadItem::Plan { .. } => Some(item.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(plan_items, vec![expected_plan]);
|
||||
assert!(
|
||||
completed_items
|
||||
.iter()
|
||||
.any(|item| matches!(item, ThreadItem::AgentMessage { .. })),
|
||||
"agent message items should still be emitted alongside the plan item"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let responses = vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
])];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let _turn = start_plan_mode_turn(&mut mcp).await?;
|
||||
let (_, completed_items, plan_deltas, _) = collect_turn_notifications(&mut mcp).await?;
|
||||
|
||||
let has_plan_item = completed_items
|
||||
.iter()
|
||||
.any(|item| matches!(item, ThreadItem::Plan { .. }));
|
||||
assert!(!has_plan_item);
|
||||
assert!(plan_deltas.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_plan_mode_turn(mcp: &mut McpProcess) -> Result<codex_app_server_protocol::Turn> {
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let thread = to_response::<ThreadStartResponse>(thread_resp)?.thread;
|
||||
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: "mock-model".to_string(),
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Plan this".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
Ok(to_response::<TurnStartResponse>(turn_resp)?.turn)
|
||||
}
|
||||
|
||||
async fn collect_turn_notifications(
|
||||
mcp: &mut McpProcess,
|
||||
) -> Result<(
|
||||
Vec<ThreadItem>,
|
||||
Vec<ThreadItem>,
|
||||
Vec<PlanDeltaNotification>,
|
||||
TurnCompletedNotification,
|
||||
)> {
|
||||
let mut started_items = Vec::new();
|
||||
let mut completed_items = Vec::new();
|
||||
let mut plan_deltas = Vec::new();
|
||||
|
||||
loop {
|
||||
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
|
||||
let JSONRPCMessage::Notification(notification) = message else {
|
||||
continue;
|
||||
};
|
||||
match notification.method.as_str() {
|
||||
"item/started" => {
|
||||
let params = notification
|
||||
.params
|
||||
.ok_or_else(|| anyhow!("item/started notifications must include params"))?;
|
||||
let payload: ItemStartedNotification = serde_json::from_value(params)?;
|
||||
started_items.push(payload.item);
|
||||
}
|
||||
"item/completed" => {
|
||||
let params = notification
|
||||
.params
|
||||
.ok_or_else(|| anyhow!("item/completed notifications must include params"))?;
|
||||
let payload: ItemCompletedNotification = serde_json::from_value(params)?;
|
||||
completed_items.push(payload.item);
|
||||
}
|
||||
"item/plan/delta" => {
|
||||
let params = notification
|
||||
.params
|
||||
.ok_or_else(|| anyhow!("item/plan/delta notifications must include params"))?;
|
||||
let payload: PlanDeltaNotification = serde_json::from_value(params)?;
|
||||
plan_deltas.push(payload);
|
||||
}
|
||||
"turn/completed" => {
|
||||
let params = notification
|
||||
.params
|
||||
.ok_or_else(|| anyhow!("turn/completed notifications must include params"))?;
|
||||
let payload: TurnCompletedNotification = serde_json::from_value(params)?;
|
||||
return Ok((started_items, completed_items, plan_deltas, payload));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
|
||||
let features = BTreeMap::from([
|
||||
(Feature::RemoteModels, false),
|
||||
(Feature::CollaborationModes, true),
|
||||
]);
|
||||
let feature_entries = features
|
||||
.into_iter()
|
||||
.map(|(feature, enabled)| {
|
||||
let key = FEATURES
|
||||
.iter()
|
||||
.find(|spec| spec.id == feature)
|
||||
.map(|spec| spec.key)
|
||||
.unwrap_or_else(|| panic!("missing feature key for {feature:?}"));
|
||||
format!("{key} = {enabled}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
std::fs::write(
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
{feature_entries}
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
base_url = "{server_uri}/v1"
|
||||
wire_api = "responses"
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
|
||||
const DEFAULT_BASE_INSTRUCTIONS: &str = "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.";
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_returns_original_thread() -> Result<()> {
|
||||
@@ -368,7 +368,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_accepts_personality_override() -> Result<()> {
|
||||
async fn thread_resume_accepts_personality_override_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
@@ -402,7 +402,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread.id.clone(),
|
||||
model: Some("gpt-5.2-codex".to_string()),
|
||||
personality: Some(Personality::Pragmatic),
|
||||
personality: Some(Personality::Friendly),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -438,14 +438,14 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
|
||||
let request = response_mock.single_request();
|
||||
let developer_texts = request.message_input_texts("developer");
|
||||
assert!(
|
||||
developer_texts
|
||||
!developer_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<personality_spec>")),
|
||||
"expected a personality update message in developer input, got {developer_texts:?}"
|
||||
"did not expect a personality update message in developer input, got {developer_texts:?}"
|
||||
);
|
||||
let instructions_text = request.instructions_text();
|
||||
assert!(
|
||||
instructions_text.contains(CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT),
|
||||
instructions_text.contains(DEFAULT_BASE_INSTRUCTIONS),
|
||||
"expected default base instructions from history, got {instructions_text:?}"
|
||||
);
|
||||
|
||||
@@ -459,7 +459,7 @@ fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io
|
||||
config_toml,
|
||||
format!(
|
||||
r#"
|
||||
model = "gpt-5.2-codex"
|
||||
model = "mock-model"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "read-only"
|
||||
|
||||
@@ -467,7 +467,6 @@ model_provider = "mock_provider"
|
||||
|
||||
[features]
|
||||
remote_models = false
|
||||
personality = true
|
||||
|
||||
[model_providers.mock_provider]
|
||||
name = "Mock provider for test"
|
||||
|
||||
@@ -11,11 +11,7 @@ use codex_app_server_protocol::ThreadUnarchiveParams;
|
||||
use codex_app_server_protocol::ThreadUnarchiveResponse;
|
||||
use codex_core::find_archived_thread_path_by_id_str;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -66,16 +62,6 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result
|
||||
archived_path.exists(),
|
||||
"expected {archived_path_display} to exist"
|
||||
);
|
||||
let old_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1);
|
||||
let old_timestamp = old_time
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("old timestamp")
|
||||
.as_secs() as i64;
|
||||
let times = FileTimes::new().set_modified(old_time);
|
||||
OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&archived_path)?
|
||||
.set_times(times)?;
|
||||
|
||||
let unarchive_id = mcp
|
||||
.send_thread_unarchive_request(ThreadUnarchiveParams {
|
||||
@@ -87,13 +73,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(unarchive_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadUnarchiveResponse {
|
||||
thread: unarchived_thread,
|
||||
} = to_response::<ThreadUnarchiveResponse>(unarchive_resp)?;
|
||||
assert!(
|
||||
unarchived_thread.updated_at > old_timestamp,
|
||||
"expected updated_at to be bumped on unarchive"
|
||||
);
|
||||
let _: ThreadUnarchiveResponse = to_response::<ThreadUnarchiveResponse>(unarchive_resp)?;
|
||||
|
||||
let rollout_path_display = rollout_path.display();
|
||||
assert!(
|
||||
|
||||
@@ -53,6 +53,18 @@ use tokio::time::timeout;
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const TEST_ORIGINATOR: &str = "codex_vscode";
|
||||
|
||||
fn untrusted_shell_command() -> Vec<String> {
|
||||
if cfg!(windows) {
|
||||
vec!["Get-Process".to_string()]
|
||||
} else {
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_sends_originator_header() -> Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response("Done")?];
|
||||
@@ -63,7 +75,7 @@ async fn turn_start_sends_originator_header() -> Result<()> {
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::Personality, true)]),
|
||||
&BTreeMap::default(),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -138,7 +150,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::Personality, true)]),
|
||||
&BTreeMap::default(),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -230,7 +242,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::Personality, true)]),
|
||||
&BTreeMap::default(),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -425,7 +437,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::Personality, true)]),
|
||||
&BTreeMap::default(),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -451,7 +463,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
personality: Some(Personality::Pragmatic),
|
||||
personality: Some(Personality::Friendly),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
@@ -473,7 +485,6 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
|
||||
if developer_texts.is_empty() {
|
||||
eprintln!("request body: {}", request.body_json());
|
||||
}
|
||||
|
||||
assert!(
|
||||
developer_texts
|
||||
.iter()
|
||||
@@ -484,117 +495,6 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_change_personality_mid_thread_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let sse1 = responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]);
|
||||
let sse2 = responses::sse(vec![
|
||||
responses::ev_response_created("resp-2"),
|
||||
responses::ev_assistant_message("msg-2", "Done"),
|
||||
responses::ev_completed("resp-2"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_sequence(&server, vec![sse1, sse2]).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::Personality, true)]),
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
model: Some("exp-codex-personality".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
personality: None,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let turn_req2 = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello again".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
personality: Some(Personality::Pragmatic),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_resp2: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req2)),
|
||||
)
|
||||
.await??;
|
||||
let _turn2: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp2)?;
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2, "expected two requests");
|
||||
|
||||
let first_developer_texts = requests[0].message_input_texts("developer");
|
||||
assert!(
|
||||
first_developer_texts
|
||||
.iter()
|
||||
.all(|text| !text.contains("<personality_spec>")),
|
||||
"expected no personality update message in first request, got {first_developer_texts:?}"
|
||||
);
|
||||
|
||||
let second_developer_texts = requests[1].message_input_texts("developer");
|
||||
assert!(
|
||||
second_developer_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<personality_spec>")),
|
||||
"expected personality update message in second request, got {second_developer_texts:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_accepts_local_image_input() -> Result<()> {
|
||||
// Two Codex turns hit the mock model (session start + turn/start).
|
||||
@@ -659,30 +559,12 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
let tmp = TempDir::new()?;
|
||||
let codex_home = tmp.path().to_path_buf();
|
||||
|
||||
// Mock server: first turn requests a shell call (elicitation), then completes.
|
||||
// Mock server: first turn requests an untrusted shell call (elicitation), then completes.
|
||||
// Second turn same, but we'll set approval_policy=never to avoid elicitation.
|
||||
let responses = vec![
|
||||
create_shell_command_sse_response(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
None,
|
||||
Some(5000),
|
||||
"call1",
|
||||
)?,
|
||||
create_shell_command_sse_response(untrusted_shell_command(), None, Some(5000), "call1")?,
|
||||
create_final_assistant_message_sse_response("done 1")?,
|
||||
create_shell_command_sse_response(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
None,
|
||||
Some(5000),
|
||||
"call2",
|
||||
)?,
|
||||
create_shell_command_sse_response(untrusted_shell_command(), None, Some(5000), "call2")?,
|
||||
create_final_assistant_message_sse_response("done 2")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence(responses).await;
|
||||
@@ -805,11 +687,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> {
|
||||
|
||||
let responses = vec![
|
||||
create_shell_command_sse_response(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(42)".to_string(),
|
||||
],
|
||||
untrusted_shell_command(),
|
||||
None,
|
||||
Some(5000),
|
||||
"call-decline",
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn run_main() -> i32 {
|
||||
match std::io::stdin().read_to_string(&mut buf) {
|
||||
Ok(_) => {
|
||||
if buf.is_empty() {
|
||||
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply_patch");
|
||||
eprintln!("Usage: apply_patch 'PATCH'\n echo 'PATCH' | apply-patch");
|
||||
return 2;
|
||||
}
|
||||
buf
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use codex_utils_cargo_bin::find_resource;
|
||||
use codex_utils_cargo_bin::repo_root;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
@@ -9,7 +9,12 @@ use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_scenarios() -> anyhow::Result<()> {
|
||||
let scenarios_dir = find_resource!("tests/fixtures/scenarios")?;
|
||||
let scenarios_dir = repo_root()?
|
||||
.join("codex-rs")
|
||||
.join("apply-patch")
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("scenarios");
|
||||
for scenario in fs::read_dir(scenarios_dir)? {
|
||||
let scenario = scenario?;
|
||||
let path = scenario.path();
|
||||
|
||||
@@ -351,7 +351,6 @@ impl Client {
|
||||
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
|
||||
match plan_type {
|
||||
crate::types::PlanType::Free => AccountPlanType::Free,
|
||||
crate::types::PlanType::Go => AccountPlanType::Go,
|
||||
crate::types::PlanType::Plus => AccountPlanType::Plus,
|
||||
crate::types::PlanType::Pro => AccountPlanType::Pro,
|
||||
crate::types::PlanType::Team => AccountPlanType::Team,
|
||||
@@ -359,6 +358,7 @@ impl Client {
|
||||
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
|
||||
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
|
||||
crate::types::PlanType::Guest
|
||||
| crate::types::PlanType::Go
|
||||
| crate::types::PlanType::FreeWorkspace
|
||||
| crate::types::PlanType::Quorum
|
||||
| crate::types::PlanType::K12 => AccountPlanType::Unknown,
|
||||
|
||||
@@ -17,8 +17,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
codex-git = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -5,21 +5,13 @@ use crate::chatgpt_token::get_chatgpt_token_data;
|
||||
use crate::chatgpt_token::init_chatgpt_token_from_auth;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Make a GET request to the ChatGPT backend API.
|
||||
pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
path: String,
|
||||
) -> anyhow::Result<T> {
|
||||
chatgpt_get_request_with_timeout(config, path, None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
path: String,
|
||||
timeout: Option<Duration>,
|
||||
) -> anyhow::Result<T> {
|
||||
let chatgpt_base_url = &config.chatgpt_base_url;
|
||||
init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
|
||||
@@ -36,17 +28,48 @@ pub(crate) async fn chatgpt_get_request_with_timeout<T: DeserializeOwned>(
|
||||
anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`")
|
||||
});
|
||||
|
||||
let mut request = client
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(&token.access_token)
|
||||
.header("chatgpt-account-id", account_id?)
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
if let Some(timeout) = timeout {
|
||||
request = request.timeout(timeout);
|
||||
}
|
||||
|
||||
let response = request.send().await.context("Failed to send request")?;
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: T = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse JSON response")?;
|
||||
Ok(result)
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Request failed with status {status}: {body}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn chatgpt_post_request<T: DeserializeOwned, P: Serialize>(
|
||||
config: &Config,
|
||||
access_token: &str,
|
||||
account_id: &str,
|
||||
path: &str,
|
||||
payload: &P,
|
||||
) -> anyhow::Result<T> {
|
||||
let chatgpt_base_url = &config.chatgpt_base_url;
|
||||
let client = create_client();
|
||||
let url = format!("{chatgpt_base_url}{path}");
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.header("chatgpt-account-id", account_id)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(payload)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: T = response
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::features::Feature;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
|
||||
use crate::chatgpt_client::chatgpt_post_request;
|
||||
use crate::chatgpt_token::get_chatgpt_token_data;
|
||||
use crate::chatgpt_token::init_chatgpt_token_from_auth;
|
||||
|
||||
pub use codex_core::connectors::AppInfo;
|
||||
pub use codex_core::connectors::ConnectorInfo;
|
||||
pub use codex_core::connectors::connector_display_label;
|
||||
use codex_core::connectors::connector_install_url;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
||||
use codex_core::connectors::merge_connectors;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirectoryListResponse {
|
||||
apps: Vec<DirectoryApp>,
|
||||
#[serde(alias = "nextToken")]
|
||||
next_token: Option<String>,
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListConnectorsRequest {
|
||||
principals: Vec<Principal>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DirectoryApp {
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Principal {
|
||||
#[serde(rename = "type")]
|
||||
principal_type: PrincipalType,
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
#[serde(alias = "logoUrl")]
|
||||
logo_url: Option<String>,
|
||||
#[serde(alias = "logoUrlDark")]
|
||||
logo_url_dark: Option<String>,
|
||||
#[serde(alias = "distributionChannel")]
|
||||
distribution_channel: Option<String>,
|
||||
visibility: Option<String>,
|
||||
}
|
||||
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
enum PrincipalType {
|
||||
User,
|
||||
}
|
||||
|
||||
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListConnectorsResponse {
|
||||
connectors: Vec<ConnectorInfo>,
|
||||
}
|
||||
|
||||
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
|
||||
if !config.features.enabled(Feature::Connectors) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (connectors_result, accessible_result) = tokio::join!(
|
||||
@@ -48,12 +46,11 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
);
|
||||
let connectors = connectors_result?;
|
||||
let accessible = accessible_result?;
|
||||
let merged = merge_connectors(connectors, accessible);
|
||||
Ok(filter_disallowed_connectors(merged))
|
||||
Ok(merge_connectors(connectors, accessible))
|
||||
}
|
||||
|
||||
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<ConnectorInfo>> {
|
||||
if !config.features.enabled(Feature::Connectors) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
|
||||
@@ -61,149 +58,56 @@ pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>
|
||||
|
||||
let token_data =
|
||||
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
|
||||
let mut apps = list_directory_connectors(config).await?;
|
||||
if token_data.id_token.is_workspace_account() {
|
||||
apps.extend(list_workspace_connectors(config).await?);
|
||||
}
|
||||
let mut connectors = merge_directory_apps(apps)
|
||||
.into_iter()
|
||||
.map(directory_app_to_app_info)
|
||||
.collect::<Vec<_>>();
|
||||
let user_id = token_data
|
||||
.id_token
|
||||
.chatgpt_user_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("ChatGPT user ID not available, please re-run `codex login`")
|
||||
})?;
|
||||
let account_id = token_data
|
||||
.id_token
|
||||
.chatgpt_account_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`")
|
||||
})?;
|
||||
let principal_id = format!("{user_id}__{account_id}");
|
||||
let request = ListConnectorsRequest {
|
||||
principals: vec![Principal {
|
||||
principal_type: PrincipalType::User,
|
||||
id: principal_id,
|
||||
}],
|
||||
};
|
||||
let response: ListConnectorsResponse = chatgpt_post_request(
|
||||
config,
|
||||
token_data.access_token.as_str(),
|
||||
account_id,
|
||||
"/aip/connectors/list_accessible?skip_actions=true&external_logos=true",
|
||||
&request,
|
||||
)
|
||||
.await?;
|
||||
let mut connectors = response.connectors;
|
||||
for connector in &mut connectors {
|
||||
let install_url = match connector.install_url.take() {
|
||||
Some(install_url) => install_url,
|
||||
None => connector_install_url(&connector.name, &connector.id),
|
||||
None => connector_install_url(&connector.connector_name, &connector.connector_id),
|
||||
};
|
||||
connector.name = normalize_connector_name(&connector.name, &connector.id);
|
||||
connector.description = normalize_connector_value(connector.description.as_deref());
|
||||
connector.connector_name =
|
||||
normalize_connector_name(&connector.connector_name, &connector.connector_id);
|
||||
connector.connector_description =
|
||||
normalize_connector_value(connector.connector_description.as_deref());
|
||||
connector.install_url = Some(install_url);
|
||||
connector.is_accessible = false;
|
||||
}
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
left.connector_name
|
||||
.cmp(&right.connector_name)
|
||||
.then_with(|| left.connector_id.cmp(&right.connector_id))
|
||||
});
|
||||
Ok(connectors)
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
|
||||
let mut apps = Vec::new();
|
||||
let mut next_token: Option<String> = None;
|
||||
loop {
|
||||
let path = match next_token.as_deref() {
|
||||
Some(token) => {
|
||||
let encoded_token = urlencoding::encode(token);
|
||||
format!("/connectors/directory/list?tier=categorized&token={encoded_token}")
|
||||
}
|
||||
None => "/connectors/directory/list?tier=categorized".to_string(),
|
||||
};
|
||||
let response: DirectoryListResponse =
|
||||
chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT))
|
||||
.await?;
|
||||
apps.extend(
|
||||
response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_hidden_directory_app(app)),
|
||||
);
|
||||
next_token = response
|
||||
.next_token
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty());
|
||||
if next_token.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
|
||||
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
|
||||
config,
|
||||
"/connectors/directory/list_workspace".to_string(),
|
||||
Some(DIRECTORY_CONNECTORS_TIMEOUT),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => Ok(response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_hidden_directory_app(app))
|
||||
.collect()),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
|
||||
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
|
||||
for app in apps {
|
||||
if let Some(existing) = merged.get_mut(&app.id) {
|
||||
merge_directory_app(existing, app);
|
||||
} else {
|
||||
merged.insert(app.id.clone(), app);
|
||||
}
|
||||
}
|
||||
merged.into_values().collect()
|
||||
}
|
||||
|
||||
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
|
||||
let DirectoryApp {
|
||||
id: _,
|
||||
name,
|
||||
description,
|
||||
logo_url,
|
||||
logo_url_dark,
|
||||
distribution_channel,
|
||||
visibility: _,
|
||||
} = incoming;
|
||||
|
||||
let incoming_name_is_empty = name.trim().is_empty();
|
||||
if existing.name.trim().is_empty() && !incoming_name_is_empty {
|
||||
existing.name = name;
|
||||
}
|
||||
|
||||
let incoming_description_present = description
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let existing_description_present = existing
|
||||
.description
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !existing_description_present && incoming_description_present {
|
||||
existing.description = description;
|
||||
}
|
||||
|
||||
if existing.logo_url.is_none() && logo_url.is_some() {
|
||||
existing.logo_url = logo_url;
|
||||
}
|
||||
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
|
||||
existing.logo_url_dark = logo_url_dark;
|
||||
}
|
||||
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
|
||||
existing.distribution_channel = distribution_channel;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
|
||||
matches!(app.visibility.as_deref(), Some("HIDDEN"))
|
||||
}
|
||||
|
||||
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
AppInfo {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
logo_url: app.logo_url,
|
||||
logo_url_dark: app.logo_url_dark,
|
||||
distribution_channel: app.distribution_channel,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -219,91 +123,3 @@ fn normalize_connector_value(value: Option<&str>) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
const ALLOWED_APPS_SDK_APPS: &[&str] = &["asdk_app_69781557cc1481919cf5e9824fa2e792"];
|
||||
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
|
||||
"asdk_app_6938a94a61d881918ef32cb999ff937c",
|
||||
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
|
||||
"connector_68de829bf7648191acd70a907364c67c",
|
||||
];
|
||||
const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_";
|
||||
|
||||
fn filter_disallowed_connectors(connectors: Vec<AppInfo>) -> Vec<AppInfo> {
|
||||
// TODO: Support Apps SDK connectors.
|
||||
connectors
|
||||
.into_iter()
|
||||
.filter(is_connector_allowed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_connector_allowed(connector: &AppInfo) -> bool {
|
||||
let connector_id = connector.id.as_str();
|
||||
if connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX)
|
||||
|| DISALLOWED_CONNECTOR_IDS.contains(&connector_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if connector_id.starts_with("asdk_app_") {
|
||||
return ALLOWED_APPS_SDK_APPS.contains(&connector_id);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn app(id: &str) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_internal_asdk_connectors() {
|
||||
let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]);
|
||||
assert_eq!(filtered, vec![app("alpha")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_whitelisted_asdk_connectors() {
|
||||
let filtered = filter_disallowed_connectors(vec![
|
||||
app("asdk_app_69781557cc1481919cf5e9824fa2e792"),
|
||||
app("beta"),
|
||||
]);
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![
|
||||
app("asdk_app_69781557cc1481919cf5e9824fa2e792"),
|
||||
app("beta")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_openai_connectors() {
|
||||
let filtered = filter_disallowed_connectors(vec![
|
||||
app("connector_openai_foo"),
|
||||
app("connector_openai_bar"),
|
||||
app("gamma"),
|
||||
]);
|
||||
assert_eq!(filtered, vec![app("gamma")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_disallowed_connector_ids() {
|
||||
let filtered = filter_disallowed_connectors(vec![
|
||||
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
|
||||
app("delta"),
|
||||
]);
|
||||
assert_eq!(filtered, vec![app("delta")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
let config = load_config_or_exit(cli_config_overrides).await;
|
||||
|
||||
match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) {
|
||||
Ok(Some(auth)) => match auth.api_auth_mode() {
|
||||
Ok(Some(auth)) => match auth.mode {
|
||||
AuthMode::ApiKey => match auth.get_token() {
|
||||
Ok(api_key) => {
|
||||
eprintln!("Logged in using an API key - {}", safe_format_key(&api_key));
|
||||
@@ -236,14 +236,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
std::process::exit(1);
|
||||
}
|
||||
},
|
||||
AuthMode::Chatgpt => {
|
||||
AuthMode::ChatGPT => {
|
||||
eprintln!("Logged in using ChatGPT");
|
||||
std::process::exit(0);
|
||||
}
|
||||
AuthMode::ChatgptAuthTokens => {
|
||||
eprintln!("Logged in using ChatGPT (external tokens)");
|
||||
std::process::exit(0);
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
eprintln!("Not logged in");
|
||||
|
||||
@@ -27,12 +27,8 @@ use codex_tui::Cli as TuiCli;
|
||||
use codex_tui::ExitReason;
|
||||
use codex_tui::update_action::UpdateAction;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::ffi::OsStr;
|
||||
use std::ffi::OsString;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod mcp_cmd;
|
||||
@@ -43,9 +39,6 @@ use crate::mcp_cmd::McpCli;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::features::Stage;
|
||||
use codex_core::features::is_known_feature_key;
|
||||
use codex_core::terminal::TerminalName;
|
||||
|
||||
@@ -109,10 +102,6 @@ enum Subcommand {
|
||||
#[clap(visible_alias = "debug")]
|
||||
Sandbox(SandboxArgs),
|
||||
|
||||
/// Tooling: helps debug the app server.
|
||||
#[clap(hide = true, name = "debug-app-server")]
|
||||
DebugAppServer(DebugAppServerCommand),
|
||||
|
||||
/// Execpolicy tooling.
|
||||
#[clap(hide = true)]
|
||||
Execpolicy(ExecpolicyCommand),
|
||||
@@ -150,16 +139,9 @@ struct CompletionCommand {
|
||||
shell: Shell,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct DebugAppServerCommand {
|
||||
/// Message to send through codex-app-server-test-client send-message-v2.
|
||||
#[arg(value_name = "USER_MESSAGE", required = true, num_args = 1.., trailing_var_arg = true)]
|
||||
user_message_parts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ResumeCommand {
|
||||
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
|
||||
/// Conversation/session id (UUID). When provided, resumes this session.
|
||||
/// If omitted, use --last to pick the most recent recorded session.
|
||||
#[arg(value_name = "SESSION_ID")]
|
||||
session_id: Option<String>,
|
||||
@@ -338,7 +320,6 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
let AppExitInfo {
|
||||
token_usage,
|
||||
thread_id: conversation_id,
|
||||
thread_name,
|
||||
..
|
||||
} = exit_info;
|
||||
|
||||
@@ -351,9 +332,8 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
|
||||
codex_core::protocol::FinalOutput::from(token_usage)
|
||||
)];
|
||||
|
||||
if let Some(resume_cmd) =
|
||||
codex_core::util::resume_command(thread_name.as_deref(), conversation_id)
|
||||
{
|
||||
if let Some(session_id) = conversation_id {
|
||||
let resume_cmd = format!("codex resume {session_id}");
|
||||
let command = if color_enabled {
|
||||
resume_cmd.cyan().to_string()
|
||||
} else {
|
||||
@@ -424,64 +404,6 @@ fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> {
|
||||
cmd.run()
|
||||
}
|
||||
|
||||
fn is_codex_rs_workspace_dir(dir: &Path) -> bool {
|
||||
dir.join("Cargo.toml").is_file()
|
||||
&& dir
|
||||
.join("app-server-test-client")
|
||||
.join("Cargo.toml")
|
||||
.is_file()
|
||||
}
|
||||
|
||||
fn resolve_codex_rs_dir() -> anyhow::Result<PathBuf> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
if is_codex_rs_workspace_dir(&cwd) {
|
||||
return Ok(cwd);
|
||||
}
|
||||
|
||||
let codex_rs = cwd.join("codex-rs");
|
||||
if is_codex_rs_workspace_dir(&codex_rs) {
|
||||
return Ok(codex_rs);
|
||||
}
|
||||
|
||||
anyhow::bail!("could not locate codex-rs workspace from {}", cwd.display());
|
||||
}
|
||||
|
||||
fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
|
||||
let codex_rs_dir = resolve_codex_rs_dir()?;
|
||||
let user_message = cmd.user_message_parts.join(" ");
|
||||
let status = Command::new("cargo")
|
||||
.arg("run")
|
||||
.arg("-p")
|
||||
.arg("codex-app-server-test-client")
|
||||
.arg("--")
|
||||
.arg("send-message-v2")
|
||||
.arg(user_message)
|
||||
.current_dir(codex_rs_dir)
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("debug command failed with status {status}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remap_debug_app_server_args(args: Vec<OsString>) -> Vec<OsString> {
|
||||
if args.get(1).is_some_and(|arg| arg == OsStr::new("debug"))
|
||||
&& args
|
||||
.get(2)
|
||||
.is_some_and(|arg| arg == OsStr::new("app-server"))
|
||||
{
|
||||
let mut remapped = Vec::with_capacity(args.len().saturating_sub(1));
|
||||
remapped.push(args[0].clone());
|
||||
remapped.push(OsString::from("debug-app-server"));
|
||||
remapped.extend(args.into_iter().skip(3));
|
||||
return remapped;
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Parser, Clone)]
|
||||
struct FeatureToggles {
|
||||
/// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`.
|
||||
@@ -526,16 +448,6 @@ struct FeaturesCli {
|
||||
enum FeaturesSubcommand {
|
||||
/// List known features with their stage and effective state.
|
||||
List,
|
||||
/// Enable a feature in config.toml.
|
||||
Enable(FeatureSetArgs),
|
||||
/// Disable a feature in config.toml.
|
||||
Disable(FeatureSetArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct FeatureSetArgs {
|
||||
/// Feature key to update (for example: unified_exec).
|
||||
feature: String,
|
||||
}
|
||||
|
||||
fn stage_str(stage: codex_core::features::Stage) -> &'static str {
|
||||
@@ -550,20 +462,42 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str {
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
cli_main(codex_linux_sandbox_exe).await?;
|
||||
Ok(())
|
||||
})
|
||||
#[cfg(windows)]
|
||||
{
|
||||
run_cli_with_large_stack()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
cli_main(codex_linux_sandbox_exe).await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn run_cli_with_large_stack() -> anyhow::Result<()> {
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(8 * 1024 * 1024)
|
||||
.spawn(|| {
|
||||
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
|
||||
cli_main(codex_linux_sandbox_exe).await?;
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(err) => std::panic::resume_unwind(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let parsed_args = remap_debug_app_server_args(std::env::args_os().collect());
|
||||
let MultitoolCli {
|
||||
config_overrides: mut root_config_overrides,
|
||||
feature_toggles,
|
||||
mut interactive,
|
||||
subcommand,
|
||||
} = MultitoolCli::parse_from(parsed_args);
|
||||
} = MultitoolCli::parse();
|
||||
|
||||
// Fold --enable/--disable into config overrides so they flow to all subcommands.
|
||||
let toggle_overrides = feature_toggles.to_overrides()?;
|
||||
@@ -739,9 +673,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
Some(Subcommand::DebugAppServer(cmd)) => {
|
||||
run_debug_app_server_command(cmd)?;
|
||||
}
|
||||
Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
|
||||
ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?,
|
||||
},
|
||||
@@ -803,69 +734,12 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
println!("{name:<name_width$} {stage:<stage_width$} {enabled}");
|
||||
}
|
||||
}
|
||||
FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => {
|
||||
enable_feature_in_config(&interactive, &feature).await?;
|
||||
}
|
||||
FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
|
||||
disable_feature_in_config(&interactive, &feature).await?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
|
||||
FeatureToggles::validate_feature(feature)?;
|
||||
let codex_home = find_codex_home()?;
|
||||
ConfigEditsBuilder::new(&codex_home)
|
||||
.with_profile(interactive.config_profile.as_deref())
|
||||
.set_feature_enabled(feature, true)
|
||||
.apply()
|
||||
.await?;
|
||||
println!("Enabled feature `{feature}` in config.toml.");
|
||||
maybe_print_under_development_feature_warning(&codex_home, interactive, feature);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
|
||||
FeatureToggles::validate_feature(feature)?;
|
||||
let codex_home = find_codex_home()?;
|
||||
ConfigEditsBuilder::new(&codex_home)
|
||||
.with_profile(interactive.config_profile.as_deref())
|
||||
.set_feature_enabled(feature, false)
|
||||
.apply()
|
||||
.await?;
|
||||
println!("Disabled feature `{feature}` in config.toml.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maybe_print_under_development_feature_warning(
|
||||
codex_home: &std::path::Path,
|
||||
interactive: &TuiCli,
|
||||
feature: &str,
|
||||
) {
|
||||
if interactive.config_profile.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(spec) = codex_core::features::FEATURES
|
||||
.iter()
|
||||
.find(|spec| spec.key == feature)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !matches!(spec.stage, Stage::UnderDevelopment) {
|
||||
return;
|
||||
}
|
||||
|
||||
let config_path = codex_home.join(codex_core::config::CONFIG_TOML_FILE);
|
||||
eprintln!(
|
||||
"Under-development features enabled: {feature}. Under-development features are incomplete and may behave unpredictably. To suppress this warning, set `suppress_unstable_features_warning = true` in {}.",
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
/// Prepend root-level overrides so they have lower precedence than
|
||||
/// CLI-specific ones specified after the subcommand (if any).
|
||||
fn prepend_config_flags(
|
||||
@@ -1029,7 +903,6 @@ mod tests {
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn finalize_resume_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
@@ -1108,7 +981,7 @@ mod tests {
|
||||
app_server
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
|
||||
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
total_tokens: 2,
|
||||
@@ -1116,10 +989,7 @@ mod tests {
|
||||
};
|
||||
AppExitInfo {
|
||||
token_usage,
|
||||
thread_id: conversation_id
|
||||
.map(ThreadId::from_string)
|
||||
.map(Result::unwrap),
|
||||
thread_name: thread_name.map(str::to_string),
|
||||
thread_id: conversation.map(ThreadId::from_string).map(Result::unwrap),
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
}
|
||||
@@ -1130,7 +1000,6 @@ mod tests {
|
||||
let exit_info = AppExitInfo {
|
||||
token_usage: TokenUsage::default(),
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
update_action: None,
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
};
|
||||
@@ -1140,7 +1009,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_includes_resume_hint_without_color() {
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
|
||||
let lines = format_exit_messages(exit_info, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
@@ -1154,28 +1023,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_applies_color_when_enabled() {
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"), None);
|
||||
let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000"));
|
||||
let lines = format_exit_messages(exit_info, true);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[1].contains("\u{1b}[36m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_exit_messages_prefers_thread_name() {
|
||||
let exit_info = sample_exit_info(
|
||||
Some("123e4567-e89b-12d3-a456-426614174000"),
|
||||
Some("my-thread"),
|
||||
);
|
||||
let lines = format_exit_messages(exit_info, false);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec![
|
||||
"Token usage: total=2 input=0 output=2".to_string(),
|
||||
"To continue this session, run codex resume my-thread".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_model_flag_applies_when_no_root_flags() {
|
||||
let interactive =
|
||||
@@ -1341,63 +1194,6 @@ mod tests {
|
||||
assert!(app_server.analytics_default_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_debug_app_server_args_rewrites_app_server_form() {
|
||||
let input = vec![
|
||||
OsString::from("codex"),
|
||||
OsString::from("debug"),
|
||||
OsString::from("app-server"),
|
||||
OsString::from("hello"),
|
||||
OsString::from("world"),
|
||||
];
|
||||
let remapped = remap_debug_app_server_args(input);
|
||||
let expected = vec![
|
||||
OsString::from("codex"),
|
||||
OsString::from("debug-app-server"),
|
||||
OsString::from("hello"),
|
||||
OsString::from("world"),
|
||||
];
|
||||
assert_eq!(remapped, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_debug_app_server_args_keeps_legacy_debug_sandbox_form() {
|
||||
let input = vec![
|
||||
OsString::from("codex"),
|
||||
OsString::from("debug"),
|
||||
OsString::from("landlock"),
|
||||
OsString::from("pwd"),
|
||||
];
|
||||
let remapped = remap_debug_app_server_args(input.clone());
|
||||
assert_eq!(remapped, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_enable_parses_feature_name() {
|
||||
let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"])
|
||||
.expect("parse should succeed");
|
||||
let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
|
||||
panic!("expected features subcommand");
|
||||
};
|
||||
let FeaturesSubcommand::Enable(FeatureSetArgs { feature }) = sub else {
|
||||
panic!("expected features enable");
|
||||
};
|
||||
assert_eq!(feature, "unified_exec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_disable_parses_feature_name() {
|
||||
let cli = MultitoolCli::try_parse_from(["codex", "features", "disable", "shell_tool"])
|
||||
.expect("parse should succeed");
|
||||
let Some(Subcommand::Features(FeaturesCli { sub })) = cli.subcommand else {
|
||||
panic!("expected features subcommand");
|
||||
};
|
||||
let FeaturesSubcommand::Disable(FeatureSetArgs { feature }) = sub else {
|
||||
panic!("expected features disable");
|
||||
};
|
||||
assert_eq!(feature, "shell_tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_toggles_known_features_generate_overrides() {
|
||||
let toggles = FeatureToggles {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use predicates::str::contains;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
|
||||
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
cmd.env("CODEX_HOME", codex_home);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn features_enable_writes_feature_flag_to_config() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut cmd = codex_command(codex_home.path())?;
|
||||
cmd.args(["features", "enable", "unified_exec"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Enabled feature `unified_exec` in config.toml."));
|
||||
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains("[features]"));
|
||||
assert!(config.contains("unified_exec = true"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn features_disable_writes_feature_flag_to_config() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut cmd = codex_command(codex_home.path())?;
|
||||
cmd.args(["features", "disable", "shell_tool"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Disabled feature `shell_tool` in config.toml."));
|
||||
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains("[features]"));
|
||||
assert!(config.contains("shell_tool = false"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn features_enable_under_development_feature_prints_warning() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut cmd = codex_command(codex_home.path())?;
|
||||
cmd.args(["features", "enable", "sqlite"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(contains("Under-development features enabled: sqlite."));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "cloud-requirements",
|
||||
crate_name = "codex_cloud_requirements",
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "codex-cloud-requirements"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] }
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Cloud-hosted config requirements for Codex.
|
||||
//!
|
||||
//! This crate fetches `requirements.toml` data from the backend as an alternative to loading it
|
||||
//! from the local filesystem. It only applies to Business (aka Enterprise CBP) or Enterprise ChatGPT
|
||||
//! customers.
|
||||
//!
|
||||
//! Today, fetching is best-effort: on error or timeout, Codex continues without cloud requirements.
|
||||
//! We expect to tighten this so that Enterprise ChatGPT customers must successfully fetch these
|
||||
//! requirements before Codex will run.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::auth::CodexAuth;
|
||||
use codex_core::config_loader::CloudRequirementsLoader;
|
||||
use codex_core::config_loader::ConfigRequirementsToml;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// This blocks codex startup, so must be short.
|
||||
const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[async_trait]
|
||||
trait RequirementsFetcher: Send + Sync {
|
||||
/// Returns requirements as a TOML string.
|
||||
///
|
||||
/// TODO(gt): For now, returns an Option. But when we want to make this fail-closed, return a
|
||||
/// Result.
|
||||
async fn fetch_requirements(&self, auth: &CodexAuth) -> Option<String>;
|
||||
}
|
||||
|
||||
struct BackendRequirementsFetcher {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl BackendRequirementsFetcher {
|
||||
fn new(base_url: String) -> Self {
|
||||
Self { base_url }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RequirementsFetcher for BackendRequirementsFetcher {
|
||||
async fn fetch_requirements(&self, auth: &CodexAuth) -> Option<String> {
|
||||
let client = BackendClient::from_auth(self.base_url.clone(), auth)
|
||||
.inspect_err(|err| {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Failed to construct backend client for cloud requirements"
|
||||
);
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
let response = client
|
||||
.get_config_requirements_file()
|
||||
.await
|
||||
.inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements"))
|
||||
.ok()?;
|
||||
|
||||
let Some(contents) = response.contents else {
|
||||
tracing::warn!("Cloud requirements response missing contents");
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(contents)
|
||||
}
|
||||
}
|
||||
|
||||
struct CloudRequirementsService {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
fetcher: Arc<dyn RequirementsFetcher>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl CloudRequirementsService {
|
||||
fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
fetcher: Arc<dyn RequirementsFetcher>,
|
||||
timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
fetcher,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_with_timeout(&self) -> Option<ConfigRequirementsToml> {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.cloud_requirements.fetch.duration_ms", &[]);
|
||||
let started_at = Instant::now();
|
||||
let result = timeout(self.timeout, self.fetch())
|
||||
.await
|
||||
.inspect_err(|_| {
|
||||
tracing::warn!("Timed out waiting for cloud requirements; continuing without them");
|
||||
})
|
||||
.ok()?;
|
||||
|
||||
match result.as_ref() {
|
||||
Some(requirements) => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
requirements = ?requirements,
|
||||
"Cloud requirements load completed"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
elapsed_ms = started_at.elapsed().as_millis(),
|
||||
"Cloud requirements load completed (none)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn fetch(&self) -> Option<ConfigRequirementsToml> {
|
||||
let auth = self.auth_manager.auth().await?;
|
||||
if !auth.is_chatgpt_auth()
|
||||
|| !matches!(
|
||||
auth.account_plan_type(),
|
||||
Some(PlanType::Business | PlanType::Enterprise)
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents = self.fetcher.fetch_requirements(&auth).await?;
|
||||
parse_cloud_requirements(&contents)
|
||||
.inspect_err(|err| tracing::warn!(error = %err, "Failed to parse cloud requirements"))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cloud_requirements_loader(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
) -> CloudRequirementsLoader {
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager,
|
||||
Arc::new(BackendRequirementsFetcher::new(chatgpt_base_url)),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
let task = tokio::spawn(async move { service.fetch_with_timeout().await });
|
||||
CloudRequirementsLoader::new(async move {
|
||||
task.await
|
||||
.inspect_err(|err| tracing::warn!(error = %err, "Cloud requirements task failed"))
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_cloud_requirements(
|
||||
contents: &str,
|
||||
) -> Result<Option<ConfigRequirementsToml>, toml::de::Error> {
|
||||
if contents.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let requirements: ConfigRequirementsToml = toml::from_str(contents)?;
|
||||
if requirements.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(requirements))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::future::pending;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> {
|
||||
std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn auth_manager_with_api_key() -> Arc<AuthManager> {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let auth_json = json!({
|
||||
"OPENAI_API_KEY": "sk-test-key",
|
||||
"tokens": null,
|
||||
"last_refresh": null,
|
||||
});
|
||||
write_auth_json(tmp.path(), auth_json).expect("write auth");
|
||||
Arc::new(AuthManager::new(
|
||||
tmp.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
))
|
||||
}
|
||||
|
||||
fn auth_manager_with_plan(plan_type: &str) -> Arc<AuthManager> {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let header = json!({ "alg": "none", "typ": "JWT" });
|
||||
let auth_payload = json!({
|
||||
"chatgpt_plan_type": plan_type,
|
||||
"chatgpt_user_id": "user-12345",
|
||||
"user_id": "user-12345",
|
||||
});
|
||||
let payload = json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": auth_payload,
|
||||
});
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header"));
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload"));
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig");
|
||||
let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}");
|
||||
|
||||
let auth_json = json!({
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": fake_jwt,
|
||||
"access_token": "test-access-token",
|
||||
"refresh_token": "test-refresh-token",
|
||||
},
|
||||
"last_refresh": null,
|
||||
});
|
||||
write_auth_json(tmp.path(), auth_json).expect("write auth");
|
||||
Arc::new(AuthManager::new(
|
||||
tmp.path().to_path_buf(),
|
||||
false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_for_fetch(contents: Option<&str>) -> Option<ConfigRequirementsToml> {
|
||||
contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten())
|
||||
}
|
||||
|
||||
struct StaticFetcher {
|
||||
contents: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RequirementsFetcher for StaticFetcher {
|
||||
async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option<String> {
|
||||
self.contents.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct PendingFetcher;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RequirementsFetcher for PendingFetcher {
|
||||
async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option<String> {
|
||||
pending::<()>().await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_skips_non_chatgpt_auth() {
|
||||
let auth_manager = auth_manager_with_api_key();
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager,
|
||||
Arc::new(StaticFetcher { contents: None }),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
let result = service.fetch().await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_skips_non_business_or_enterprise_plan() {
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_plan("pro"),
|
||||
Arc::new(StaticFetcher { contents: None }),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
let result = service.fetch().await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_business_plan() {
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_plan("business"),
|
||||
Arc::new(StaticFetcher {
|
||||
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
|
||||
}),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
assert_eq!(
|
||||
service.fetch().await,
|
||||
Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_handles_missing_contents() {
|
||||
let result = parse_for_fetch(None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_handles_empty_contents() {
|
||||
let result = parse_for_fetch(Some(" "));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_handles_invalid_toml() {
|
||||
let result = parse_for_fetch(Some("not = ["));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_ignores_empty_requirements() {
|
||||
let result = parse_for_fetch(Some("# comment"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_parses_valid_toml() {
|
||||
let result = parse_for_fetch(Some("allowed_approval_policies = [\"never\"]"));
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fetch_cloud_requirements_times_out() {
|
||||
let auth_manager = auth_manager_with_plan("enterprise");
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager,
|
||||
Arc::new(PendingFetcher),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
let handle = tokio::spawn(async move { service.fetch_with_timeout().await });
|
||||
tokio::time::advance(CLOUD_REQUIREMENTS_TIMEOUT + Duration::from_millis(1)).await;
|
||||
|
||||
let result = handle.await.expect("cloud requirements task");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use crate::error::ApiError;
|
||||
use crate::provider::Provider;
|
||||
use crate::sse::responses::ResponsesStreamEvent;
|
||||
use crate::sse::responses::process_responses_event;
|
||||
use crate::telemetry::WebsocketTelemetry;
|
||||
use codex_client::TransportError;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
@@ -19,7 +18,6 @@ use std::time::Duration;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::Instant;
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
@@ -40,21 +38,14 @@ pub struct ResponsesWebsocketConnection {
|
||||
// TODO (pakrym): is this the right place for timeout?
|
||||
idle_timeout: Duration,
|
||||
server_reasoning_included: bool,
|
||||
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
||||
}
|
||||
|
||||
impl ResponsesWebsocketConnection {
|
||||
fn new(
|
||||
stream: WsStream,
|
||||
idle_timeout: Duration,
|
||||
server_reasoning_included: bool,
|
||||
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
||||
) -> Self {
|
||||
fn new(stream: WsStream, idle_timeout: Duration, server_reasoning_included: bool) -> Self {
|
||||
Self {
|
||||
stream: Arc::new(Mutex::new(Some(stream))),
|
||||
idle_timeout,
|
||||
server_reasoning_included,
|
||||
telemetry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +62,6 @@ impl ResponsesWebsocketConnection {
|
||||
let stream = Arc::clone(&self.stream);
|
||||
let idle_timeout = self.idle_timeout;
|
||||
let server_reasoning_included = self.server_reasoning_included;
|
||||
let telemetry = self.telemetry.clone();
|
||||
let request_body = serde_json::to_value(&request).map_err(|err| {
|
||||
ApiError::Stream(format!("failed to encode websocket request: {err}"))
|
||||
})?;
|
||||
@@ -97,7 +87,6 @@ impl ResponsesWebsocketConnection {
|
||||
tx_event.clone(),
|
||||
request_body,
|
||||
idle_timeout,
|
||||
telemetry,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -125,7 +114,6 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
|
||||
&self,
|
||||
extra_headers: HeaderMap,
|
||||
turn_state: Option<Arc<OnceLock<String>>>,
|
||||
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
||||
) -> Result<ResponsesWebsocketConnection, ApiError> {
|
||||
let ws_url = self
|
||||
.provider
|
||||
@@ -142,7 +130,6 @@ impl<A: AuthProvider> ResponsesWebsocketClient<A> {
|
||||
stream,
|
||||
self.provider.stream_idle_timeout,
|
||||
server_reasoning_included,
|
||||
telemetry,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -231,7 +218,6 @@ async fn run_websocket_response_stream(
|
||||
tx_event: mpsc::Sender<std::result::Result<ResponseEvent, ApiError>>,
|
||||
request_body: Value,
|
||||
idle_timeout: Duration,
|
||||
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
||||
) -> Result<(), ApiError> {
|
||||
let request_text = match serde_json::to_string(&request_body) {
|
||||
Ok(text) => text,
|
||||
@@ -242,26 +228,16 @@ async fn run_websocket_response_stream(
|
||||
}
|
||||
};
|
||||
|
||||
let request_start = Instant::now();
|
||||
let result = ws_stream
|
||||
.send(Message::Text(request_text.into()))
|
||||
.await
|
||||
.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")));
|
||||
|
||||
if let Some(t) = telemetry.as_ref() {
|
||||
t.on_ws_request(request_start.elapsed(), result.as_ref().err());
|
||||
if let Err(err) = ws_stream.send(Message::Text(request_text.into())).await {
|
||||
return Err(ApiError::Stream(format!(
|
||||
"failed to send websocket request: {err}"
|
||||
)));
|
||||
}
|
||||
|
||||
result?;
|
||||
|
||||
loop {
|
||||
let poll_start = Instant::now();
|
||||
let response = tokio::time::timeout(idle_timeout, ws_stream.next())
|
||||
.await
|
||||
.map_err(|_| ApiError::Stream("idle timeout waiting for websocket".into()));
|
||||
if let Some(t) = telemetry.as_ref() {
|
||||
t.on_ws_event(&response, poll_start.elapsed());
|
||||
}
|
||||
let message = match response {
|
||||
Ok(Some(Ok(msg))) => msg,
|
||||
Ok(Some(Err(err))) => {
|
||||
|
||||
@@ -33,11 +33,9 @@ pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection;
|
||||
pub use crate::error::ApiError;
|
||||
pub use crate::provider::Provider;
|
||||
pub use crate::provider::WireApi;
|
||||
pub use crate::provider::is_azure_responses_wire_base_url;
|
||||
pub use crate::requests::ChatRequest;
|
||||
pub use crate::requests::ChatRequestBuilder;
|
||||
pub use crate::requests::ResponsesRequest;
|
||||
pub use crate::requests::ResponsesRequestBuilder;
|
||||
pub use crate::sse::stream_from_fixture;
|
||||
pub use crate::telemetry::SseTelemetry;
|
||||
pub use crate::telemetry::WebsocketTelemetry;
|
||||
|
||||
@@ -95,7 +95,16 @@ impl Provider {
|
||||
}
|
||||
|
||||
pub fn is_azure_responses_endpoint(&self) -> bool {
|
||||
is_azure_responses_wire_base_url(self.wire.clone(), &self.name, Some(&self.base_url))
|
||||
if self.wire != WireApi::Responses {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.name.eq_ignore_ascii_case("azure") {
|
||||
return true;
|
||||
}
|
||||
|
||||
self.base_url.to_ascii_lowercase().contains("openai.azure.")
|
||||
|| matches_azure_responses_base_url(&self.base_url)
|
||||
}
|
||||
|
||||
pub fn websocket_url_for_path(&self, path: &str) -> Result<Url, url::ParseError> {
|
||||
@@ -112,23 +121,6 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_azure_responses_wire_base_url(wire: WireApi, name: &str, base_url: Option<&str>) -> bool {
|
||||
if wire != WireApi::Responses {
|
||||
return false;
|
||||
}
|
||||
|
||||
if name.eq_ignore_ascii_case("azure") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(base_url) = base_url else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let base = base_url.to_ascii_lowercase();
|
||||
base.contains("openai.azure.") || matches_azure_responses_base_url(&base)
|
||||
}
|
||||
|
||||
fn matches_azure_responses_base_url(base_url: &str) -> bool {
|
||||
const AZURE_MARKERS: [&str; 5] = [
|
||||
"cognitiveservices.azure.",
|
||||
@@ -137,54 +129,6 @@ fn matches_azure_responses_base_url(base_url: &str) -> bool {
|
||||
"azurefd.",
|
||||
"windows.net/openai",
|
||||
];
|
||||
AZURE_MARKERS.iter().any(|marker| base_url.contains(marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_azure_responses_base_urls() {
|
||||
let positive_cases = [
|
||||
"https://foo.openai.azure.com/openai",
|
||||
"https://foo.openai.azure.us/openai/deployments/bar",
|
||||
"https://foo.cognitiveservices.azure.cn/openai",
|
||||
"https://foo.aoai.azure.com/openai",
|
||||
"https://foo.openai.azure-api.net/openai",
|
||||
"https://foo.z01.azurefd.net/",
|
||||
];
|
||||
|
||||
for base_url in positive_cases {
|
||||
assert!(
|
||||
is_azure_responses_wire_base_url(WireApi::Responses, "test", Some(base_url)),
|
||||
"expected {base_url} to be detected as Azure"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(is_azure_responses_wire_base_url(
|
||||
WireApi::Responses,
|
||||
"Azure",
|
||||
Some("https://example.com")
|
||||
));
|
||||
|
||||
let negative_cases = [
|
||||
"https://api.openai.com/v1",
|
||||
"https://example.com/openai",
|
||||
"https://myproxy.azurewebsites.net/openai",
|
||||
];
|
||||
|
||||
for base_url in negative_cases {
|
||||
assert!(
|
||||
!is_azure_responses_wire_base_url(WireApi::Responses, "test", Some(base_url)),
|
||||
"expected {base_url} not to be detected as Azure"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(!is_azure_responses_wire_base_url(
|
||||
WireApi::Chat,
|
||||
"Azure",
|
||||
Some("https://foo.openai.azure.com/openai")
|
||||
));
|
||||
}
|
||||
let base = base_url.to_ascii_lowercase();
|
||||
AZURE_MARKERS.iter().any(|marker| base.contains(marker))
|
||||
}
|
||||
|
||||
@@ -41,14 +41,6 @@ pub fn parse_rate_limit(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses the bespoke Codex rate-limit headers into a `RateLimitSnapshot`.
|
||||
pub fn parse_promo_message(headers: &HeaderMap) -> Option<String> {
|
||||
parse_header_str(headers, "x-codex-promo-message")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::string::ToString::to_string)
|
||||
}
|
||||
|
||||
fn parse_rate_limit_window(
|
||||
headers: &HeaderMap,
|
||||
used_percent_header: &str,
|
||||
|
||||
@@ -157,7 +157,7 @@ struct ResponseCompletedOutputTokensDetails {
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ResponsesStreamEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: String,
|
||||
kind: String,
|
||||
response: Option<Value>,
|
||||
item: Option<Value>,
|
||||
delta: Option<String>,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::error::ApiError;
|
||||
use codex_client::Request;
|
||||
use codex_client::RequestTelemetry;
|
||||
use codex_client::Response;
|
||||
@@ -11,8 +10,6 @@ use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio_tungstenite::tungstenite::Error;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Generic telemetry.
|
||||
pub trait SseTelemetry: Send + Sync {
|
||||
@@ -31,17 +28,6 @@ pub trait SseTelemetry: Send + Sync {
|
||||
);
|
||||
}
|
||||
|
||||
/// Telemetry for Responses WebSocket transport.
|
||||
pub trait WebsocketTelemetry: Send + Sync {
|
||||
fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>);
|
||||
|
||||
fn on_ws_event(
|
||||
&self,
|
||||
result: &Result<Option<Result<Message, Error>>, ApiError>,
|
||||
duration: Duration,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) trait WithStatus {
|
||||
fn status(&self) -> StatusCode;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ async fn models_client_hits_models_endpoint() {
|
||||
priority: 1,
|
||||
upgrade: None,
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
model_instructions_template: None,
|
||||
supports_reasoning_summaries: false,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
|
||||
@@ -40,5 +40,6 @@ codex_rust_crate(
|
||||
"//codex-rs/rmcp-client:test_stdio_server",
|
||||
"//codex-rs/rmcp-client:test_streamable_http_server",
|
||||
"//codex-rs/cli:codex",
|
||||
"//codex-rs/exec:codex-exec",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-core"
|
||||
version.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
@@ -40,11 +39,11 @@ codex-protocol = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
codex-utils-readiness = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
env-flags = { workspace = true }
|
||||
@@ -91,7 +90,6 @@ tokio = { workspace = true, features = [
|
||||
"signal",
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["rt"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
toml_edit = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
@@ -147,10 +145,6 @@ image = { workspace = true, features = ["jpeg", "png"] }
|
||||
maplit = { workspace = true }
|
||||
predicates = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true, features = [
|
||||
"experimental_metrics_custom_reader",
|
||||
"metrics",
|
||||
] }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
let samples_dir = Path::new("src/skills/assets/samples");
|
||||
if !samples_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed={}", samples_dir.display());
|
||||
visit_dir(samples_dir);
|
||||
}
|
||||
|
||||
fn visit_dir(dir: &Path) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
if path.is_dir() {
|
||||
visit_dir(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,13 +111,6 @@
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Store credentials in memory only for the current process.",
|
||||
"enum": [
|
||||
"ephemeral"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -151,9 +144,6 @@
|
||||
"apply_patch_freeform": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -190,9 +180,6 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"powershell_utf8": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -208,18 +195,12 @@
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"runtime_metrics": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_env_var_dependency_prompt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_mcp_dependency_install": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -261,6 +242,9 @@
|
||||
],
|
||||
"description": "Optional path to a file containing model instructions."
|
||||
},
|
||||
"model_personality": {
|
||||
"$ref": "#/definitions/Personality"
|
||||
},
|
||||
"model_provider": {
|
||||
"description": "The key in the `model_providers` map identifying the [`ModelProviderInfo`] to use.",
|
||||
"type": "string"
|
||||
@@ -277,9 +261,6 @@
|
||||
"oss_provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"personality": {
|
||||
"$ref": "#/definitions/Personality"
|
||||
},
|
||||
"sandbox_mode": {
|
||||
"$ref": "#/definitions/SandboxMode"
|
||||
},
|
||||
@@ -453,11 +434,6 @@
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"supports_websockets": {
|
||||
"default": false,
|
||||
"description": "Whether this provider supports the Responses API WebSocket transport.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"wire_api": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1096,6 +1072,13 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Experimental: Responses API over WebSocket transport.",
|
||||
"enum": [
|
||||
"responses_websocket"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Regular Chat Completions compatible with `/v1/chat/completions`.",
|
||||
"enum": [
|
||||
@@ -1179,9 +1162,6 @@
|
||||
"apply_patch_freeform": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"apps": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1218,9 +1198,6 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"powershell_utf8": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1236,18 +1213,12 @@
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"runtime_metrics": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_snapshot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"shell_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_env_var_dependency_prompt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_mcp_dependency_install": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1376,6 +1347,14 @@
|
||||
],
|
||||
"description": "Optional path to a file containing model instructions that will override the built-in instructions for the selected model. Users are STRONGLY DISCOURAGED from using this field, as deviating from the instructions sanctioned by Codex will likely degrade model performance."
|
||||
},
|
||||
"model_personality": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Personality"
|
||||
}
|
||||
],
|
||||
"description": "EXPERIMENTAL Optionally specify a personality for the model"
|
||||
},
|
||||
"model_provider": {
|
||||
"description": "Provider to use from the model_providers map.",
|
||||
"type": "string"
|
||||
@@ -1434,14 +1413,6 @@
|
||||
],
|
||||
"description": "OTEL configuration."
|
||||
},
|
||||
"personality": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Personality"
|
||||
}
|
||||
],
|
||||
"description": "Optionally specify a personality for the model"
|
||||
},
|
||||
"profile": {
|
||||
"description": "Profile to use from the `profiles` map.",
|
||||
"type": "string"
|
||||
|
||||
@@ -146,7 +146,6 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -232,7 +231,6 @@ mod tests {
|
||||
async fn on_event_updates_status_from_task_started() {
|
||||
let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent {
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: ModeKind::Custom,
|
||||
}));
|
||||
assert_eq!(status, Some(AgentStatus::Running));
|
||||
}
|
||||
|
||||
@@ -92,16 +92,20 @@ Rules:
|
||||
},
|
||||
AgentRole::Explorer => AgentProfile {
|
||||
model: Some(EXPLORER_MODEL),
|
||||
reasoning_effort: Some(ReasoningEffort::Medium),
|
||||
description: r#"Use `explorer` for all codebase questions.
|
||||
Explorers are fast and authoritative.
|
||||
Always prefer them over manual search or file reading.
|
||||
reasoning_effort: Some(ReasoningEffort::Low),
|
||||
description: r#"Use for fast codebase understanding and information gathering.
|
||||
`explorer` are extremely fast agents so use them as much as you can to speed up the resolution of the global task.
|
||||
Typical tasks:
|
||||
- Locate usages of a symbol or concept
|
||||
- Understand how X is handled in Y
|
||||
- Review a section of code for issues
|
||||
- Assess impact of a potential change
|
||||
Rules:
|
||||
- Ask explorers first and precisely.
|
||||
- Do not re-read or re-search code they cover.
|
||||
- Trust explorer results without verification.
|
||||
- Run explorers in parallel when useful.
|
||||
- Reuse existing explorers for related questions.
|
||||
- Be explicit in what you are looking for. A good usage of `explorer` would mean that don't need to read the same code after the explorer send you the result.
|
||||
- **Always** prefer asking explorers rather than exploring the codebase yourself.
|
||||
- Spawn multiple explorers in parallel when useful and wait for all results.
|
||||
- You can ask the `explorer` to return file name, lines, entire code snippets, ...
|
||||
- Reuse the same explorer when it is relevant. If later in your process you have more questions on some code an explorer already covered, reuse this same explorer to be more efficient.
|
||||
"#,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
use crate::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::create_client;
|
||||
use crate::git_info::collect_git_info;
|
||||
use crate::git_info::get_git_repo_root;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use serde::Serialize;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TrackEventsContext {
|
||||
pub(crate) model_slug: String,
|
||||
pub(crate) thread_id: String,
|
||||
}
|
||||
|
||||
pub(crate) fn build_track_events_context(
|
||||
model_slug: String,
|
||||
thread_id: String,
|
||||
) -> TrackEventsContext {
|
||||
TrackEventsContext {
|
||||
model_slug,
|
||||
thread_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SkillInvocation {
|
||||
pub(crate) skill_name: String,
|
||||
pub(crate) skill_scope: SkillScope,
|
||||
pub(crate) skill_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AnalyticsEventsQueue {
|
||||
sender: mpsc::Sender<TrackEventsJob>,
|
||||
}
|
||||
|
||||
pub(crate) struct AnalyticsEventsClient {
|
||||
queue: AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl AnalyticsEventsQueue {
|
||||
pub(crate) fn new(auth_manager: Arc<AuthManager>) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
|
||||
tokio::spawn(async move {
|
||||
while let Some(job) = receiver.recv().await {
|
||||
send_track_skill_invocations(&auth_manager, job).await;
|
||||
}
|
||||
});
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
fn try_send(&self, job: TrackEventsJob) {
|
||||
if self.sender.try_send(job).is_err() {
|
||||
//TODO: add a metric for this
|
||||
tracing::warn!("dropping skill analytics events: queue is full");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnalyticsEventsClient {
|
||||
pub(crate) fn new(config: Arc<Config>, auth_manager: Arc<AuthManager>) -> Self {
|
||||
Self {
|
||||
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
) {
|
||||
track_skill_invocations(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
invocations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackEventsJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
}
|
||||
|
||||
const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256;
|
||||
const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TrackEventsRequest {
|
||||
events: Vec<TrackEvent>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TrackEvent {
|
||||
event_type: &'static str,
|
||||
skill_id: String,
|
||||
skill_name: String,
|
||||
event_params: TrackEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TrackEventParams {
|
||||
product_client_id: Option<String>,
|
||||
skill_scope: Option<String>,
|
||||
repo_url: Option<String>,
|
||||
thread_id: Option<String>,
|
||||
invoke_type: Option<String>,
|
||||
model_slug: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
if invocations.is_empty() {
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob {
|
||||
config,
|
||||
tracking,
|
||||
invocations,
|
||||
};
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackEventsJob) {
|
||||
let TrackEventsJob {
|
||||
config,
|
||||
tracking,
|
||||
invocations,
|
||||
} = job;
|
||||
let Some(auth) = auth_manager.auth().await else {
|
||||
return;
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return;
|
||||
}
|
||||
let access_token = match auth.get_token() {
|
||||
Ok(token) => token,
|
||||
Err(_) => return,
|
||||
};
|
||||
let Some(account_id) = auth.get_account_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut events = Vec::with_capacity(invocations.len());
|
||||
for invocation in invocations {
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
};
|
||||
let repo_root = get_git_repo_root(invocation.skill_path.as_path());
|
||||
let repo_url = if let Some(root) = repo_root.as_ref() {
|
||||
collect_git_info(root)
|
||||
.await
|
||||
.and_then(|info| info.repository_url)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let skill_id = skill_id_for_local_skill(
|
||||
repo_url.as_deref(),
|
||||
repo_root.as_deref(),
|
||||
invocation.skill_path.as_path(),
|
||||
invocation.skill_name.as_str(),
|
||||
);
|
||||
events.push(TrackEvent {
|
||||
event_type: "skill_invocation",
|
||||
skill_id,
|
||||
skill_name: invocation.skill_name.clone(),
|
||||
event_params: TrackEventParams {
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
invoke_type: Some("explicit".to_string()),
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
repo_url,
|
||||
skill_scope: Some(skill_scope.to_string()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/codex/analytics-events/events");
|
||||
let payload = TrackEventsRequest { events };
|
||||
|
||||
let response = create_client()
|
||||
.post(&url)
|
||||
.timeout(ANALYTICS_EVENTS_TIMEOUT)
|
||||
.bearer_auth(&access_token)
|
||||
.header("chatgpt-account-id", &account_id)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => {}
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("events failed with status {status}: {body}");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to send events request: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_id_for_local_skill(
|
||||
repo_url: Option<&str>,
|
||||
repo_root: Option<&Path>,
|
||||
skill_path: &Path,
|
||||
skill_name: &str,
|
||||
) -> String {
|
||||
let path = normalize_path_for_skill_id(repo_url, repo_root, skill_path);
|
||||
let prefix = if let Some(url) = repo_url {
|
||||
format!("repo_{url}")
|
||||
} else {
|
||||
"personal".to_string()
|
||||
};
|
||||
let raw_id = format!("{prefix}_{path}_{skill_name}");
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(raw_id.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Returns a normalized path for skill ID construction.
|
||||
///
|
||||
/// - Repo-scoped skills use a path relative to the repo root.
|
||||
/// - User/admin/system skills use an absolute path.
|
||||
fn normalize_path_for_skill_id(
|
||||
repo_url: Option<&str>,
|
||||
repo_root: Option<&Path>,
|
||||
skill_path: &Path,
|
||||
) -> String {
|
||||
let resolved_path =
|
||||
std::fs::canonicalize(skill_path).unwrap_or_else(|_| skill_path.to_path_buf());
|
||||
match (repo_url, repo_root) {
|
||||
(Some(_), Some(root)) => {
|
||||
let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
resolved_path
|
||||
.strip_prefix(&resolved_root)
|
||||
.unwrap_or(resolved_path.as_path())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
_ => resolved_path.to_string_lossy().replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_path_for_skill_id;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn expected_absolute_path(path: &PathBuf) -> String {
|
||||
std::fs::canonicalize(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() {
|
||||
let repo_root = PathBuf::from("/repo/root");
|
||||
let skill_path = PathBuf::from("/repo/root/.codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(
|
||||
Some("https://example.com/repo.git"),
|
||||
Some(repo_root.as_path()),
|
||||
skill_path.as_path(),
|
||||
);
|
||||
|
||||
assert_eq!(path, ".codex/skills/doc/SKILL.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() {
|
||||
let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
|
||||
let expected = expected_absolute_path(&skill_path);
|
||||
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() {
|
||||
let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
|
||||
let expected = expected_absolute_path(&skill_path);
|
||||
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_repo_root_not_in_skill_path_uses_absolute_path() {
|
||||
let repo_root = PathBuf::from("/repo/root");
|
||||
let skill_path = PathBuf::from("/other/path/.codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(
|
||||
Some("https://example.com/repo.git"),
|
||||
Some(repo_root.as_path()),
|
||||
skill_path.as_path(),
|
||||
);
|
||||
let expected = expected_absolute_path(&skill_path);
|
||||
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,12 @@ use chrono::Utc;
|
||||
use codex_api::AuthProvider as ApiAuthProvider;
|
||||
use codex_api::TransportError;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::rate_limits::parse_promo_message;
|
||||
use codex_api::rate_limits::parse_rate_limit;
|
||||
use http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::ModelCapError;
|
||||
use crate::error::RetryLimitReachedError;
|
||||
use crate::error::UnexpectedResponseError;
|
||||
use crate::error::UsageLimitReachedError;
|
||||
@@ -51,27 +49,9 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
} else if status == http::StatusCode::INTERNAL_SERVER_ERROR {
|
||||
CodexErr::InternalServerError
|
||||
} else if status == http::StatusCode::TOO_MANY_REQUESTS {
|
||||
if let Some(model) = headers
|
||||
.as_ref()
|
||||
.and_then(|map| map.get(MODEL_CAP_MODEL_HEADER))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string)
|
||||
{
|
||||
let reset_after_seconds = headers
|
||||
.as_ref()
|
||||
.and_then(|map| map.get(MODEL_CAP_RESET_AFTER_HEADER))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
return CodexErr::ModelCap(ModelCapError {
|
||||
model,
|
||||
reset_after_seconds,
|
||||
});
|
||||
}
|
||||
|
||||
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body_text) {
|
||||
if err.error.error_type.as_deref() == Some("usage_limit_reached") {
|
||||
let rate_limits = headers.as_ref().and_then(parse_rate_limit);
|
||||
let promo_message = headers.as_ref().and_then(parse_promo_message);
|
||||
let resets_at = err
|
||||
.error
|
||||
.resets_at
|
||||
@@ -80,7 +60,6 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
plan_type: err.error.plan_type,
|
||||
resets_at,
|
||||
rate_limits,
|
||||
promo_message,
|
||||
});
|
||||
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
|
||||
return CodexErr::UsageNotIncluded;
|
||||
@@ -113,42 +92,6 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
}
|
||||
}
|
||||
|
||||
const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model";
|
||||
const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_api::TransportError;
|
||||
use http::HeaderMap;
|
||||
use http::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_model_cap_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
MODEL_CAP_MODEL_HEADER,
|
||||
http::HeaderValue::from_static("boomslang"),
|
||||
);
|
||||
headers.insert(
|
||||
MODEL_CAP_RESET_AFTER_HEADER,
|
||||
http::HeaderValue::from_static("120"),
|
||||
);
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: StatusCode::TOO_MANY_REQUESTS,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(String::new()),
|
||||
}));
|
||||
|
||||
let CodexErr::ModelCap(model_cap) = err else {
|
||||
panic!("expected CodexErr::ModelCap, got {err:?}");
|
||||
};
|
||||
assert_eq!(model_cap.model, "boomslang");
|
||||
assert_eq!(model_cap.reset_after_seconds, Some(120));
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_request_id(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
headers.and_then(|map| {
|
||||
["cf-ray", "x-request-id", "x-oai-request-id"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
mod storage;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
@@ -13,9 +12,8 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_app_server_protocol::AuthMode as ApiAuthMode;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
|
||||
pub use crate::auth::storage::AuthCredentialsStoreMode;
|
||||
@@ -25,7 +23,6 @@ use crate::auth::storage::create_auth_storage;
|
||||
use crate::config::Config;
|
||||
use crate::error::RefreshTokenFailedError;
|
||||
use crate::error::RefreshTokenFailedReason;
|
||||
use crate::token_data::IdTokenInfo;
|
||||
use crate::token_data::KnownPlan as InternalKnownPlan;
|
||||
use crate::token_data::PlanType as InternalPlanType;
|
||||
use crate::token_data::TokenData;
|
||||
@@ -36,50 +33,19 @@ use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Account type for the current user.
|
||||
///
|
||||
/// This is used internally to determine the base URL for generating responses,
|
||||
/// and to gate ChatGPT-only behaviors like rate limits and available models (as
|
||||
/// opposed to API key-based auth).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AuthMode {
|
||||
ApiKey,
|
||||
Chatgpt,
|
||||
}
|
||||
|
||||
/// Authentication mechanism used by the current user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CodexAuth {
|
||||
ApiKey(ApiKeyAuth),
|
||||
Chatgpt(ChatgptAuth),
|
||||
ChatgptAuthTokens(ChatgptAuthTokens),
|
||||
}
|
||||
pub struct CodexAuth {
|
||||
pub mode: AuthMode,
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyAuth {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatgptAuth {
|
||||
state: ChatgptAuthState,
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
|
||||
storage: Arc<dyn AuthStorageBackend>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatgptAuthTokens {
|
||||
state: ChatgptAuthState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChatgptAuthState {
|
||||
auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
|
||||
client: CodexHttpClient,
|
||||
pub(crate) client: CodexHttpClient,
|
||||
}
|
||||
|
||||
impl PartialEq for CodexAuth {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.api_auth_mode() == other.api_auth_mode()
|
||||
self.mode == other.mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,31 +68,6 @@ pub enum RefreshTokenError {
|
||||
Transient(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalAuthTokens {
|
||||
pub access_token: String,
|
||||
pub id_token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExternalAuthRefreshReason {
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalAuthRefreshContext {
|
||||
pub reason: ExternalAuthRefreshReason,
|
||||
pub previous_account_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ExternalAuthRefresher: Send + Sync {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: ExternalAuthRefreshContext,
|
||||
) -> std::io::Result<ExternalAuthTokens>;
|
||||
}
|
||||
|
||||
impl RefreshTokenError {
|
||||
pub fn failed_reason(&self) -> Option<RefreshTokenFailedReason> {
|
||||
match self {
|
||||
@@ -146,78 +87,14 @@ impl From<RefreshTokenError> for std::io::Error {
|
||||
}
|
||||
|
||||
impl CodexAuth {
|
||||
fn from_auth_dot_json(
|
||||
codex_home: &Path,
|
||||
auth_dot_json: AuthDotJson,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
client: CodexHttpClient,
|
||||
) -> std::io::Result<Self> {
|
||||
let auth_mode = auth_dot_json.resolved_mode();
|
||||
if auth_mode == ApiAuthMode::ApiKey {
|
||||
let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else {
|
||||
return Err(std::io::Error::other("API key auth is missing a key."));
|
||||
};
|
||||
return Ok(CodexAuth::from_api_key_with_client(api_key, client));
|
||||
}
|
||||
|
||||
let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
|
||||
let state = ChatgptAuthState {
|
||||
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
|
||||
client,
|
||||
};
|
||||
|
||||
match auth_mode {
|
||||
ApiAuthMode::Chatgpt => {
|
||||
let storage = create_auth_storage(codex_home.to_path_buf(), storage_mode);
|
||||
Ok(Self::Chatgpt(ChatgptAuth { state, storage }))
|
||||
}
|
||||
ApiAuthMode::ChatgptAuthTokens => {
|
||||
Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state }))
|
||||
}
|
||||
ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the available auth information from auth storage.
|
||||
pub fn from_auth_storage(
|
||||
codex_home: &Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
) -> std::io::Result<Option<CodexAuth>> {
|
||||
load_auth(codex_home, false, auth_credentials_store_mode)
|
||||
}
|
||||
|
||||
pub fn internal_auth_mode(&self) -> AuthMode {
|
||||
match self {
|
||||
Self::ApiKey(_) => AuthMode::ApiKey,
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn api_auth_mode(&self) -> ApiAuthMode {
|
||||
match self {
|
||||
Self::ApiKey(_) => ApiAuthMode::ApiKey,
|
||||
Self::Chatgpt(_) => ApiAuthMode::Chatgpt,
|
||||
Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_chatgpt_auth(&self) -> bool {
|
||||
self.internal_auth_mode() == AuthMode::Chatgpt
|
||||
}
|
||||
|
||||
pub fn is_external_chatgpt_tokens(&self) -> bool {
|
||||
matches!(self, Self::ChatgptAuthTokens(_))
|
||||
}
|
||||
|
||||
/// Returns `None` is `is_internal_auth_mode() != AuthMode::ApiKey`.
|
||||
pub fn api_key(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Err` if `is_chatgpt_auth()` is false.
|
||||
pub fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
|
||||
let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
|
||||
match auth_dot_json {
|
||||
@@ -230,23 +107,20 @@ impl CodexAuth {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the token string used for bearer authentication.
|
||||
pub fn get_token(&self) -> Result<String, std::io::Error> {
|
||||
match self {
|
||||
Self::ApiKey(auth) => Ok(auth.api_key.clone()),
|
||||
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => {
|
||||
let access_token = self.get_token_data()?.access_token;
|
||||
Ok(access_token)
|
||||
match self.mode {
|
||||
AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()),
|
||||
AuthMode::ChatGPT => {
|
||||
let id_token = self.get_token_data()?.access_token;
|
||||
Ok(id_token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
pub fn get_account_id(&self) -> Option<String> {
|
||||
self.get_current_token_data().and_then(|t| t.account_id)
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
pub fn get_account_email(&self) -> Option<String> {
|
||||
self.get_current_token_data().and_then(|t| t.id_token.email)
|
||||
}
|
||||
@@ -258,7 +132,6 @@ impl CodexAuth {
|
||||
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
|
||||
let map_known = |kp: &InternalKnownPlan| match kp {
|
||||
InternalKnownPlan::Free => AccountPlanType::Free,
|
||||
InternalKnownPlan::Go => AccountPlanType::Go,
|
||||
InternalKnownPlan::Plus => AccountPlanType::Plus,
|
||||
InternalKnownPlan::Pro => AccountPlanType::Pro,
|
||||
InternalKnownPlan::Team => AccountPlanType::Team,
|
||||
@@ -275,18 +148,11 @@ impl CodexAuth {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
fn get_current_auth_json(&self) -> Option<AuthDotJson> {
|
||||
let state = match self {
|
||||
Self::Chatgpt(auth) => &auth.state,
|
||||
Self::ChatgptAuthTokens(auth) => &auth.state,
|
||||
Self::ApiKey(_) => return None,
|
||||
};
|
||||
#[expect(clippy::unwrap_used)]
|
||||
state.auth_dot_json.lock().unwrap().clone()
|
||||
self.auth_dot_json.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Returns `None` if `is_chatgpt_auth()` is false.
|
||||
fn get_current_token_data(&self) -> Option<TokenData> {
|
||||
self.get_current_auth_json().and_then(|t| t.tokens)
|
||||
}
|
||||
@@ -294,7 +160,6 @@ impl CodexAuth {
|
||||
/// Consider this private to integration tests.
|
||||
pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: Default::default(),
|
||||
@@ -305,19 +170,24 @@ impl CodexAuth {
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
|
||||
let client = crate::default_client::create_client();
|
||||
let state = ChatgptAuthState {
|
||||
auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
|
||||
client,
|
||||
};
|
||||
let storage = create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File);
|
||||
Self::Chatgpt(ChatgptAuth { state, storage })
|
||||
let auth_dot_json = Arc::new(Mutex::new(Some(auth_dot_json)));
|
||||
Self {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File),
|
||||
auth_dot_json,
|
||||
client: crate::default_client::create_client(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_api_key_with_client(api_key: &str, _client: CodexHttpClient) -> Self {
|
||||
Self::ApiKey(ApiKeyAuth {
|
||||
api_key: api_key.to_owned(),
|
||||
})
|
||||
fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self {
|
||||
Self {
|
||||
api_key: Some(api_key.to_owned()),
|
||||
mode: AuthMode::ApiKey,
|
||||
storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File),
|
||||
auth_dot_json: Arc::new(Mutex::new(None)),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_api_key(api_key: &str) -> Self {
|
||||
@@ -325,25 +195,6 @@ impl CodexAuth {
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatgptAuth {
|
||||
fn current_auth_json(&self) -> Option<AuthDotJson> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
self.state.auth_dot_json.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn current_token_data(&self) -> Option<TokenData> {
|
||||
self.current_auth_json().and_then(|auth| auth.tokens)
|
||||
}
|
||||
|
||||
fn storage(&self) -> &Arc<dyn AuthStorageBackend> {
|
||||
&self.storage
|
||||
}
|
||||
|
||||
fn client(&self) -> &CodexHttpClient {
|
||||
&self.state.client
|
||||
}
|
||||
}
|
||||
|
||||
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
|
||||
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
|
||||
|
||||
@@ -378,7 +229,6 @@ pub fn login_with_api_key(
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<()> {
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::ApiKey),
|
||||
openai_api_key: Some(api_key.to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
@@ -386,20 +236,6 @@ pub fn login_with_api_key(
|
||||
save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode)
|
||||
}
|
||||
|
||||
/// Writes an in-memory auth payload for externally managed ChatGPT tokens.
|
||||
pub fn login_with_chatgpt_auth_tokens(
|
||||
codex_home: &Path,
|
||||
id_token: &str,
|
||||
access_token: &str,
|
||||
) -> std::io::Result<()> {
|
||||
let auth_dot_json = AuthDotJson::from_external_token_strings(id_token, access_token)?;
|
||||
save_auth(
|
||||
codex_home,
|
||||
&auth_dot_json,
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist the provided auth payload using the specified backend.
|
||||
pub fn save_auth(
|
||||
codex_home: &Path,
|
||||
@@ -434,10 +270,10 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
|
||||
};
|
||||
|
||||
if let Some(required_method) = config.forced_login_method {
|
||||
let method_violation = match (required_method, auth.internal_auth_mode()) {
|
||||
let method_violation = match (required_method, auth.mode) {
|
||||
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
|
||||
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) => None,
|
||||
(ForcedLoginMethod::Api, AuthMode::Chatgpt) => Some(
|
||||
(ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None,
|
||||
(ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some(
|
||||
"API key login is required, but ChatGPT is currently being used. Logging out."
|
||||
.to_string(),
|
||||
),
|
||||
@@ -457,7 +293,7 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() {
|
||||
if !auth.is_chatgpt_auth() {
|
||||
if auth.mode != AuthMode::ChatGPT {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -501,26 +337,12 @@ fn logout_with_message(
|
||||
message: String,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<()> {
|
||||
// External auth tokens live in the ephemeral store, but persistent auth may still exist
|
||||
// from earlier logins. Clear both so a forced logout truly removes all active auth.
|
||||
let removal_result = logout_all_stores(codex_home, auth_credentials_store_mode);
|
||||
let error_message = match removal_result {
|
||||
Ok(_) => message,
|
||||
Err(err) => format!("{message}. Failed to remove auth.json: {err}"),
|
||||
};
|
||||
Err(std::io::Error::other(error_message))
|
||||
}
|
||||
|
||||
fn logout_all_stores(
|
||||
codex_home: &Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<bool> {
|
||||
if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
|
||||
return logout(codex_home, AuthCredentialsStoreMode::Ephemeral);
|
||||
match logout(codex_home, auth_credentials_store_mode) {
|
||||
Ok(_) => Err(std::io::Error::other(message)),
|
||||
Err(err) => Err(std::io::Error::other(format!(
|
||||
"{message}. Failed to remove auth.json: {err}"
|
||||
))),
|
||||
}
|
||||
let removed_ephemeral = logout(codex_home, AuthCredentialsStoreMode::Ephemeral)?;
|
||||
let removed_managed = logout(codex_home, auth_credentials_store_mode)?;
|
||||
Ok(removed_ephemeral || removed_managed)
|
||||
}
|
||||
|
||||
fn load_auth(
|
||||
@@ -528,12 +350,6 @@ fn load_auth(
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> std::io::Result<Option<CodexAuth>> {
|
||||
let build_auth = |auth_dot_json: AuthDotJson, storage_mode| {
|
||||
let client = crate::default_client::create_client();
|
||||
CodexAuth::from_auth_dot_json(codex_home, auth_dot_json, storage_mode, client)
|
||||
};
|
||||
|
||||
// API key via env var takes precedence over any other auth method.
|
||||
if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
|
||||
let client = crate::default_client::create_client();
|
||||
return Ok(Some(CodexAuth::from_api_key_with_client(
|
||||
@@ -542,34 +358,39 @@ fn load_auth(
|
||||
)));
|
||||
}
|
||||
|
||||
// External ChatGPT auth tokens live in the in-memory (ephemeral) store. Always check this
|
||||
// first so external auth takes precedence over any persisted credentials.
|
||||
let ephemeral_storage = create_auth_storage(
|
||||
codex_home.to_path_buf(),
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
);
|
||||
if let Some(auth_dot_json) = ephemeral_storage.load()? {
|
||||
let auth = build_auth(auth_dot_json, AuthCredentialsStoreMode::Ephemeral)?;
|
||||
return Ok(Some(auth));
|
||||
}
|
||||
|
||||
// If the caller explicitly requested ephemeral auth, there is no persisted fallback.
|
||||
if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Fall back to the configured persistent store (file/keyring/auto) for managed auth.
|
||||
let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode);
|
||||
|
||||
let client = crate::default_client::create_client();
|
||||
let auth_dot_json = match storage.load()? {
|
||||
Some(auth) => auth,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let auth = build_auth(auth_dot_json, auth_credentials_store_mode)?;
|
||||
Ok(Some(auth))
|
||||
let AuthDotJson {
|
||||
openai_api_key: auth_json_api_key,
|
||||
tokens,
|
||||
last_refresh,
|
||||
} = auth_dot_json;
|
||||
|
||||
// Prefer AuthMode.ApiKey if it's set in the auth.json.
|
||||
if let Some(api_key) = &auth_json_api_key {
|
||||
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
|
||||
}
|
||||
|
||||
Ok(Some(CodexAuth {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
storage: storage.clone(),
|
||||
auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens,
|
||||
last_refresh,
|
||||
}))),
|
||||
client,
|
||||
}))
|
||||
}
|
||||
|
||||
fn update_tokens(
|
||||
async fn update_tokens(
|
||||
storage: &Arc<dyn AuthStorageBackend>,
|
||||
id_token: Option<String>,
|
||||
access_token: Option<String>,
|
||||
@@ -716,82 +537,17 @@ fn refresh_token_endpoint() -> String {
|
||||
.unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string())
|
||||
}
|
||||
|
||||
impl AuthDotJson {
|
||||
fn from_external_tokens(external: &ExternalAuthTokens, id_token: IdTokenInfo) -> Self {
|
||||
let account_id = id_token.chatgpt_account_id.clone();
|
||||
let tokens = TokenData {
|
||||
id_token,
|
||||
access_token: external.access_token.clone(),
|
||||
refresh_token: String::new(),
|
||||
account_id,
|
||||
};
|
||||
|
||||
Self {
|
||||
auth_mode: Some(ApiAuthMode::ChatgptAuthTokens),
|
||||
openai_api_key: None,
|
||||
tokens: Some(tokens),
|
||||
last_refresh: Some(Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_external_token_strings(id_token: &str, access_token: &str) -> std::io::Result<Self> {
|
||||
let id_token_info = parse_id_token(id_token).map_err(std::io::Error::other)?;
|
||||
let external = ExternalAuthTokens {
|
||||
access_token: access_token.to_string(),
|
||||
id_token: id_token.to_string(),
|
||||
};
|
||||
Ok(Self::from_external_tokens(&external, id_token_info))
|
||||
}
|
||||
|
||||
fn resolved_mode(&self) -> ApiAuthMode {
|
||||
if let Some(mode) = self.auth_mode {
|
||||
return mode;
|
||||
}
|
||||
if self.openai_api_key.is_some() {
|
||||
return ApiAuthMode::ApiKey;
|
||||
}
|
||||
ApiAuthMode::Chatgpt
|
||||
}
|
||||
|
||||
fn storage_mode(
|
||||
&self,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> AuthCredentialsStoreMode {
|
||||
if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens {
|
||||
AuthCredentialsStoreMode::Ephemeral
|
||||
} else {
|
||||
auth_credentials_store_mode
|
||||
}
|
||||
}
|
||||
}
|
||||
use std::sync::RwLock;
|
||||
|
||||
/// Internal cached auth state.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct CachedAuth {
|
||||
auth: Option<CodexAuth>,
|
||||
/// Callback used to refresh external auth by asking the parent app for new tokens.
|
||||
external_refresher: Option<Arc<dyn ExternalAuthRefresher>>,
|
||||
}
|
||||
|
||||
impl Debug for CachedAuth {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CachedAuth")
|
||||
.field(
|
||||
"auth_mode",
|
||||
&self.auth.as_ref().map(CodexAuth::api_auth_mode),
|
||||
)
|
||||
.field(
|
||||
"external_refresher",
|
||||
&self.external_refresher.as_ref().map(|_| "present"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
enum UnauthorizedRecoveryStep {
|
||||
Reload,
|
||||
RefreshToken,
|
||||
ExternalRefresh,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -800,53 +556,30 @@ enum ReloadOutcome {
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum UnauthorizedRecoveryMode {
|
||||
Managed,
|
||||
External,
|
||||
}
|
||||
|
||||
// UnauthorizedRecovery is a state machine that handles an attempt to refresh the authentication when requests
|
||||
// to API fail with 401 status code.
|
||||
// The client calls next() every time it encounters a 401 error, one time per retry.
|
||||
// For API key based authentication, we don't do anything and let the error bubble to the user.
|
||||
//
|
||||
// For ChatGPT based authentication, we:
|
||||
// 1. Attempt to reload the auth data from disk. We only reload if the account id matches the one the current process is running as.
|
||||
// 2. Attempt to refresh the token using OAuth token refresh flow.
|
||||
// If after both steps the server still responds with 401 we let the error bubble to the user.
|
||||
//
|
||||
// For external ChatGPT auth tokens (chatgptAuthTokens), UnauthorizedRecovery does not touch disk or refresh
|
||||
// tokens locally. Instead it calls the ExternalAuthRefresher (account/chatgptAuthTokens/refresh) to ask the
|
||||
// parent app for new tokens, stores them in the ephemeral auth store, and retries once.
|
||||
pub struct UnauthorizedRecovery {
|
||||
manager: Arc<AuthManager>,
|
||||
step: UnauthorizedRecoveryStep,
|
||||
expected_account_id: Option<String>,
|
||||
mode: UnauthorizedRecoveryMode,
|
||||
}
|
||||
|
||||
impl UnauthorizedRecovery {
|
||||
fn new(manager: Arc<AuthManager>) -> Self {
|
||||
let cached_auth = manager.auth_cached();
|
||||
let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id);
|
||||
let mode = if cached_auth
|
||||
let expected_account_id = manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
|
||||
{
|
||||
UnauthorizedRecoveryMode::External
|
||||
} else {
|
||||
UnauthorizedRecoveryMode::Managed
|
||||
};
|
||||
let step = match mode {
|
||||
UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload,
|
||||
UnauthorizedRecoveryMode::External => UnauthorizedRecoveryStep::ExternalRefresh,
|
||||
};
|
||||
.and_then(CodexAuth::get_account_id);
|
||||
Self {
|
||||
manager,
|
||||
step,
|
||||
step: UnauthorizedRecoveryStep::Reload,
|
||||
expected_account_id,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,14 +587,7 @@ impl UnauthorizedRecovery {
|
||||
if !self
|
||||
.manager
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_chatgpt_auth)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.mode == UnauthorizedRecoveryMode::External
|
||||
&& !self.manager.has_external_auth_refresher()
|
||||
.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -896,12 +622,6 @@ impl UnauthorizedRecovery {
|
||||
self.manager.refresh_token().await?;
|
||||
self.step = UnauthorizedRecoveryStep::Done;
|
||||
}
|
||||
UnauthorizedRecoveryStep::ExternalRefresh => {
|
||||
self.manager
|
||||
.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
|
||||
.await?;
|
||||
self.step = UnauthorizedRecoveryStep::Done;
|
||||
}
|
||||
UnauthorizedRecoveryStep::Done => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -922,7 +642,6 @@ pub struct AuthManager {
|
||||
inner: RwLock<CachedAuth>,
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
forced_chatgpt_workspace_id: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl AuthManager {
|
||||
@@ -935,7 +654,7 @@ impl AuthManager {
|
||||
enable_codex_api_key_env: bool,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
) -> Self {
|
||||
let managed_auth = load_auth(
|
||||
let auth = load_auth(
|
||||
&codex_home,
|
||||
enable_codex_api_key_env,
|
||||
auth_credentials_store_mode,
|
||||
@@ -944,46 +663,34 @@ impl AuthManager {
|
||||
.flatten();
|
||||
Self {
|
||||
codex_home,
|
||||
inner: RwLock::new(CachedAuth {
|
||||
auth: managed_auth,
|
||||
external_refresher: None,
|
||||
}),
|
||||
inner: RwLock::new(CachedAuth { auth }),
|
||||
enable_codex_api_key_env,
|
||||
auth_credentials_store_mode,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Create an AuthManager with a specific CodexAuth, for testing only.
|
||||
pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
|
||||
let cached = CachedAuth {
|
||||
auth: Some(auth),
|
||||
external_refresher: None,
|
||||
};
|
||||
let cached = CachedAuth { auth: Some(auth) };
|
||||
|
||||
Arc::new(Self {
|
||||
codex_home: PathBuf::from("non-existent"),
|
||||
inner: RwLock::new(cached),
|
||||
enable_codex_api_key_env: false,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
/// Create an AuthManager with a specific CodexAuth and codex home, for testing only.
|
||||
pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc<Self> {
|
||||
let cached = CachedAuth {
|
||||
auth: Some(auth),
|
||||
external_refresher: None,
|
||||
};
|
||||
let cached = CachedAuth { auth: Some(auth) };
|
||||
Arc::new(Self {
|
||||
codex_home,
|
||||
inner: RwLock::new(cached),
|
||||
enable_codex_api_key_env: false,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode::File,
|
||||
forced_chatgpt_workspace_id: RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1008,7 +715,7 @@ impl AuthManager {
|
||||
pub fn reload(&self) -> bool {
|
||||
tracing::info!("Reloading auth");
|
||||
let new_auth = self.load_auth_from_storage();
|
||||
self.set_cached_auth(new_auth)
|
||||
self.set_auth(new_auth)
|
||||
}
|
||||
|
||||
fn reload_if_account_id_matches(&self, expected_account_id: Option<&str>) -> ReloadOutcome {
|
||||
@@ -1032,11 +739,11 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
tracing::info!("Reloading auth for account {expected_account_id}");
|
||||
self.set_cached_auth(new_auth);
|
||||
self.set_auth(new_auth);
|
||||
ReloadOutcome::Reloaded
|
||||
}
|
||||
|
||||
fn auths_equal(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool {
|
||||
fn auths_equal(a: &Option<CodexAuth>, b: &Option<CodexAuth>) -> bool {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => a == b,
|
||||
@@ -1054,10 +761,9 @@ impl AuthManager {
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn set_cached_auth(&self, new_auth: Option<CodexAuth>) -> bool {
|
||||
fn set_auth(&self, new_auth: Option<CodexAuth>) -> bool {
|
||||
if let Ok(mut guard) = self.inner.write() {
|
||||
let previous = guard.auth.as_ref();
|
||||
let changed = !AuthManager::auths_equal(previous, new_auth.as_ref());
|
||||
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
|
||||
tracing::info!("Reloaded auth, changed: {changed}");
|
||||
guard.auth = new_auth;
|
||||
changed
|
||||
@@ -1066,39 +772,6 @@ impl AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_external_auth_refresher(&self, refresher: Arc<dyn ExternalAuthRefresher>) {
|
||||
if let Ok(mut guard) = self.inner.write() {
|
||||
guard.external_refresher = Some(refresher);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option<String>) {
|
||||
if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() {
|
||||
*guard = workspace_id;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forced_chatgpt_workspace_id(&self) -> Option<String> {
|
||||
self.forced_chatgpt_workspace_id
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
pub fn has_external_auth_refresher(&self) -> bool {
|
||||
self.inner
|
||||
.read()
|
||||
.ok()
|
||||
.map(|guard| guard.external_refresher.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_external_auth_active(&self) -> bool {
|
||||
self.auth_cached()
|
||||
.as_ref()
|
||||
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
|
||||
}
|
||||
|
||||
/// Convenience constructor returning an `Arc` wrapper.
|
||||
pub fn shared(
|
||||
codex_home: PathBuf,
|
||||
@@ -1126,25 +799,13 @@ impl AuthManager {
|
||||
Some(auth) => auth,
|
||||
None => return Ok(()),
|
||||
};
|
||||
match auth {
|
||||
CodexAuth::ChatgptAuthTokens(_) => {
|
||||
self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
|
||||
.await
|
||||
}
|
||||
CodexAuth::Chatgpt(chatgpt_auth) => {
|
||||
let token_data = chatgpt_auth.current_token_data().ok_or_else(|| {
|
||||
RefreshTokenError::Transient(std::io::Error::other(
|
||||
"Token data is not available.",
|
||||
))
|
||||
})?;
|
||||
self.refresh_tokens(&chatgpt_auth, token_data.refresh_token)
|
||||
.await?;
|
||||
// Reload to pick up persisted changes.
|
||||
self.reload();
|
||||
Ok(())
|
||||
}
|
||||
CodexAuth::ApiKey(_) => Ok(()),
|
||||
}
|
||||
let token_data = auth.get_current_token_data().ok_or_else(|| {
|
||||
RefreshTokenError::Transient(std::io::Error::other("Token data is not available."))
|
||||
})?;
|
||||
self.refresh_tokens(&auth, token_data.refresh_token).await?;
|
||||
// Reload to pick up persisted changes.
|
||||
self.reload();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true)
|
||||
@@ -1152,29 +813,22 @@ impl AuthManager {
|
||||
/// reloads the in‑memory auth cache so callers immediately observe the
|
||||
/// unauthenticated state.
|
||||
pub fn logout(&self) -> std::io::Result<bool> {
|
||||
let removed = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?;
|
||||
let removed = super::auth::logout(&self.codex_home, self.auth_credentials_store_mode)?;
|
||||
// Always reload to clear any cached auth (even if file absent).
|
||||
self.reload();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn get_auth_mode(&self) -> Option<ApiAuthMode> {
|
||||
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
|
||||
}
|
||||
|
||||
pub fn get_internal_auth_mode(&self) -> Option<AuthMode> {
|
||||
self.auth_cached()
|
||||
.as_ref()
|
||||
.map(CodexAuth::internal_auth_mode)
|
||||
pub fn get_auth_mode(&self) -> Option<AuthMode> {
|
||||
self.auth_cached().map(|a| a.mode)
|
||||
}
|
||||
|
||||
async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result<bool, RefreshTokenError> {
|
||||
let chatgpt_auth = match auth {
|
||||
CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if auth.mode != AuthMode::ChatGPT {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let auth_dot_json = match chatgpt_auth.current_auth_json() {
|
||||
let auth_dot_json = match auth.get_current_auth_json() {
|
||||
Some(auth_dot_json) => auth_dot_json,
|
||||
None => return Ok(false),
|
||||
};
|
||||
@@ -1189,78 +843,25 @@ impl AuthManager {
|
||||
if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.refresh_tokens(chatgpt_auth, tokens.refresh_token)
|
||||
.await?;
|
||||
self.refresh_tokens(auth, tokens.refresh_token).await?;
|
||||
self.reload();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn refresh_external_auth(
|
||||
&self,
|
||||
reason: ExternalAuthRefreshReason,
|
||||
) -> Result<(), RefreshTokenError> {
|
||||
let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
|
||||
let refresher = match self.inner.read() {
|
||||
Ok(guard) => guard.external_refresher.clone(),
|
||||
Err(_) => {
|
||||
return Err(RefreshTokenError::Transient(std::io::Error::other(
|
||||
"failed to read external auth state",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(refresher) = refresher else {
|
||||
return Err(RefreshTokenError::Transient(std::io::Error::other(
|
||||
"external auth refresher is not configured",
|
||||
)));
|
||||
};
|
||||
|
||||
let previous_account_id = self
|
||||
.auth_cached()
|
||||
.as_ref()
|
||||
.and_then(CodexAuth::get_account_id);
|
||||
let context = ExternalAuthRefreshContext {
|
||||
reason,
|
||||
previous_account_id,
|
||||
};
|
||||
|
||||
let refreshed = refresher.refresh(context).await?;
|
||||
let id_token = parse_id_token(&refreshed.id_token)
|
||||
.map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?;
|
||||
if let Some(expected_workspace_id) = forced_chatgpt_workspace_id.as_deref() {
|
||||
let actual_workspace_id = id_token.chatgpt_account_id.as_deref();
|
||||
if actual_workspace_id != Some(expected_workspace_id) {
|
||||
return Err(RefreshTokenError::Transient(std::io::Error::other(
|
||||
format!(
|
||||
"external auth refresh returned workspace {actual_workspace_id:?}, expected {expected_workspace_id:?}",
|
||||
),
|
||||
)));
|
||||
}
|
||||
}
|
||||
let auth_dot_json = AuthDotJson::from_external_tokens(&refreshed, id_token);
|
||||
save_auth(
|
||||
&self.codex_home,
|
||||
&auth_dot_json,
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
)
|
||||
.map_err(RefreshTokenError::Transient)?;
|
||||
self.reload();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_tokens(
|
||||
&self,
|
||||
auth: &ChatgptAuth,
|
||||
auth: &CodexAuth,
|
||||
refresh_token: String,
|
||||
) -> Result<(), RefreshTokenError> {
|
||||
let refresh_response = try_refresh_token(refresh_token, auth.client()).await?;
|
||||
let refresh_response = try_refresh_token(refresh_token, &auth.client).await?;
|
||||
|
||||
update_tokens(
|
||||
auth.storage(),
|
||||
&auth.storage,
|
||||
refresh_response.id_token,
|
||||
refresh_response.access_token,
|
||||
refresh_response.refresh_token,
|
||||
)
|
||||
.await
|
||||
.map_err(RefreshTokenError::from)?;
|
||||
|
||||
Ok(())
|
||||
@@ -1309,6 +910,7 @@ mod tests {
|
||||
Some("new-access-token".to_string()),
|
||||
Some("new-refresh-token".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("update_tokens should succeed");
|
||||
|
||||
let tokens = updated.tokens.expect("tokens should exist");
|
||||
@@ -1369,22 +971,26 @@ mod tests {
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
storage: _,
|
||||
..
|
||||
} = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(None, auth.api_key());
|
||||
assert_eq!(AuthMode::Chatgpt, auth.internal_auth_mode());
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
let auth_dot_json = auth
|
||||
.get_current_auth_json()
|
||||
.expect("AuthDotJson should exist");
|
||||
let guard = auth_dot_json.lock().unwrap();
|
||||
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
|
||||
let last_refresh = auth_dot_json
|
||||
.last_refresh
|
||||
.expect("last_refresh should be recorded");
|
||||
|
||||
assert_eq!(
|
||||
AuthDotJson {
|
||||
auth_mode: None,
|
||||
&AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: IdTokenInfo {
|
||||
@@ -1418,8 +1024,8 @@ mod tests {
|
||||
let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(auth.internal_auth_mode(), AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key(), Some("sk-test-key"));
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
|
||||
|
||||
assert!(auth.get_token_data().is_err());
|
||||
}
|
||||
@@ -1428,7 +1034,6 @@ mod tests {
|
||||
fn logout_removes_auth_file() -> Result<(), std::io::Error> {
|
||||
let dir = tempdir()?;
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::ApiKey),
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
|
||||
@@ -5,7 +5,6 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -16,14 +15,11 @@ use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::token_data::TokenData;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_keyring_store::DefaultKeyringStore;
|
||||
use codex_keyring_store::KeyringStore;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
/// Determine where Codex should store CLI auth credentials.
|
||||
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -36,16 +32,11 @@ pub enum AuthCredentialsStoreMode {
|
||||
Keyring,
|
||||
/// Use keyring when available; otherwise, fall back to a file in CODEX_HOME.
|
||||
Auto,
|
||||
/// Store credentials in memory only for the current process.
|
||||
Ephemeral,
|
||||
}
|
||||
|
||||
/// Expected structure for $CODEX_HOME/auth.json.
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
|
||||
pub struct AuthDotJson {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_mode: Option<AuthMode>,
|
||||
|
||||
#[serde(rename = "OPENAI_API_KEY")]
|
||||
pub openai_api_key: Option<String>,
|
||||
|
||||
@@ -85,8 +76,8 @@ impl FileAuthStorage {
|
||||
Self { codex_home }
|
||||
}
|
||||
|
||||
/// Attempt to read and parse the `auth.json` file in the given `CODEX_HOME` directory.
|
||||
/// Returns the full AuthDotJson structure.
|
||||
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
|
||||
/// Returns the full AuthDotJson structure after refreshing if necessary.
|
||||
pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result<AuthDotJson> {
|
||||
let mut file = File::open(auth_file)?;
|
||||
let mut contents = String::new();
|
||||
@@ -265,49 +256,6 @@ impl AuthStorageBackend for AutoAuthStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// A global in-memory store for mapping codex_home -> AuthDotJson.
|
||||
static EPHEMERAL_AUTH_STORE: Lazy<Mutex<HashMap<String, AuthDotJson>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EphemeralAuthStorage {
|
||||
codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl EphemeralAuthStorage {
|
||||
fn new(codex_home: PathBuf) -> Self {
|
||||
Self { codex_home }
|
||||
}
|
||||
|
||||
fn with_store<F, T>(&self, action: F) -> std::io::Result<T>
|
||||
where
|
||||
F: FnOnce(&mut HashMap<String, AuthDotJson>, String) -> std::io::Result<T>,
|
||||
{
|
||||
let key = compute_store_key(&self.codex_home)?;
|
||||
let mut store = EPHEMERAL_AUTH_STORE
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("failed to lock ephemeral auth storage"))?;
|
||||
action(&mut store, key)
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthStorageBackend for EphemeralAuthStorage {
|
||||
fn load(&self) -> std::io::Result<Option<AuthDotJson>> {
|
||||
self.with_store(|store, key| Ok(store.get(&key).cloned()))
|
||||
}
|
||||
|
||||
fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> {
|
||||
self.with_store(|store, key| {
|
||||
store.insert(key, auth.clone());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn delete(&self) -> std::io::Result<bool> {
|
||||
self.with_store(|store, key| Ok(store.remove(&key).is_some()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_auth_storage(
|
||||
codex_home: PathBuf,
|
||||
mode: AuthCredentialsStoreMode,
|
||||
@@ -327,7 +275,6 @@ fn create_auth_storage_with_keyring_store(
|
||||
Arc::new(KeyringAuthStorage::new(codex_home, keyring_store))
|
||||
}
|
||||
AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)),
|
||||
AuthCredentialsStoreMode::Ephemeral => Arc::new(EphemeralAuthStorage::new(codex_home)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +296,6 @@ mod tests {
|
||||
let codex_home = tempdir()?;
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
@@ -369,7 +315,6 @@ mod tests {
|
||||
let codex_home = tempdir()?;
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
@@ -391,7 +336,6 @@ mod tests {
|
||||
fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
@@ -406,32 +350,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let storage = create_auth_storage(
|
||||
dir.path().to_path_buf(),
|
||||
AuthCredentialsStoreMode::Ephemeral,
|
||||
);
|
||||
let auth_dot_json = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("sk-ephemeral".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
let loaded = storage.load()?;
|
||||
assert_eq!(Some(auth_dot_json), loaded);
|
||||
|
||||
let removed = storage.delete()?;
|
||||
assert!(removed);
|
||||
let loaded = storage.load()?;
|
||||
assert_eq!(None, loaded);
|
||||
assert!(!get_auth_file(dir.path()).exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seed_keyring_and_fallback_auth_file_for_delete<F>(
|
||||
mock_keyring: &MockKeyringStore,
|
||||
codex_home: &Path,
|
||||
@@ -507,7 +425,6 @@ mod tests {
|
||||
|
||||
fn auth_with_prefix(prefix: &str) -> AuthDotJson {
|
||||
AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some(format!("{prefix}-api-key")),
|
||||
tokens: Some(TokenData {
|
||||
id_token: id_token_with_prefix(prefix),
|
||||
@@ -528,7 +445,6 @@ mod tests {
|
||||
Arc::new(mock_keyring.clone()),
|
||||
);
|
||||
let expected = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("sk-test".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
@@ -565,7 +481,6 @@ mod tests {
|
||||
let auth_file = get_auth_file(codex_home.path());
|
||||
std::fs::write(&auth_file, "stale")?;
|
||||
let auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: Default::default(),
|
||||
|
||||
@@ -21,13 +21,13 @@ use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient;
|
||||
use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection;
|
||||
use codex_api::SseTelemetry;
|
||||
use codex_api::TransportError;
|
||||
use codex_api::WebsocketTelemetry;
|
||||
use codex_api::build_conversation_headers;
|
||||
use codex_api::common::Reasoning;
|
||||
use codex_api::common::ResponsesWsRequest;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::requests::responses::Compression;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_otel::OtelManager;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -47,12 +47,9 @@ use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Error;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::auth::RefreshTokenError;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
@@ -68,7 +65,6 @@ use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::tools::spec::create_tools_json_for_chat_completions_api;
|
||||
use crate::tools::spec::create_tools_json_for_responses_api;
|
||||
use crate::transport_manager::TransportManager;
|
||||
|
||||
pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible";
|
||||
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
|
||||
@@ -84,7 +80,6 @@ struct ModelClientState {
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
summary: ReasoningSummaryConfig,
|
||||
session_source: SessionSource,
|
||||
transport_manager: TransportManager,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -96,7 +91,6 @@ pub struct ModelClientSession {
|
||||
state: Arc<ModelClientState>,
|
||||
connection: Option<ApiWebSocketConnection>,
|
||||
websocket_last_items: Vec<ResponseItem>,
|
||||
transport_manager: TransportManager,
|
||||
/// Turn state for sticky routing.
|
||||
///
|
||||
/// This is an `OnceLock` that stores the turn state value received from the server
|
||||
@@ -122,7 +116,6 @@ impl ModelClient {
|
||||
summary: ReasoningSummaryConfig,
|
||||
conversation_id: ThreadId,
|
||||
session_source: SessionSource,
|
||||
transport_manager: TransportManager,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: Arc::new(ModelClientState {
|
||||
@@ -135,7 +128,6 @@ impl ModelClient {
|
||||
effort,
|
||||
summary,
|
||||
session_source,
|
||||
transport_manager,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -145,7 +137,6 @@ impl ModelClient {
|
||||
state: Arc::clone(&self.state),
|
||||
connection: None,
|
||||
websocket_last_items: Vec::new(),
|
||||
transport_manager: self.state.transport_manager.clone(),
|
||||
turn_state: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
@@ -180,10 +171,6 @@ impl ModelClient {
|
||||
self.state.session_source.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn transport_manager(&self) -> TransportManager {
|
||||
self.state.transport_manager.clone()
|
||||
}
|
||||
|
||||
/// Returns the currently configured model slug.
|
||||
pub fn get_model(&self) -> String {
|
||||
self.state.model_info.slug.clone()
|
||||
@@ -223,7 +210,7 @@ impl ModelClient {
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry = self.build_request_telemetry();
|
||||
@@ -263,18 +250,9 @@ impl ModelClientSession {
|
||||
/// For Chat providers, the underlying stream is optionally aggregated
|
||||
/// based on the `show_raw_agent_reasoning` flag in the config.
|
||||
pub async fn stream(&mut self, prompt: &Prompt) -> Result<ResponseStream> {
|
||||
let wire_api = self.state.provider.wire_api;
|
||||
match wire_api {
|
||||
WireApi::Responses => {
|
||||
let websocket_enabled = self.responses_websocket_enabled()
|
||||
&& !self.transport_manager.disable_websockets();
|
||||
|
||||
if websocket_enabled {
|
||||
self.stream_responses_websocket(prompt).await
|
||||
} else {
|
||||
self.stream_responses_api(prompt).await
|
||||
}
|
||||
}
|
||||
match self.state.provider.wire_api {
|
||||
WireApi::Responses => self.stream_responses_api(prompt).await,
|
||||
WireApi::ResponsesWebsocket => self.stream_responses_websocket(prompt).await,
|
||||
WireApi::Chat => {
|
||||
let api_stream = self.stream_chat_completions(prompt).await?;
|
||||
|
||||
@@ -293,34 +271,6 @@ impl ModelClientSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn try_switch_fallback_transport(&mut self) -> bool {
|
||||
let websocket_enabled = self.responses_websocket_enabled();
|
||||
let activated = self
|
||||
.transport_manager
|
||||
.activate_http_fallback(websocket_enabled);
|
||||
if activated {
|
||||
warn!("falling back to HTTP");
|
||||
self.state.otel_manager.counter(
|
||||
"codex.transport.fallback_to_http",
|
||||
1,
|
||||
&[("from_wire_api", "responses_websocket")],
|
||||
);
|
||||
|
||||
self.connection = None;
|
||||
self.websocket_last_items.clear();
|
||||
}
|
||||
activated
|
||||
}
|
||||
|
||||
fn responses_websocket_enabled(&self) -> bool {
|
||||
self.state.provider.supports_websockets
|
||||
&& self
|
||||
.state
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::ResponsesWebsockets)
|
||||
}
|
||||
|
||||
fn build_responses_request(&self, prompt: &Prompt) -> Result<ApiPrompt> {
|
||||
let instructions = prompt.base_instructions.text.clone();
|
||||
let tools_json: Vec<Value> = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
@@ -454,14 +404,9 @@ impl ModelClientSession {
|
||||
if needs_new {
|
||||
let mut headers = options.extra_headers.clone();
|
||||
headers.extend(build_conversation_headers(options.conversation_id.clone()));
|
||||
let websocket_telemetry = self.build_websocket_telemetry();
|
||||
let new_conn: ApiWebSocketConnection =
|
||||
ApiWebSocketResponsesClient::new(api_provider, api_auth)
|
||||
.connect(
|
||||
headers,
|
||||
options.turn_state.clone(),
|
||||
Some(websocket_telemetry),
|
||||
)
|
||||
.connect(headers, options.turn_state.clone())
|
||||
.await?;
|
||||
self.connection = Some(new_conn);
|
||||
}
|
||||
@@ -477,7 +422,7 @@ impl ModelClientSession {
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::EnableRequestCompression)
|
||||
&& auth.is_some_and(CodexAuth::is_chatgpt_auth)
|
||||
&& auth.is_some_and(|auth| auth.mode == AuthMode::ChatGPT)
|
||||
&& self.state.provider.is_openai()
|
||||
{
|
||||
Compression::Zstd
|
||||
@@ -515,7 +460,7 @@ impl ModelClientSession {
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
|
||||
@@ -571,7 +516,7 @@ impl ModelClientSession {
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry();
|
||||
@@ -617,7 +562,7 @@ impl ModelClientSession {
|
||||
let api_provider = self
|
||||
.state
|
||||
.provider
|
||||
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
|
||||
.to_api_provider(auth.as_ref().map(|a| a.mode))?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
|
||||
let compression = self.responses_request_compression(auth.as_ref());
|
||||
|
||||
@@ -658,13 +603,6 @@ impl ModelClientSession {
|
||||
let sse_telemetry: Arc<dyn SseTelemetry> = telemetry;
|
||||
(request_telemetry, sse_telemetry)
|
||||
}
|
||||
|
||||
/// Builds telemetry for the Responses API WebSocket transport.
|
||||
fn build_websocket_telemetry(&self) -> Arc<dyn WebsocketTelemetry> {
|
||||
let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone()));
|
||||
let websocket_telemetry: Arc<dyn WebsocketTelemetry> = telemetry;
|
||||
websocket_telemetry
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelClient {
|
||||
@@ -864,19 +802,3 @@ impl SseTelemetry for ApiTelemetry {
|
||||
self.otel_manager.log_sse_event(result, duration);
|
||||
}
|
||||
}
|
||||
|
||||
impl WebsocketTelemetry for ApiTelemetry {
|
||||
fn on_ws_request(&self, duration: Duration, error: Option<&ApiError>) {
|
||||
let error_message = error.map(std::string::ToString::to_string);
|
||||
self.otel_manager
|
||||
.record_websocket_request(duration, error_message.as_deref());
|
||||
}
|
||||
|
||||
fn on_ws_event(
|
||||
&self,
|
||||
result: &std::result::Result<Option<std::result::Result<Message, Error>>, ApiError>,
|
||||
duration: Duration,
|
||||
) {
|
||||
self.otel_manager.record_websocket_event(result, duration);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -208,10 +208,6 @@ async fn forward_events(
|
||||
id: _,
|
||||
msg: EventMsg::SessionConfigured(_),
|
||||
} => {}
|
||||
Event {
|
||||
id: _,
|
||||
msg: EventMsg::ThreadNameUpdated(_),
|
||||
} => {}
|
||||
Event {
|
||||
id,
|
||||
msg: EventMsg::ExecApprovalRequest(event),
|
||||
|
||||
@@ -61,7 +61,6 @@ pub(crate) async fn run_compact_task(
|
||||
) {
|
||||
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
||||
model_context_window: turn_context.client.get_model_context_window(),
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
run_compact_task_inner(sess.clone(), turn_context, input).await;
|
||||
|
||||
@@ -22,7 +22,6 @@ pub(crate) async fn run_inline_remote_auto_compact_task(
|
||||
pub(crate) async fn run_remote_compact_task(sess: Arc<Session>, turn_context: Arc<TurnContext>) {
|
||||
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
||||
model_context_window: turn_context.client.get_model_context_window(),
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
|
||||
|
||||
@@ -18,12 +18,6 @@ pub enum ConstraintError {
|
||||
|
||||
#[error("field `{field_name}` cannot be empty")]
|
||||
EmptyField { field_name: String },
|
||||
|
||||
#[error("invalid rules in requirements (set by {requirement_source}): {reason}")]
|
||||
ExecPolicyParse {
|
||||
requirement_source: RequirementSource,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ConstraintError {
|
||||
|
||||
@@ -278,7 +278,7 @@ impl ConfigDocument {
|
||||
mutated
|
||||
}),
|
||||
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
|
||||
&["personality"],
|
||||
&["model_personality"],
|
||||
personality.map(|personality| value(personality.to_string())),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
|
||||
@@ -724,7 +724,7 @@ impl ConfigEditsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_personality(mut self, personality: Option<Personality>) -> Self {
|
||||
pub fn set_model_personality(mut self, personality: Option<Personality>) -> Self {
|
||||
self.edits
|
||||
.push(ConfigEdit::SetModelPersonality { personality });
|
||||
self
|
||||
|
||||
@@ -18,13 +18,11 @@ use crate::config::types::ShellEnvironmentPolicyToml;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config::types::Tui;
|
||||
use crate::config::types::UriBasedFileOpener;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::McpServerIdentity;
|
||||
use crate::config_loader::McpServerRequirement;
|
||||
use crate::config_loader::ResidencyRequirement;
|
||||
use crate::config_loader::Sourced;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::features::Feature;
|
||||
@@ -58,6 +56,7 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use dirs::home_dir;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -134,18 +133,13 @@ pub struct Config {
|
||||
pub model_provider: ModelProviderInfo,
|
||||
|
||||
/// Optionally specify the personality of the model
|
||||
pub personality: Option<Personality>,
|
||||
pub model_personality: Option<Personality>,
|
||||
|
||||
/// Approval policy for executing commands.
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
|
||||
pub sandbox_policy: Constrained<SandboxPolicy>,
|
||||
|
||||
/// enforce_residency means web traffic cannot be routed outside of a
|
||||
/// particular geography. HTTP clients should direct their requests
|
||||
/// using backend-specific headers or URLs to enforce this.
|
||||
pub enforce_residency: Constrained<Option<ResidencyRequirement>>,
|
||||
|
||||
/// True if the user passed in an override or set a value in config.toml
|
||||
/// for either of approval_policy or sandbox_mode.
|
||||
pub did_user_set_custom_approval_policy_or_sandbox_mode: bool,
|
||||
@@ -372,7 +366,6 @@ pub struct ConfigBuilder {
|
||||
cli_overrides: Option<Vec<(String, TomlValue)>>,
|
||||
harness_overrides: Option<ConfigOverrides>,
|
||||
loader_overrides: Option<LoaderOverrides>,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -397,11 +390,6 @@ impl ConfigBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self {
|
||||
self.cloud_requirements = cloud_requirements;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fallback_cwd(mut self, fallback_cwd: Option<PathBuf>) -> Self {
|
||||
self.fallback_cwd = fallback_cwd;
|
||||
self
|
||||
@@ -413,7 +401,6 @@ impl ConfigBuilder {
|
||||
cli_overrides,
|
||||
harness_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
fallback_cwd,
|
||||
} = self;
|
||||
let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?;
|
||||
@@ -426,14 +413,9 @@ impl ConfigBuilder {
|
||||
None => AbsolutePathBuf::current_dir()?,
|
||||
};
|
||||
harness_overrides.cwd = Some(cwd.to_path_buf());
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
)
|
||||
.await?;
|
||||
let config_layer_stack =
|
||||
load_config_layers_state(&codex_home, Some(cwd), &cli_overrides, loader_overrides)
|
||||
.await?;
|
||||
let merged_toml = config_layer_stack.effective_config();
|
||||
|
||||
// Note that each layer in ConfigLayerStack should have resolved
|
||||
@@ -529,7 +511,6 @@ pub async fn load_config_as_toml_with_cli_overrides(
|
||||
Some(cwd.clone()),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -628,14 +609,9 @@ pub async fn load_global_mcp_servers(
|
||||
// There is no cwd/project context for this query, so this will not include
|
||||
// MCP servers defined in in-repo .codex/ folders.
|
||||
let cwd: Option<AbsolutePathBuf> = None;
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
codex_home,
|
||||
cwd,
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
let config_layer_stack =
|
||||
load_config_layers_state(codex_home, cwd, &cli_overrides, LoaderOverrides::default())
|
||||
.await?;
|
||||
let merged_toml = config_layer_stack.effective_config();
|
||||
let Some(servers_value) = merged_toml.get("mcp_servers") else {
|
||||
return Ok(BTreeMap::new());
|
||||
@@ -912,8 +888,9 @@ pub struct ConfigToml {
|
||||
/// Override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
|
||||
/// EXPERIMENTAL
|
||||
/// Optionally specify a personality for the model
|
||||
pub personality: Option<Personality>,
|
||||
pub model_personality: Option<Personality>,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
@@ -1192,7 +1169,7 @@ pub struct ConfigOverrides {
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub personality: Option<Personality>,
|
||||
pub model_personality: Option<Personality>,
|
||||
pub compact_prompt: Option<String>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
@@ -1250,15 +1227,11 @@ fn resolve_web_search_mode(
|
||||
|
||||
pub(crate) fn resolve_web_search_mode_for_turn(
|
||||
explicit_mode: Option<WebSearchMode>,
|
||||
is_azure_responses_endpoint: bool,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> WebSearchMode {
|
||||
if let Some(mode) = explicit_mode {
|
||||
return mode;
|
||||
}
|
||||
if is_azure_responses_endpoint {
|
||||
return WebSearchMode::Disabled;
|
||||
}
|
||||
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
|
||||
WebSearchMode::Live
|
||||
} else {
|
||||
@@ -1299,7 +1272,7 @@ impl Config {
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
personality,
|
||||
model_personality,
|
||||
compact_prompt,
|
||||
include_apply_patch_tool: include_apply_patch_tool_override,
|
||||
show_raw_agent_reasoning,
|
||||
@@ -1399,6 +1372,12 @@ impl Config {
|
||||
|| cfg.sandbox_mode.is_some();
|
||||
|
||||
let mut model_providers = built_in_model_providers();
|
||||
if features.enabled(Feature::ResponsesWebsockets)
|
||||
&& let Some(provider) = model_providers.get_mut("openai")
|
||||
&& provider.is_openai()
|
||||
{
|
||||
provider.wire_api = crate::model_provider_info::WireApi::ResponsesWebsocket;
|
||||
}
|
||||
// Merge user-defined providers into the built-in list.
|
||||
for (key, provider) in cfg.model_providers.into_iter() {
|
||||
model_providers.entry(key).or_insert(provider);
|
||||
@@ -1496,14 +1475,9 @@ impl Config {
|
||||
Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
|
||||
let personality = personality
|
||||
.or(config_profile.personality)
|
||||
.or(cfg.personality)
|
||||
.or_else(|| {
|
||||
features
|
||||
.enabled(Feature::Personality)
|
||||
.then_some(Personality::Friendly)
|
||||
});
|
||||
let model_personality = model_personality
|
||||
.or(config_profile.model_personality)
|
||||
.or(cfg.model_personality);
|
||||
|
||||
let experimental_compact_prompt_path = config_profile
|
||||
.experimental_compact_prompt_file
|
||||
@@ -1525,8 +1499,6 @@ impl Config {
|
||||
approval_policy: mut constrained_approval_policy,
|
||||
sandbox_policy: mut constrained_sandbox_policy,
|
||||
mcp_servers,
|
||||
exec_policy: _,
|
||||
enforce_residency,
|
||||
} = requirements;
|
||||
|
||||
constrained_approval_policy
|
||||
@@ -1549,14 +1521,13 @@ impl Config {
|
||||
cwd: resolved_cwd,
|
||||
approval_policy: constrained_approval_policy,
|
||||
sandbox_policy: constrained_sandbox_policy,
|
||||
enforce_residency,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode,
|
||||
forced_auto_mode_downgraded_on_windows,
|
||||
shell_environment_policy,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
personality,
|
||||
model_personality,
|
||||
developer_instructions,
|
||||
compact_prompt,
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
@@ -1764,12 +1735,27 @@ fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool {
|
||||
/// specified by the `CODEX_HOME` environment variable. If not set, defaults to
|
||||
/// `~/.codex`.
|
||||
///
|
||||
/// - If `CODEX_HOME` is set, the value must exist and be a directory. The
|
||||
/// value will be canonicalized and this function will Err otherwise.
|
||||
/// - If `CODEX_HOME` is set, the value will be canonicalized and this
|
||||
/// function will Err if the path does not exist.
|
||||
/// - If `CODEX_HOME` is not set, this function does not verify that the
|
||||
/// directory exists.
|
||||
pub fn find_codex_home() -> std::io::Result<PathBuf> {
|
||||
codex_utils_home_dir::find_codex_home()
|
||||
// Honor the `CODEX_HOME` environment variable when it is set to allow users
|
||||
// (and tests) to override the default location.
|
||||
if let Ok(val) = std::env::var("CODEX_HOME")
|
||||
&& !val.is_empty()
|
||||
{
|
||||
return PathBuf::from(val).canonicalize();
|
||||
}
|
||||
|
||||
let mut p = home_dir().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Could not find home directory",
|
||||
)
|
||||
})?;
|
||||
p.push(".codex");
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Returns the path to the folder where Codex logs are stored. Does not verify
|
||||
@@ -2347,14 +2333,14 @@ trust_level = "trusted"
|
||||
|
||||
#[test]
|
||||
fn web_search_mode_for_turn_defaults_to_cached_when_unset() {
|
||||
let mode = resolve_web_search_mode_for_turn(None, false, &SandboxPolicy::ReadOnly);
|
||||
let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::ReadOnly);
|
||||
|
||||
assert_eq!(mode, WebSearchMode::Cached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_mode_for_turn_defaults_to_live_for_danger_full_access() {
|
||||
let mode = resolve_web_search_mode_for_turn(None, false, &SandboxPolicy::DangerFullAccess);
|
||||
let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::DangerFullAccess);
|
||||
|
||||
assert_eq!(mode, WebSearchMode::Live);
|
||||
}
|
||||
@@ -2363,20 +2349,12 @@ trust_level = "trusted"
|
||||
fn web_search_mode_for_turn_prefers_explicit_value() {
|
||||
let mode = resolve_web_search_mode_for_turn(
|
||||
Some(WebSearchMode::Cached),
|
||||
false,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
);
|
||||
|
||||
assert_eq!(mode, WebSearchMode::Cached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_search_mode_for_turn_disables_for_azure_responses_endpoint() {
|
||||
let mode = resolve_web_search_mode_for_turn(None, true, &SandboxPolicy::DangerFullAccess);
|
||||
|
||||
assert_eq!(mode, WebSearchMode::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_legacy_toggles_override_base() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -2577,7 +2555,7 @@ profile = "project"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_websockets_feature_does_not_change_wire_api() -> std::io::Result<()> {
|
||||
fn responses_websockets_feature_updates_openai_provider() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut entries = BTreeMap::new();
|
||||
entries.insert("responses_websockets".to_string(), true);
|
||||
@@ -2594,7 +2572,7 @@ profile = "project"
|
||||
|
||||
assert_eq!(
|
||||
config.model_provider.wire_api,
|
||||
crate::model_provider_info::WireApi::Responses
|
||||
crate::model_provider_info::WireApi::ResponsesWebsocket
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -2639,14 +2617,8 @@ profile = "project"
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
codex_home.path(),
|
||||
Some(cwd),
|
||||
&Vec::new(),
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
let config_layer_stack =
|
||||
load_config_layers_state(codex_home.path(), Some(cwd), &Vec::new(), overrides).await?;
|
||||
let cfg = deserialize_config_toml_with_base(
|
||||
config_layer_stack.effective_config(),
|
||||
codex_home.path(),
|
||||
@@ -2773,7 +2745,6 @@ profile = "project"
|
||||
Some(cwd),
|
||||
&[("model".to_string(), TomlValue::String("cli".to_string()))],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3721,7 +3692,6 @@ model_verbosity = "high"
|
||||
stream_max_retries: Some(10),
|
||||
stream_idle_timeout_ms: Some(300_000),
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
let model_provider_map = {
|
||||
let mut model_provider_map = built_in_model_providers();
|
||||
@@ -3783,7 +3753,6 @@ model_verbosity = "high"
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
@@ -3811,7 +3780,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Friendly),
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -3868,7 +3837,6 @@ model_verbosity = "high"
|
||||
model_provider: fixture.openai_chat_completions_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
@@ -3896,7 +3864,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Friendly),
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -3968,7 +3936,6 @@ model_verbosity = "high"
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
@@ -3996,7 +3963,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Friendly),
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -4054,7 +4021,6 @@ model_verbosity = "high"
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
forced_auto_mode_downgraded_on_windows: false,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
@@ -4082,7 +4048,7 @@ model_verbosity = "high"
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_verbosity: Some(Verbosity::High),
|
||||
personality: Some(Personality::Friendly),
|
||||
model_personality: None,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct ConfigProfile {
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
pub personality: Option<Personality>,
|
||||
pub model_personality: Option<Personality>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -2,7 +2,6 @@ use super::CONFIG_TOML_FILE;
|
||||
use super::ConfigToml;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
@@ -110,7 +109,6 @@ pub struct ConfigService {
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
}
|
||||
|
||||
impl ConfigService {
|
||||
@@ -118,13 +116,11 @@ impl ConfigService {
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
cli_overrides,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +129,6 @@ impl ConfigService {
|
||||
codex_home,
|
||||
cli_overrides: Vec::new(),
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
cloud_requirements: CloudRequirementsLoader::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +146,6 @@ impl ConfigService {
|
||||
.cli_overrides(self.cli_overrides.clone())
|
||||
.loader_overrides(self.loader_overrides.clone())
|
||||
.fallback_cwd(Some(cwd.to_path_buf()))
|
||||
.cloud_requirements(self.cloud_requirements.clone())
|
||||
.build()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -382,7 +376,6 @@ impl ConfigService {
|
||||
cwd,
|
||||
&self.cli_overrides,
|
||||
self.loader_overrides.clone(),
|
||||
self.cloud_requirements.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -820,7 +813,6 @@ remote_compaction = true
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
@@ -903,7 +895,6 @@ remote_compaction = true
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let result = service
|
||||
@@ -1008,7 +999,6 @@ remote_compaction = true
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let error = service
|
||||
@@ -1057,7 +1047,6 @@ remote_compaction = true
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
@@ -1105,7 +1094,6 @@ remote_compaction = true
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let result = service
|
||||
|
||||
@@ -10,7 +10,7 @@ This module is the canonical place to **load and describe Codex configuration la
|
||||
|
||||
Exported from `codex_core::config_loader`:
|
||||
|
||||
- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack`
|
||||
- `load_config_layers_state(codex_home, cwd_opt, cli_overrides, overrides) -> ConfigLayerStack`
|
||||
- `ConfigLayerStack`
|
||||
- `effective_config() -> toml::Value`
|
||||
- `origins() -> HashMap<String, ConfigLayerMetadata>`
|
||||
@@ -49,7 +49,6 @@ let layers = load_config_layers_state(
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
None,
|
||||
).await?;
|
||||
|
||||
let effective = layers.effective_config();
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::future::FutureExt;
|
||||
use futures::future::Shared;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CloudRequirementsLoader {
|
||||
// TODO(gt): This should return a Result once we can fail-closed.
|
||||
fut: Shared<BoxFuture<'static, Option<ConfigRequirementsToml>>>,
|
||||
}
|
||||
|
||||
impl CloudRequirementsLoader {
|
||||
pub fn new<F>(fut: F) -> Self
|
||||
where
|
||||
F: Future<Output = Option<ConfigRequirementsToml>> + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
fut: fut.boxed().shared(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> Option<ConfigRequirementsToml> {
|
||||
self.fut.clone().await
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CloudRequirementsLoader {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CloudRequirementsLoader").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CloudRequirementsLoader {
|
||||
fn default() -> Self {
|
||||
Self::new(async { None })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_future_runs_once() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = Arc::clone(&counter);
|
||||
let loader = CloudRequirementsLoader::new(async move {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
Some(ConfigRequirementsToml::default())
|
||||
});
|
||||
|
||||
let (first, second) = tokio::join!(loader.get(), loader.get());
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use super::requirements_exec_policy::RequirementsExecPolicy;
|
||||
use super::requirements_exec_policy::RequirementsExecPolicyToml;
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintError;
|
||||
|
||||
@@ -15,7 +13,6 @@ use crate::config::ConstraintError;
|
||||
pub enum RequirementSource {
|
||||
Unknown,
|
||||
MdmManagedPreferences { domain: String, key: String },
|
||||
CloudRequirements,
|
||||
SystemRequirementsToml { file: AbsolutePathBuf },
|
||||
LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf },
|
||||
LegacyManagedConfigTomlFromMdm,
|
||||
@@ -28,9 +25,6 @@ impl fmt::Display for RequirementSource {
|
||||
RequirementSource::MdmManagedPreferences { domain, key } => {
|
||||
write!(f, "MDM {domain}:{key}")
|
||||
}
|
||||
RequirementSource::CloudRequirements => {
|
||||
write!(f, "cloud requirements")
|
||||
}
|
||||
RequirementSource::SystemRequirementsToml { file } => {
|
||||
write!(f, "{}", file.as_path().display())
|
||||
}
|
||||
@@ -51,8 +45,6 @@ pub struct ConfigRequirements {
|
||||
pub approval_policy: Constrained<AskForApproval>,
|
||||
pub sandbox_policy: Constrained<SandboxPolicy>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub(crate) exec_policy: Option<Sourced<RequirementsExecPolicy>>,
|
||||
pub enforce_residency: Constrained<Option<ResidencyRequirement>>,
|
||||
}
|
||||
|
||||
impl Default for ConfigRequirements {
|
||||
@@ -61,8 +53,6 @@ impl Default for ConfigRequirements {
|
||||
approval_policy: Constrained::allow_any_from_default(),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::ReadOnly),
|
||||
mcp_servers: None,
|
||||
exec_policy: None,
|
||||
enforce_residency: Constrained::allow_any(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,8 +75,6 @@ pub struct ConfigRequirementsToml {
|
||||
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
|
||||
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
|
||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
pub rules: Option<RequirementsExecPolicyToml>,
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
}
|
||||
|
||||
/// Value paired with the requirement source it came from, for better error
|
||||
@@ -116,8 +104,6 @@ pub struct ConfigRequirementsWithSources {
|
||||
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
|
||||
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
|
||||
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
|
||||
}
|
||||
|
||||
impl ConfigRequirementsWithSources {
|
||||
@@ -149,8 +135,6 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -160,15 +144,11 @@ impl ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
} = self;
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
|
||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||
rules: rules.map(|sourced| sourced.value),
|
||||
enforce_residency: enforce_residency.map(|sourced| sourced.value),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,19 +180,11 @@ impl From<SandboxMode> for SandboxModeRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ResidencyRequirement {
|
||||
Us,
|
||||
}
|
||||
|
||||
impl ConfigRequirementsToml {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.allowed_approval_policies.is_none()
|
||||
&& self.allowed_sandbox_modes.is_none()
|
||||
&& self.mcp_servers.is_none()
|
||||
&& self.rules.is_none()
|
||||
&& self.enforce_residency.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,8 +196,6 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
} = toml;
|
||||
|
||||
let approval_policy: Constrained<AskForApproval> = match allowed_approval_policies {
|
||||
@@ -300,46 +270,10 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
}
|
||||
None => Constrained::allow_any(default_sandbox_policy),
|
||||
};
|
||||
let exec_policy = match rules {
|
||||
Some(Sourced { value, source }) => {
|
||||
let policy = value.to_requirements_policy().map_err(|err| {
|
||||
ConstraintError::ExecPolicyParse {
|
||||
requirement_source: source.clone(),
|
||||
reason: err.to_string(),
|
||||
}
|
||||
})?;
|
||||
Some(Sourced::new(policy, source))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let enforce_residency: Constrained<Option<ResidencyRequirement>> = match enforce_residency {
|
||||
Some(Sourced {
|
||||
value: residency,
|
||||
source: requirement_source,
|
||||
}) => {
|
||||
let required = Some(residency);
|
||||
Constrained::new(required, move |candidate| {
|
||||
if candidate == &required {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "enforce_residency",
|
||||
candidate: format!("{candidate:?}"),
|
||||
allowed: format!("{required:?}"),
|
||||
requirement_source: requirement_source.clone(),
|
||||
})
|
||||
}
|
||||
})?
|
||||
}
|
||||
None => Constrained::allow_any(None),
|
||||
};
|
||||
Ok(ConfigRequirements {
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
mcp_servers,
|
||||
exec_policy,
|
||||
enforce_residency,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -348,25 +282,16 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_protocol::protocol::NetworkAccess;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use toml::from_str;
|
||||
|
||||
fn tokens(cmd: &[&str]) -> Vec<String> {
|
||||
cmd.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
mcp_servers,
|
||||
rules,
|
||||
enforce_residency,
|
||||
} = toml;
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: allowed_approval_policies
|
||||
@@ -374,9 +299,6 @@ mod tests {
|
||||
allowed_sandbox_modes: allowed_sandbox_modes
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
enforce_residency: enforce_residency
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,8 +312,6 @@ mod tests {
|
||||
SandboxModeRequirement::WorkspaceWrite,
|
||||
SandboxModeRequirement::DangerFullAccess,
|
||||
];
|
||||
let enforce_residency = ResidencyRequirement::Us;
|
||||
let enforce_source = source.clone();
|
||||
|
||||
// Intentionally constructed without `..Default::default()` so adding a new field to
|
||||
// `ConfigRequirementsToml` forces this test to be updated.
|
||||
@@ -399,8 +319,6 @@ mod tests {
|
||||
allowed_approval_policies: Some(allowed_approval_policies.clone()),
|
||||
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(enforce_residency),
|
||||
};
|
||||
|
||||
target.merge_unset_fields(source.clone(), other);
|
||||
@@ -414,8 +332,6 @@ mod tests {
|
||||
)),
|
||||
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)),
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -444,8 +360,6 @@ mod tests {
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
@@ -482,8 +396,6 @@ mod tests {
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
@@ -536,33 +448,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_error_includes_cloud_requirements_source() -> Result<()> {
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let source_location = RequirementSource::CloudRequirements;
|
||||
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
target.merge_unset_fields(source_location.clone(), source);
|
||||
let requirements = ConfigRequirements::try_from(target)?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.approval_policy.can_set(&AskForApproval::Never),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: "Never".into(),
|
||||
allowed: "[OnRequest]".into(),
|
||||
requirement_source: source_location,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_allowed_approval_policies() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
@@ -710,64 +595,4 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_exec_policy_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[rules]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }], decision = "forbidden" },
|
||||
]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||
let policy = requirements.exec_policy.expect("exec policy").value;
|
||||
|
||||
assert_eq!(
|
||||
policy.as_ref().check(&tokens(&["rm", "-rf"]), &|_| {
|
||||
panic!("rule should match so heuristic should not be called");
|
||||
}),
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["rm"]),
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_policy_error_includes_requirement_source() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[rules]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }] },
|
||||
]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements_toml_file =
|
||||
AbsolutePathBuf::from_absolute_path("/etc/codex/requirements.toml")?;
|
||||
let source_location = RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file,
|
||||
};
|
||||
|
||||
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
||||
requirements_with_sources.merge_unset_fields(source_location.clone(), config);
|
||||
let err = ConfigRequirements::try_from(requirements_with_sources)
|
||||
.expect_err("invalid exec policy");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
ConstraintError::ExecPolicyParse {
|
||||
requirement_source: source_location,
|
||||
reason: "rules prefix_rule at index 0 is missing a decision".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
mod cloud_requirements;
|
||||
mod config_requirements;
|
||||
mod diagnostics;
|
||||
mod fingerprint;
|
||||
@@ -7,6 +6,7 @@ mod layer_io;
|
||||
mod macos;
|
||||
mod merge;
|
||||
mod overrides;
|
||||
#[cfg(test)]
|
||||
mod requirements_exec_policy;
|
||||
mod state;
|
||||
|
||||
@@ -25,19 +25,16 @@ use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub use cloud_requirements::CloudRequirementsLoader;
|
||||
pub use config_requirements::ConfigRequirements;
|
||||
pub use config_requirements::ConfigRequirementsToml;
|
||||
pub use config_requirements::McpServerIdentity;
|
||||
pub use config_requirements::McpServerRequirement;
|
||||
pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::ResidencyRequirement;
|
||||
pub use config_requirements::SandboxModeRequirement;
|
||||
pub use config_requirements::Sourced;
|
||||
pub use diagnostics::ConfigError;
|
||||
@@ -72,7 +69,6 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||
/// earlier layer cannot be overridden by a later layer:
|
||||
///
|
||||
/// - admin: managed preferences (*)
|
||||
/// - cloud: managed cloud requirements
|
||||
/// - system `/etc/codex/requirements.toml`
|
||||
///
|
||||
/// For backwards compatibility, we also load from
|
||||
@@ -102,7 +98,6 @@ pub async fn load_config_layers_state(
|
||||
cwd: Option<AbsolutePathBuf>,
|
||||
cli_overrides: &[(String, TomlValue)],
|
||||
overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
) -> io::Result<ConfigLayerStack> {
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
|
||||
@@ -115,11 +110,6 @@ pub async fn load_config_layers_state(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(requirements) = cloud_requirements.get().await {
|
||||
config_requirements_toml
|
||||
.merge_unset_fields(RequirementSource::CloudRequirements, requirements);
|
||||
}
|
||||
|
||||
// Honor /etc/codex/requirements.toml.
|
||||
if cfg!(unix) {
|
||||
load_requirements_toml(
|
||||
@@ -236,7 +226,6 @@ pub async fn load_config_layers_state(
|
||||
&cwd,
|
||||
&project_trust_context.project_root,
|
||||
&project_trust_context,
|
||||
codex_home,
|
||||
)
|
||||
.await?;
|
||||
layers.extend(project_layers);
|
||||
@@ -425,9 +414,7 @@ async fn load_requirements_from_legacy_scheme(
|
||||
/// empty array, which indicates that root detection should be disabled).
|
||||
/// - Returns an error if `project_root_markers` is specified but is not an
|
||||
/// array of strings.
|
||||
pub(crate) fn project_root_markers_from_config(
|
||||
config: &TomlValue,
|
||||
) -> io::Result<Option<Vec<String>>> {
|
||||
fn project_root_markers_from_config(config: &TomlValue) -> io::Result<Option<Vec<String>>> {
|
||||
let Some(table) = config.as_table() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -456,7 +443,7 @@ pub(crate) fn project_root_markers_from_config(
|
||||
Ok(Some(markers))
|
||||
}
|
||||
|
||||
pub(crate) fn default_project_root_markers() -> Vec<String> {
|
||||
fn default_project_root_markers() -> Vec<String> {
|
||||
DEFAULT_PROJECT_ROOT_MARKERS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
@@ -674,11 +661,7 @@ async fn load_project_layers(
|
||||
cwd: &AbsolutePathBuf,
|
||||
project_root: &AbsolutePathBuf,
|
||||
trust_context: &ProjectTrustContext,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<ConfigLayerEntry>> {
|
||||
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let codex_home_normalized =
|
||||
normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
|
||||
let mut dirs = cwd
|
||||
.as_path()
|
||||
.ancestors()
|
||||
@@ -709,11 +692,6 @@ async fn load_project_layers(
|
||||
let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?;
|
||||
let decision = trust_context.decision_for_dir(&layer_dir);
|
||||
let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?;
|
||||
let dot_codex_normalized =
|
||||
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
|
||||
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
|
||||
continue;
|
||||
}
|
||||
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?;
|
||||
match tokio::fs::read_to_string(&config_file).await {
|
||||
Ok(contents) => {
|
||||
|
||||
@@ -9,43 +9,16 @@ use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RequirementsExecPolicy {
|
||||
policy: Policy,
|
||||
/// TOML types for expressing exec policy requirements.
|
||||
///
|
||||
/// These types are kept separate from `ConfigRequirementsToml` and are
|
||||
/// converted into `codex-execpolicy` rules.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct RequirementsExecPolicyTomlRoot {
|
||||
pub exec_policy: RequirementsExecPolicyToml,
|
||||
}
|
||||
|
||||
impl RequirementsExecPolicy {
|
||||
pub fn new(policy: Policy) -> Self {
|
||||
Self { policy }
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for RequirementsExecPolicy {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
policy_fingerprint(&self.policy) == policy_fingerprint(&other.policy)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for RequirementsExecPolicy {}
|
||||
|
||||
impl AsRef<Policy> for RequirementsExecPolicy {
|
||||
fn as_ref(&self) -> &Policy {
|
||||
&self.policy
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_fingerprint(policy: &Policy) -> Vec<String> {
|
||||
let mut entries = Vec::new();
|
||||
for (program, rules) in policy.rules().iter_all() {
|
||||
for rule in rules {
|
||||
entries.push(format!("{program}:{rule:?}"));
|
||||
}
|
||||
}
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
|
||||
/// TOML representation of `[rules]` within `requirements.toml`.
|
||||
/// TOML representation of `[exec_policy]` within `requirements.toml`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct RequirementsExecPolicyToml {
|
||||
pub prefix_rules: Vec<RequirementsExecPolicyPrefixRuleToml>,
|
||||
@@ -92,14 +65,14 @@ impl RequirementsExecPolicyDecisionToml {
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RequirementsExecPolicyParseError {
|
||||
#[error("rules prefix_rules cannot be empty")]
|
||||
#[error("exec policy prefix_rules cannot be empty")]
|
||||
EmptyPrefixRules,
|
||||
|
||||
#[error("rules prefix_rule at index {rule_index} has an empty pattern")]
|
||||
#[error("exec policy prefix_rule at index {rule_index} has an empty pattern")]
|
||||
EmptyPattern { rule_index: usize },
|
||||
|
||||
#[error(
|
||||
"rules prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}"
|
||||
"exec policy prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}"
|
||||
)]
|
||||
InvalidPatternToken {
|
||||
rule_index: usize,
|
||||
@@ -107,20 +80,12 @@ pub enum RequirementsExecPolicyParseError {
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("rules prefix_rule at index {rule_index} has an empty justification")]
|
||||
#[error("exec policy prefix_rule at index {rule_index} has an empty justification")]
|
||||
EmptyJustification { rule_index: usize },
|
||||
|
||||
#[error("rules prefix_rule at index {rule_index} is missing a decision")]
|
||||
MissingDecision { rule_index: usize },
|
||||
|
||||
#[error(
|
||||
"rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt' or 'forbidden')"
|
||||
)]
|
||||
AllowDecisionNotAllowed { rule_index: usize },
|
||||
}
|
||||
|
||||
impl RequirementsExecPolicyToml {
|
||||
/// Convert requirements TOML rules into the internal `.rules`
|
||||
/// Convert requirements TOML exec policy rules into the internal `.rules`
|
||||
/// representation used by `codex-execpolicy`.
|
||||
pub fn to_policy(&self) -> Result<Policy, RequirementsExecPolicyParseError> {
|
||||
if self.prefix_rules.is_empty() {
|
||||
@@ -147,17 +112,10 @@ impl RequirementsExecPolicyToml {
|
||||
.map(|(token_index, token)| parse_pattern_token(token, rule_index, token_index))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let decision = match rule.decision {
|
||||
Some(RequirementsExecPolicyDecisionToml::Allow) => {
|
||||
return Err(RequirementsExecPolicyParseError::AllowDecisionNotAllowed {
|
||||
rule_index,
|
||||
});
|
||||
}
|
||||
Some(decision) => decision.as_decision(),
|
||||
None => {
|
||||
return Err(RequirementsExecPolicyParseError::MissingDecision { rule_index });
|
||||
}
|
||||
};
|
||||
let decision = rule
|
||||
.decision
|
||||
.map(RequirementsExecPolicyDecisionToml::as_decision)
|
||||
.unwrap_or(Decision::Allow);
|
||||
let justification = rule.justification.clone();
|
||||
|
||||
let (first_token, remaining_tokens) = pattern_tokens
|
||||
@@ -181,12 +139,6 @@ impl RequirementsExecPolicyToml {
|
||||
|
||||
Ok(Policy::new(rules_by_program))
|
||||
}
|
||||
|
||||
pub(crate) fn to_requirements_policy(
|
||||
&self,
|
||||
) -> Result<RequirementsExecPolicy, RequirementsExecPolicyParseError> {
|
||||
self.to_policy().map(RequirementsExecPolicy::new)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pattern_token(
|
||||
|
||||
@@ -4,15 +4,11 @@ use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::ConstraintError;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLoadError;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::config_loader::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::config_loader::config_requirements::RequirementSource;
|
||||
use crate::config_loader::fingerprint::version_for_toml;
|
||||
use crate::config_loader::load_requirements_toml;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
@@ -69,7 +65,6 @@ async fn returns_config_error_for_invalid_user_config_toml() {
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("expected error");
|
||||
@@ -99,7 +94,6 @@ async fn returns_config_error_for_invalid_managed_config_toml() {
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("expected error");
|
||||
@@ -188,7 +182,6 @@ extra = true
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect("load config");
|
||||
@@ -225,7 +218,6 @@ async fn returns_empty_when_all_layers_missing() {
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect("load layers");
|
||||
@@ -323,7 +315,6 @@ flag = false
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect("load config");
|
||||
@@ -363,7 +354,6 @@ allowed_sandbox_modes = ["read-only"]
|
||||
),
|
||||
),
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -424,7 +414,6 @@ allowed_approval_policies = ["never"]
|
||||
),
|
||||
),
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -451,7 +440,6 @@ async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Resul
|
||||
&requirements_file,
|
||||
r#"
|
||||
allowed_approval_policies = ["never", "on-request"]
|
||||
enforce_residency = "us"
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
@@ -466,6 +454,7 @@ enforce_residency = "us"
|
||||
.cloned(),
|
||||
Some(vec![AskForApproval::Never, AskForApproval::OnRequest])
|
||||
);
|
||||
|
||||
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
|
||||
assert_eq!(
|
||||
config_requirements.approval_policy.value(),
|
||||
@@ -480,99 +469,6 @@ enforce_residency = "us"
|
||||
.can_set(&AskForApproval::OnFailure)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
config_requirements.enforce_residency.value(),
|
||||
Some(crate::config_loader::ResidencyRequirement::Us)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cloud_requirements_are_not_overwritten_by_system_requirements() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let requirements_file = tmp.path().join("requirements.toml");
|
||||
tokio::fs::write(
|
||||
&requirements_file,
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
config_requirements_toml.merge_unset_fields(
|
||||
RequirementSource::CloudRequirements,
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
},
|
||||
);
|
||||
load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?;
|
||||
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
.allowed_approval_policies
|
||||
.as_ref()
|
||||
.map(|sourced| sourced.value.clone()),
|
||||
Some(vec![AskForApproval::Never])
|
||||
);
|
||||
assert_eq!(
|
||||
config_requirements_toml
|
||||
.allowed_approval_policies
|
||||
.as_ref()
|
||||
.map(|sourced| sourced.source.clone()),
|
||||
Some(RequirementSource::CloudRequirements)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
mcp_servers: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
};
|
||||
let expected = requirements.clone();
|
||||
let cloud_requirements = CloudRequirementsLoader::new(async move { Some(requirements) });
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
cloud_requirements,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
layers.requirements_toml().allowed_approval_policies,
|
||||
expected.allowed_approval_policies
|
||||
);
|
||||
assert_eq!(
|
||||
layers
|
||||
.requirements()
|
||||
.approval_policy
|
||||
.can_set(&AskForApproval::OnRequest),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: "OnRequest".into(),
|
||||
allowed: "[Never]".into(),
|
||||
requirement_source: RequirementSource::CloudRequirements,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -605,7 +501,6 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -737,7 +632,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -761,103 +655,6 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let home_dir = tmp.path().join("home");
|
||||
let codex_home = home_dir.join(".codex");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "foo = \"user\"\n").await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?;
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.get_layers(
|
||||
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
true,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
let expected: Vec<&ConfigLayerEntry> = Vec::new();
|
||||
assert_eq!(expected, project_layers);
|
||||
assert_eq!(
|
||||
layers.effective_config().get("foo"),
|
||||
Some(&TomlValue::String("user".to_string()))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
let project_dot_codex = project_root.join(".codex");
|
||||
let nested_dot_codex = nested.join(".codex");
|
||||
|
||||
tokio::fs::create_dir_all(&nested_dot_codex).await?;
|
||||
tokio::fs::create_dir_all(project_root.join(".git")).await?;
|
||||
tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?;
|
||||
|
||||
tokio::fs::create_dir_all(&project_dot_codex).await?;
|
||||
make_config_for_test(&project_dot_codex, &project_root, TrustLevel::Trusted, None).await?;
|
||||
let user_config_path = project_dot_codex.join(CONFIG_TOML_FILE);
|
||||
let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?;
|
||||
tokio::fs::write(
|
||||
&user_config_path,
|
||||
format!("foo = \"user\"\n{user_config_contents}"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
let layers = load_config_layers_state(
|
||||
&project_dot_codex,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.get_layers(
|
||||
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
true,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
|
||||
let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config");
|
||||
assert_eq!(
|
||||
vec![&ConfigLayerEntry {
|
||||
name: super::ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?,
|
||||
},
|
||||
config: child_config.clone(),
|
||||
version: version_for_toml(&child_config),
|
||||
disabled_reason: None,
|
||||
}],
|
||||
project_layers
|
||||
);
|
||||
assert_eq!(
|
||||
layers.effective_config().get("foo"),
|
||||
Some(&TomlValue::String("child".to_string()))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -894,7 +691,6 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
let project_layers_untrusted: Vec<_> = layers_untrusted
|
||||
@@ -932,7 +728,6 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
let project_layers_unknown: Vec<_> = layers_unknown
|
||||
@@ -993,7 +788,6 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
let project_layers: Vec<_> = layers
|
||||
@@ -1049,7 +843,6 @@ async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1091,7 +884,6 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1121,79 +913,49 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
|
||||
}
|
||||
|
||||
mod requirements_exec_policy_tests {
|
||||
use super::super::config_requirements::ConfigRequirementsWithSources;
|
||||
use super::super::requirements_exec_policy::RequirementsExecPolicyDecisionToml;
|
||||
use super::super::requirements_exec_policy::RequirementsExecPolicyParseError;
|
||||
use super::super::requirements_exec_policy::RequirementsExecPolicyPatternTokenToml;
|
||||
use super::super::requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml;
|
||||
use super::super::requirements_exec_policy::RequirementsExecPolicyToml;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::exec_policy::load_exec_policy;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use super::super::requirements_exec_policy::RequirementsExecPolicyTomlRoot;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use toml::Value as TomlValue;
|
||||
use toml::from_str;
|
||||
|
||||
fn tokens(cmd: &[&str]) -> Vec<String> {
|
||||
cmd.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
fn panic_if_called(_: &[String]) -> Decision {
|
||||
panic!("rule should match so heuristic should not be called");
|
||||
}
|
||||
|
||||
fn config_stack_for_dot_codex_folder_with_requirements(
|
||||
dot_codex_folder: &Path,
|
||||
requirements: ConfigRequirements,
|
||||
) -> ConfigLayerStack {
|
||||
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(dot_codex_folder)
|
||||
.expect("absolute dot_codex_folder");
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project { dot_codex_folder },
|
||||
TomlValue::Table(Default::default()),
|
||||
);
|
||||
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())
|
||||
.expect("ConfigLayerStack")
|
||||
}
|
||||
|
||||
fn requirements_from_toml(toml_str: &str) -> ConfigRequirements {
|
||||
let config: ConfigRequirementsToml = from_str(toml_str).expect("parse requirements toml");
|
||||
let mut with_sources = ConfigRequirementsWithSources::default();
|
||||
with_sources.merge_unset_fields(RequirementSource::Unknown, config);
|
||||
ConfigRequirements::try_from(with_sources).expect("requirements")
|
||||
fn allow_all(_: &[String]) -> Decision {
|
||||
Decision::Allow
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_single_prefix_rule_from_raw_toml() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
[exec_policy]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }], decision = "forbidden" },
|
||||
]
|
||||
"#;
|
||||
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?;
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RequirementsExecPolicyToml {
|
||||
prefix_rules: vec![RequirementsExecPolicyPrefixRuleToml {
|
||||
pattern: vec![RequirementsExecPolicyPatternTokenToml {
|
||||
token: Some("rm".to_string()),
|
||||
any_of: None,
|
||||
RequirementsExecPolicyTomlRoot {
|
||||
exec_policy: RequirementsExecPolicyToml {
|
||||
prefix_rules: vec![RequirementsExecPolicyPrefixRuleToml {
|
||||
pattern: vec![RequirementsExecPolicyPatternTokenToml {
|
||||
token: Some("rm".to_string()),
|
||||
any_of: None,
|
||||
}],
|
||||
decision: Some(RequirementsExecPolicyDecisionToml::Forbidden),
|
||||
justification: None,
|
||||
}],
|
||||
decision: Some(RequirementsExecPolicyDecisionToml::Forbidden),
|
||||
justification: None,
|
||||
}],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1203,41 +965,44 @@ prefix_rules = [
|
||||
#[test]
|
||||
fn parses_multiple_prefix_rules_from_raw_toml() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
[exec_policy]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }], decision = "forbidden" },
|
||||
{ pattern = [{ token = "git" }, { any_of = ["push", "commit"] }], decision = "prompt", justification = "review changes before push or commit" },
|
||||
]
|
||||
"#;
|
||||
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?;
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RequirementsExecPolicyToml {
|
||||
prefix_rules: vec![
|
||||
RequirementsExecPolicyPrefixRuleToml {
|
||||
pattern: vec![RequirementsExecPolicyPatternTokenToml {
|
||||
token: Some("rm".to_string()),
|
||||
any_of: None,
|
||||
}],
|
||||
decision: Some(RequirementsExecPolicyDecisionToml::Forbidden),
|
||||
justification: None,
|
||||
},
|
||||
RequirementsExecPolicyPrefixRuleToml {
|
||||
pattern: vec![
|
||||
RequirementsExecPolicyPatternTokenToml {
|
||||
token: Some("git".to_string()),
|
||||
RequirementsExecPolicyTomlRoot {
|
||||
exec_policy: RequirementsExecPolicyToml {
|
||||
prefix_rules: vec![
|
||||
RequirementsExecPolicyPrefixRuleToml {
|
||||
pattern: vec![RequirementsExecPolicyPatternTokenToml {
|
||||
token: Some("rm".to_string()),
|
||||
any_of: None,
|
||||
},
|
||||
RequirementsExecPolicyPatternTokenToml {
|
||||
token: None,
|
||||
any_of: Some(vec!["push".to_string(), "commit".to_string()]),
|
||||
},
|
||||
],
|
||||
decision: Some(RequirementsExecPolicyDecisionToml::Prompt),
|
||||
justification: Some("review changes before push or commit".to_string()),
|
||||
},
|
||||
],
|
||||
}],
|
||||
decision: Some(RequirementsExecPolicyDecisionToml::Forbidden),
|
||||
justification: None,
|
||||
},
|
||||
RequirementsExecPolicyPrefixRuleToml {
|
||||
pattern: vec![
|
||||
RequirementsExecPolicyPatternTokenToml {
|
||||
token: Some("git".to_string()),
|
||||
any_of: None,
|
||||
},
|
||||
RequirementsExecPolicyPatternTokenToml {
|
||||
token: None,
|
||||
any_of: Some(vec!["push".to_string(), "commit".to_string()]),
|
||||
},
|
||||
],
|
||||
decision: Some(RequirementsExecPolicyDecisionToml::Prompt),
|
||||
justification: Some("review changes before push or commit".to_string()),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1247,16 +1012,17 @@ prefix_rules = [
|
||||
#[test]
|
||||
fn converts_rules_toml_into_internal_policy_representation() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
[exec_policy]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }], decision = "forbidden" },
|
||||
]
|
||||
"#;
|
||||
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let policy = parsed.to_policy()?;
|
||||
let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?;
|
||||
let policy = parsed.exec_policy.to_policy()?;
|
||||
|
||||
assert_eq!(
|
||||
policy.check(&tokens(&["rm", "-rf", "/tmp"]), &panic_if_called),
|
||||
policy.check(&tokens(&["rm", "-rf", "/tmp"]), &allow_all),
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
@@ -1273,15 +1039,16 @@ prefix_rules = [
|
||||
#[test]
|
||||
fn head_any_of_expands_into_multiple_program_rules() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
[exec_policy]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ any_of = ["git", "hg"] }, { token = "status" }], decision = "prompt" },
|
||||
]
|
||||
"#;
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let policy = parsed.to_policy()?;
|
||||
let parsed: RequirementsExecPolicyTomlRoot = from_str(toml_str)?;
|
||||
let policy = parsed.exec_policy.to_policy()?;
|
||||
|
||||
assert_eq!(
|
||||
policy.check(&tokens(&["git", "status"]), &panic_if_called),
|
||||
policy.check(&tokens(&["git", "status"]), &allow_all),
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
@@ -1292,7 +1059,7 @@ prefix_rules = [
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
policy.check(&tokens(&["hg", "status"]), &panic_if_called),
|
||||
policy.check(&tokens(&["hg", "status"]), &allow_all),
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
@@ -1305,139 +1072,4 @@ prefix_rules = [
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_decision_is_rejected() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }] },
|
||||
]
|
||||
"#;
|
||||
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let err = parsed.to_policy().expect_err("missing decision");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
RequirementsExecPolicyParseError::MissingDecision { rule_index: 0 }
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_decision_is_rejected() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }], decision = "allow" },
|
||||
]
|
||||
"#;
|
||||
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let err = parsed.to_policy().expect_err("allow decision not allowed");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
RequirementsExecPolicyParseError::AllowDecisionNotAllowed { rule_index: 0 }
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_prefix_rules_is_rejected() -> anyhow::Result<()> {
|
||||
let toml_str = r#"
|
||||
prefix_rules = []
|
||||
"#;
|
||||
|
||||
let parsed: RequirementsExecPolicyToml = from_str(toml_str)?;
|
||||
let err = parsed.to_policy().expect_err("empty prefix rules");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
RequirementsExecPolicyParseError::EmptyPrefixRules
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_requirements_exec_policy_without_rules_files() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let requirements = requirements_from_toml(
|
||||
r#"
|
||||
[rules]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "rm" }], decision = "forbidden" },
|
||||
]
|
||||
"#,
|
||||
);
|
||||
let config_stack =
|
||||
config_stack_for_dot_codex_folder_with_requirements(temp_dir.path(), requirements);
|
||||
|
||||
let policy = load_exec_policy(&config_stack).await?;
|
||||
|
||||
assert_eq!(
|
||||
policy.check_multiple([vec!["rm".to_string()]].iter(), &panic_if_called),
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["rm".to_string()],
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_requirements_exec_policy_with_file_rules() -> anyhow::Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let policy_dir = temp_dir.path().join("rules");
|
||||
std::fs::create_dir_all(&policy_dir)?;
|
||||
std::fs::write(
|
||||
policy_dir.join("deny.rules"),
|
||||
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
|
||||
)?;
|
||||
|
||||
let requirements = requirements_from_toml(
|
||||
r#"
|
||||
[rules]
|
||||
prefix_rules = [
|
||||
{ pattern = [{ token = "git" }, { token = "push" }], decision = "prompt" },
|
||||
]
|
||||
"#,
|
||||
);
|
||||
let config_stack =
|
||||
config_stack_for_dot_codex_folder_with_requirements(temp_dir.path(), requirements);
|
||||
|
||||
let policy = load_exec_policy(&config_stack).await?;
|
||||
|
||||
assert_eq!(
|
||||
policy.check_multiple([vec!["rm".to_string()]].iter(), &panic_if_called),
|
||||
Evaluation {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["rm".to_string()],
|
||||
decision: Decision::Forbidden,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
policy.check_multiple(
|
||||
[vec!["git".to_string(), "push".to_string()]].iter(),
|
||||
&panic_if_called
|
||||
),
|
||||
Evaluation {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: vec!["git".to_string(), "push".to_string()],
|
||||
decision: Decision::Prompt,
|
||||
justification: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_channel::unbounded;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::AuthManager;
|
||||
@@ -14,13 +15,28 @@ use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectorInfo {
|
||||
#[serde(rename = "id")]
|
||||
pub connector_id: String,
|
||||
#[serde(rename = "name")]
|
||||
pub connector_name: String,
|
||||
#[serde(default, rename = "description")]
|
||||
pub connector_description: Option<String>,
|
||||
#[serde(default, rename = "logo_url")]
|
||||
pub logo_url: Option<String>,
|
||||
#[serde(default, rename = "install_url")]
|
||||
pub install_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_accessible: bool,
|
||||
}
|
||||
|
||||
pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
config: &Config,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
) -> anyhow::Result<Vec<ConnectorInfo>> {
|
||||
if !config.features.enabled(Feature::Connectors) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
@@ -56,13 +72,6 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
||||
let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
|
||||
mcp_connection_manager
|
||||
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
|
||||
.await;
|
||||
}
|
||||
|
||||
let tools = mcp_connection_manager.list_all_tools().await;
|
||||
cancel_token.cancel();
|
||||
|
||||
@@ -77,17 +86,13 @@ fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connector_display_label(connector: &AppInfo) -> String {
|
||||
format_connector_label(&connector.name, &connector.id)
|
||||
}
|
||||
|
||||
pub fn connector_mention_slug(connector: &AppInfo) -> String {
|
||||
connector_name_slug(&connector_display_label(connector))
|
||||
pub fn connector_display_label(connector: &ConnectorInfo) -> String {
|
||||
format_connector_label(&connector.connector_name, &connector.connector_id)
|
||||
}
|
||||
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
) -> Vec<ConnectorInfo> {
|
||||
let tools = mcp_tools.values().filter_map(|tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return None;
|
||||
@@ -100,37 +105,34 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
}
|
||||
|
||||
pub fn merge_connectors(
|
||||
connectors: Vec<AppInfo>,
|
||||
accessible_connectors: Vec<AppInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
let mut merged: HashMap<String, AppInfo> = connectors
|
||||
connectors: Vec<ConnectorInfo>,
|
||||
accessible_connectors: Vec<ConnectorInfo>,
|
||||
) -> Vec<ConnectorInfo> {
|
||||
let mut merged: HashMap<String, ConnectorInfo> = connectors
|
||||
.into_iter()
|
||||
.map(|mut connector| {
|
||||
connector.is_accessible = false;
|
||||
(connector.id.clone(), connector)
|
||||
(connector.connector_id.clone(), connector)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for mut connector in accessible_connectors {
|
||||
connector.is_accessible = true;
|
||||
let connector_id = connector.id.clone();
|
||||
let connector_id = connector.connector_id.clone();
|
||||
if let Some(existing) = merged.get_mut(&connector_id) {
|
||||
existing.is_accessible = true;
|
||||
if existing.name == existing.id && connector.name != connector.id {
|
||||
existing.name = connector.name;
|
||||
if existing.connector_name == existing.connector_id
|
||||
&& connector.connector_name != connector.connector_id
|
||||
{
|
||||
existing.connector_name = connector.connector_name;
|
||||
}
|
||||
if existing.description.is_none() && connector.description.is_some() {
|
||||
existing.description = connector.description;
|
||||
if existing.connector_description.is_none() && connector.connector_description.is_some()
|
||||
{
|
||||
existing.connector_description = connector.connector_description;
|
||||
}
|
||||
if existing.logo_url.is_none() && connector.logo_url.is_some() {
|
||||
existing.logo_url = connector.logo_url;
|
||||
}
|
||||
if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() {
|
||||
existing.logo_url_dark = connector.logo_url_dark;
|
||||
}
|
||||
if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() {
|
||||
existing.distribution_channel = connector.distribution_channel;
|
||||
}
|
||||
} else {
|
||||
merged.insert(connector_id, connector);
|
||||
}
|
||||
@@ -139,20 +141,23 @@ pub fn merge_connectors(
|
||||
let mut merged = merged.into_values().collect::<Vec<_>>();
|
||||
for connector in &mut merged {
|
||||
if connector.install_url.is_none() {
|
||||
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
|
||||
connector.install_url = Some(connector_install_url(
|
||||
&connector.connector_name,
|
||||
&connector.connector_id,
|
||||
));
|
||||
}
|
||||
}
|
||||
merged.sort_by(|left, right| {
|
||||
right
|
||||
.is_accessible
|
||||
.cmp(&left.is_accessible)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
.then_with(|| left.connector_name.cmp(&right.connector_name))
|
||||
.then_with(|| left.connector_id.cmp(&right.connector_id))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<ConnectorInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>)>,
|
||||
{
|
||||
@@ -167,16 +172,14 @@ where
|
||||
connectors.insert(connector_id, connector_name);
|
||||
}
|
||||
}
|
||||
let mut accessible: Vec<AppInfo> = connectors
|
||||
let mut accessible: Vec<ConnectorInfo> = connectors
|
||||
.into_iter()
|
||||
.map(|(connector_id, connector_name)| AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: connector_name.clone(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
.map(|(connector_id, connector_name)| ConnectorInfo {
|
||||
install_url: Some(connector_install_url(&connector_name, &connector_id)),
|
||||
connector_id,
|
||||
connector_name,
|
||||
connector_description: None,
|
||||
logo_url: None,
|
||||
is_accessible: true,
|
||||
})
|
||||
.collect();
|
||||
@@ -184,8 +187,8 @@ where
|
||||
right
|
||||
.is_accessible
|
||||
.cmp(&left.is_accessible)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
.then_with(|| left.connector_name.cmp(&right.connector_name))
|
||||
.then_with(|| left.connector_id.cmp(&right.connector_id))
|
||||
});
|
||||
accessible
|
||||
}
|
||||
@@ -202,7 +205,7 @@ pub fn connector_install_url(name: &str, connector_id: &str) -> String {
|
||||
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
|
||||
}
|
||||
|
||||
pub fn connector_name_slug(name: &str) -> String {
|
||||
fn connector_name_slug(name: &str) -> String {
|
||||
let mut normalized = String::with_capacity(name.len());
|
||||
for character in name.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
|
||||
@@ -88,7 +88,7 @@ impl ContextManager {
|
||||
let model_info = turn_context.client.get_model_info();
|
||||
let personality = turn_context
|
||||
.personality
|
||||
.or(turn_context.client.config().personality);
|
||||
.or(turn_context.client.config().model_personality);
|
||||
let base_instructions = model_info.get_model_instructions(personality);
|
||||
let base_tokens = i64::try_from(approx_token_count(&base_instructions)).unwrap_or(i64::MAX);
|
||||
|
||||
@@ -266,7 +266,7 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
fn process_item(&self, item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem {
|
||||
let policy_with_serialization_budget = policy * 1.2;
|
||||
let policy_with_serialization_budget = policy.mul(1.2);
|
||||
match item {
|
||||
ResponseItem::FunctionCallOutput { call_id, output } => {
|
||||
let truncated =
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use crate::config_loader::ResidencyRequirement;
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use codex_client::CodexHttpClient;
|
||||
pub use codex_client::CodexRequestBuilder;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderValue;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex;
|
||||
@@ -26,7 +24,6 @@ use std::sync::RwLock;
|
||||
pub static USER_AGENT_SUFFIX: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
|
||||
pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
|
||||
pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE";
|
||||
pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Originator {
|
||||
@@ -34,8 +31,6 @@ pub struct Originator {
|
||||
pub header_value: HeaderValue,
|
||||
}
|
||||
static ORIGINATOR: LazyLock<RwLock<Option<Originator>>> = LazyLock::new(|| RwLock::new(None));
|
||||
static REQUIREMENTS_RESIDENCY: LazyLock<RwLock<Option<ResidencyRequirement>>> =
|
||||
LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SetOriginatorError {
|
||||
@@ -79,14 +74,6 @@ pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_default_client_residency_requirement(enforce_residency: Option<ResidencyRequirement>) {
|
||||
let Ok(mut guard) = REQUIREMENTS_RESIDENCY.write() else {
|
||||
tracing::warn!("Failed to acquire requirements residency lock");
|
||||
return;
|
||||
};
|
||||
*guard = enforce_residency;
|
||||
}
|
||||
|
||||
pub fn originator() -> Originator {
|
||||
if let Ok(guard) = ORIGINATOR.read()
|
||||
&& let Some(originator) = guard.as_ref()
|
||||
@@ -179,17 +166,10 @@ pub fn create_client() -> CodexHttpClient {
|
||||
}
|
||||
|
||||
pub fn build_reqwest_client() -> reqwest::Client {
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("originator", originator().header_value);
|
||||
if let Ok(guard) = REQUIREMENTS_RESIDENCY.read()
|
||||
&& let Some(requirement) = guard.as_ref()
|
||||
&& !headers.contains_key(RESIDENCY_HEADER_NAME)
|
||||
{
|
||||
let value = match requirement {
|
||||
ResidencyRequirement::Us => HeaderValue::from_static("us"),
|
||||
};
|
||||
headers.insert(RESIDENCY_HEADER_NAME, value);
|
||||
}
|
||||
let ua = get_codex_user_agent();
|
||||
|
||||
let mut builder = reqwest::Client::builder()
|
||||
@@ -234,8 +214,6 @@ mod tests {
|
||||
async fn test_create_client_sets_default_headers() {
|
||||
skip_if_no_network!();
|
||||
|
||||
set_default_client_residency_requirement(Some(ResidencyRequirement::Us));
|
||||
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -278,13 +256,6 @@ mod tests {
|
||||
.get("user-agent")
|
||||
.expect("user-agent header missing");
|
||||
assert_eq!(ua_header.to_str().unwrap(), expected_ua);
|
||||
|
||||
let residency_header = headers
|
||||
.get(RESIDENCY_HEADER_NAME)
|
||||
.expect("residency header missing");
|
||||
assert_eq!(residency_header.to_str().unwrap(), "us");
|
||||
|
||||
set_default_client_residency_requirement(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -113,9 +113,6 @@ pub enum CodexErr {
|
||||
#[error("{0}")]
|
||||
UsageLimitReached(UsageLimitReachedError),
|
||||
|
||||
#[error("{0}")]
|
||||
ModelCap(ModelCapError),
|
||||
|
||||
#[error("{0}")]
|
||||
ResponseStreamFailed(ResponseStreamFailed),
|
||||
|
||||
@@ -126,7 +123,7 @@ pub enum CodexErr {
|
||||
QuotaExceeded,
|
||||
|
||||
#[error(
|
||||
"To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus."
|
||||
"To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing."
|
||||
)]
|
||||
UsageNotIncluded,
|
||||
|
||||
@@ -208,8 +205,7 @@ impl CodexErr {
|
||||
| CodexErr::AgentLimitReached { .. }
|
||||
| CodexErr::Spawn
|
||||
| CodexErr::SessionConfiguredNotFirstEvent
|
||||
| CodexErr::UsageLimitReached(_)
|
||||
| CodexErr::ModelCap(_) => false,
|
||||
| CodexErr::UsageLimitReached(_) => false,
|
||||
CodexErr::Stream(..)
|
||||
| CodexErr::Timeout
|
||||
| CodexErr::UnexpectedStatus(_)
|
||||
@@ -360,22 +356,13 @@ pub struct UsageLimitReachedError {
|
||||
pub(crate) plan_type: Option<PlanType>,
|
||||
pub(crate) resets_at: Option<DateTime<Utc>>,
|
||||
pub(crate) rate_limits: Option<RateLimitSnapshot>,
|
||||
pub(crate) promo_message: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UsageLimitReachedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(promo_message) = &self.promo_message {
|
||||
return write!(
|
||||
f,
|
||||
"You've hit your usage limit. {promo_message},{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
);
|
||||
}
|
||||
|
||||
let message = match self.plan_type.as_ref() {
|
||||
Some(PlanType::Known(KnownPlan::Plus)) => format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => {
|
||||
@@ -384,11 +371,9 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
)
|
||||
}
|
||||
Some(PlanType::Known(KnownPlan::Free)) | Some(PlanType::Known(KnownPlan::Go)) => {
|
||||
format!(
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
)
|
||||
Some(PlanType::Known(KnownPlan::Free)) => {
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing)."
|
||||
.to_string()
|
||||
}
|
||||
Some(PlanType::Known(KnownPlan::Pro)) => format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
@@ -409,30 +394,6 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModelCapError {
|
||||
pub(crate) model: String,
|
||||
pub(crate) reset_after_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ModelCapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut message = format!(
|
||||
"Model {} is at capacity. Please try a different model.",
|
||||
self.model
|
||||
);
|
||||
if let Some(seconds) = self.reset_after_seconds {
|
||||
message.push_str(&format!(
|
||||
" Try again in {}.",
|
||||
format_duration_short(seconds)
|
||||
));
|
||||
} else {
|
||||
message.push_str(" Try again later.");
|
||||
}
|
||||
write!(f, "{message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_suffix(resets_at: Option<&DateTime<Utc>>) -> String {
|
||||
if let Some(resets_at) = resets_at {
|
||||
let formatted = format_retry_timestamp(resets_at);
|
||||
@@ -464,18 +425,6 @@ fn format_retry_timestamp(resets_at: &DateTime<Utc>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration_short(seconds: u64) -> String {
|
||||
if seconds < 60 {
|
||||
"less than a minute".to_string()
|
||||
} else if seconds < 3600 {
|
||||
format!("{}m", seconds / 60)
|
||||
} else if seconds < 86_400 {
|
||||
format!("{}h", seconds / 3600)
|
||||
} else {
|
||||
format!("{}d", seconds / 86_400)
|
||||
}
|
||||
}
|
||||
|
||||
fn day_suffix(day: u32) -> &'static str {
|
||||
match day {
|
||||
11..=13 => "th",
|
||||
@@ -539,10 +488,6 @@ impl CodexErr {
|
||||
CodexErr::UsageLimitReached(_)
|
||||
| CodexErr::QuotaExceeded
|
||||
| CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CodexErr::ModelCap(err) => CodexErrorInfo::ModelCap {
|
||||
model: err.model.clone(),
|
||||
reset_after_seconds: err.reset_after_seconds,
|
||||
},
|
||||
CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
|
||||
http_status_code: self.http_status_code_value(),
|
||||
},
|
||||
@@ -679,50 +624,10 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_cap_error_formats_message() {
|
||||
let err = ModelCapError {
|
||||
model: "boomslang".to_string(),
|
||||
reset_after_seconds: Some(120),
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Model boomslang is at capacity. Please try a different model. Try again in 2m."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_cap_error_formats_message_without_reset() {
|
||||
let err = ModelCapError {
|
||||
model: "boomslang".to_string(),
|
||||
reset_after_seconds: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Model boomslang is at capacity. Please try a different model. Try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_cap_error_maps_to_protocol() {
|
||||
let err = CodexErr::ModelCap(ModelCapError {
|
||||
model: "boomslang".to_string(),
|
||||
reset_after_seconds: Some(30),
|
||||
});
|
||||
assert_eq!(
|
||||
err.to_codex_protocol_error(),
|
||||
CodexErrorInfo::ModelCap {
|
||||
model: "boomslang".to_string(),
|
||||
reset_after_seconds: Some(30),
|
||||
}
|
||||
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -826,25 +731,10 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Free)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_go_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Go)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later."
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing)."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -854,7 +744,6 @@ mod tests {
|
||||
plan_type: None,
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -872,7 +761,6 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Team)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
|
||||
@@ -887,7 +775,6 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Business)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -901,7 +788,6 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Enterprise)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -919,7 +805,6 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
@@ -938,7 +823,6 @@ mod tests {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -990,10 +874,9 @@ mod tests {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
"You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
@@ -1010,7 +893,6 @@ mod tests {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -1027,31 +909,9 @@ mod tests {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_with_promo_message() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::seconds(30);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(rate_limit_snapshot()),
|
||||
promo_message: Some(
|
||||
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
|
||||
),
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +426,6 @@ mod tests {
|
||||
status: Some("completed".to_string()),
|
||||
action: Some(WebSearchAction::Search {
|
||||
query: Some("weather".to_string()),
|
||||
queries: None,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -440,7 +439,6 @@ mod tests {
|
||||
query: "weather".to_string(),
|
||||
action: WebSearchAction::Search {
|
||||
query: Some("weather".to_string()),
|
||||
queries: None,
|
||||
},
|
||||
}
|
||||
),
|
||||
|
||||
@@ -232,72 +232,6 @@ pub(crate) async fn execute_exec_env(
|
||||
finalize_exec_result(raw_output_result, sandbox, duration)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn extract_create_process_as_user_error_code(err: &str) -> Option<String> {
|
||||
let marker = "CreateProcessAsUserW failed: ";
|
||||
let start = err.find(marker)? + marker.len();
|
||||
let tail = &err[start..];
|
||||
let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
|
||||
if digits.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(digits)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windowsapps_path_kind(path: &str) -> &'static str {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
if lower.contains("\\program files\\windowsapps\\") {
|
||||
return "windowsapps_package";
|
||||
}
|
||||
if lower.contains("\\appdata\\local\\microsoft\\windowsapps\\") {
|
||||
return "windowsapps_alias";
|
||||
}
|
||||
if lower.contains("\\windowsapps\\") {
|
||||
return "windowsapps_other";
|
||||
}
|
||||
"other"
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn record_windows_sandbox_spawn_failure(
|
||||
command_path: Option<&str>,
|
||||
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
|
||||
err: &str,
|
||||
) {
|
||||
let Some(error_code) = extract_create_process_as_user_error_code(err) else {
|
||||
return;
|
||||
};
|
||||
let path = command_path.unwrap_or("unknown");
|
||||
let exe = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_ascii_lowercase();
|
||||
let path_kind = windowsapps_path_kind(path);
|
||||
let level = if matches!(
|
||||
windows_sandbox_level,
|
||||
codex_protocol::config_types::WindowsSandboxLevel::Elevated
|
||||
) {
|
||||
"elevated"
|
||||
} else {
|
||||
"legacy"
|
||||
};
|
||||
if let Some(metrics) = codex_otel::metrics::global() {
|
||||
let _ = metrics.counter(
|
||||
"codex.windows_sandbox.createprocessasuserw_failed",
|
||||
1,
|
||||
&[
|
||||
("error_code", error_code.as_str()),
|
||||
("path_kind", path_kind),
|
||||
("exe", exe.as_str()),
|
||||
("level", level),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn exec_windows_sandbox(
|
||||
params: ExecParams,
|
||||
@@ -331,9 +265,7 @@ async fn exec_windows_sandbox(
|
||||
"windows sandbox: failed to resolve codex_home: {err}"
|
||||
)))
|
||||
})?;
|
||||
let command_path = command.first().cloned();
|
||||
let sandbox_level = windows_sandbox_level;
|
||||
let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated);
|
||||
let use_elevated = matches!(windows_sandbox_level, WindowsSandboxLevel::Elevated);
|
||||
let spawn_res = tokio::task::spawn_blocking(move || {
|
||||
if use_elevated {
|
||||
run_windows_sandbox_capture_elevated(
|
||||
@@ -362,11 +294,6 @@ async fn exec_windows_sandbox(
|
||||
let capture = match spawn_res {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(err)) => {
|
||||
record_windows_sandbox_spawn_failure(
|
||||
command_path.as_deref(),
|
||||
sandbox_level,
|
||||
&err.to_string(),
|
||||
);
|
||||
return Err(CodexErr::Io(io::Error::other(format!(
|
||||
"windows sandbox: {err}"
|
||||
))));
|
||||
|
||||
@@ -280,18 +280,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
|
||||
let policy = parser.build();
|
||||
tracing::debug!("loaded rules from {} files", policy_paths.len());
|
||||
|
||||
let Some(requirements_policy) = config_stack.requirements().exec_policy.as_deref() else {
|
||||
return Ok(policy);
|
||||
};
|
||||
|
||||
let mut combined_rules = policy.rules().clone();
|
||||
for (program, rules) in requirements_policy.as_ref().rules().iter_all() {
|
||||
for rule in rules {
|
||||
combined_rules.insert(program.clone(), rule.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Policy::new(combined_rules))
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
|
||||
|
||||
@@ -101,8 +101,6 @@ pub enum Feature {
|
||||
RemoteModels,
|
||||
/// Experimental shell snapshotting.
|
||||
ShellSnapshot,
|
||||
/// Enable runtime metrics snapshots via a manual reader.
|
||||
RuntimeMetrics,
|
||||
/// Persist rollout metadata to a local SQLite database.
|
||||
Sqlite,
|
||||
/// Append additional AGENTS.md guidance to user instructions.
|
||||
@@ -113,18 +111,14 @@ pub enum Feature {
|
||||
EnableRequestCompression,
|
||||
/// Enable collab tools.
|
||||
Collab,
|
||||
/// Enable apps.
|
||||
Apps,
|
||||
/// Enable connectors (apps).
|
||||
Connectors,
|
||||
/// Allow prompting and installing missing MCP dependencies.
|
||||
SkillMcpDependencyInstall,
|
||||
/// Prompt for missing skill env var dependencies.
|
||||
SkillEnvVarDependencyPrompt,
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
Steer,
|
||||
/// Enable collaboration modes (Plan, Code, Pair Programming, Execute).
|
||||
CollaborationModes,
|
||||
/// Enable personality selection in the TUI.
|
||||
Personality,
|
||||
/// Use the Responses API WebSocket transport for OpenAI by default.
|
||||
ResponsesWebsockets,
|
||||
}
|
||||
@@ -439,12 +433,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RuntimeMetrics,
|
||||
key: "runtime_metrics",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Sqlite,
|
||||
key: "sqlite",
|
||||
@@ -472,8 +460,8 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::RequestRule,
|
||||
key: "request_rule",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WindowsSandbox,
|
||||
@@ -503,7 +491,11 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::PowershellUtf8,
|
||||
key: "powershell_utf8",
|
||||
#[cfg(windows)]
|
||||
stage: Stage::Stable,
|
||||
stage: Stage::Experimental {
|
||||
name: "Powershell UTF-8 support",
|
||||
menu_description: "Enable UTF-8 output in Powershell.",
|
||||
announcement: "Codex now supports UTF-8 output in Powershell. If you are seeing problems, disable in /experimental.",
|
||||
},
|
||||
#[cfg(windows)]
|
||||
default_enabled: true,
|
||||
#[cfg(not(windows))]
|
||||
@@ -524,13 +516,9 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Apps,
|
||||
key: "apps",
|
||||
stage: Stage::Experimental {
|
||||
name: "Apps",
|
||||
menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.",
|
||||
announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!",
|
||||
},
|
||||
id: Feature::Connectors,
|
||||
key: "connectors",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
@@ -539,12 +527,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::SkillEnvVarDependencyPrompt,
|
||||
key: "skill_env_var_dependency_prompt",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Steer,
|
||||
key: "steer",
|
||||
@@ -558,14 +540,8 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
FeatureSpec {
|
||||
id: Feature::CollaborationModes,
|
||||
key: "collaboration_modes",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Personality,
|
||||
key: "personality",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ResponsesWebsockets,
|
||||
|
||||
@@ -9,10 +9,6 @@ struct Alias {
|
||||
}
|
||||
|
||||
const ALIASES: &[Alias] = &[
|
||||
Alias {
|
||||
legacy_key: "connectors",
|
||||
feature: Feature::Apps,
|
||||
},
|
||||
Alias {
|
||||
legacy_key: "enable_experimental_windows_sandbox",
|
||||
feature: Feature::WindowsSandbox,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
mod analytics_client;
|
||||
pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
pub mod auth;
|
||||
@@ -39,23 +38,18 @@ pub mod landlock;
|
||||
pub mod mcp;
|
||||
mod mcp_connection_manager;
|
||||
pub mod models_manager;
|
||||
mod transport_manager;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
|
||||
pub use mcp_connection_manager::SandboxState;
|
||||
mod mcp_tool_call;
|
||||
mod mentions;
|
||||
mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod parse_command;
|
||||
pub mod path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod powershell;
|
||||
mod proposed_plan_parser;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod stream_events_utils;
|
||||
mod tagged_block_parser;
|
||||
mod text_encoding;
|
||||
pub mod token_data;
|
||||
mod truncate;
|
||||
@@ -104,14 +98,12 @@ pub mod turn_diff_tracker;
|
||||
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
pub use rollout::RolloutRecorder;
|
||||
pub use rollout::RolloutRecorderParams;
|
||||
pub use rollout::SESSIONS_SUBDIR;
|
||||
pub use rollout::SessionMeta;
|
||||
pub use rollout::find_archived_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use rollout::find_conversation_path_by_id_str;
|
||||
pub use rollout::find_thread_path_by_id_str;
|
||||
pub use rollout::find_thread_path_by_name_str;
|
||||
pub use rollout::list::Cursor;
|
||||
pub use rollout::list::ThreadItem;
|
||||
pub use rollout::list::ThreadSortKey;
|
||||
@@ -120,7 +112,6 @@ pub use rollout::list::parse_cursor;
|
||||
pub use rollout::list::read_head_for_summary;
|
||||
pub use rollout::list::read_session_meta_line;
|
||||
pub use rollout::rollout_date_parts;
|
||||
pub use transport_manager::TransportManager;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
|
||||
@@ -6,7 +6,6 @@ pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::unbounded;
|
||||
use codex_protocol::protocol::McpListToolsResponseEvent;
|
||||
@@ -26,7 +25,7 @@ use crate::mcp_connection_manager::SandboxState;
|
||||
|
||||
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
||||
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
||||
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps_mcp";
|
||||
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
|
||||
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
|
||||
@@ -98,7 +97,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(30)),
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
@@ -129,7 +128,7 @@ pub(crate) fn effective_mcp_servers(
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
with_codex_apps_mcp(
|
||||
config.mcp_servers.get().clone(),
|
||||
config.features.enabled(Feature::Apps),
|
||||
config.features.enabled(Feature::Connectors),
|
||||
auth,
|
||||
config,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user