Compare commits

..

5 Commits

Author SHA1 Message Date
Ahmed Ibrahim
9b7329699a Merge branch 'fix-timeout' of https://github.com/openai/codex into fix-timeout 2025-11-06 10:34:20 -08:00
Ahmed Ibrahim
8307d9bf3b fix 2025-11-06 10:34:11 -08:00
Ahmed Ibrahim
294dafcacf Merge branch 'main' into fix-timeout 2025-11-06 10:29:56 -08:00
Ahmed Ibrahim
0b26c76047 fix 2025-11-06 10:27:24 -08:00
Ahmed Ibrahim
ca9f9c6f5d usage 2025-11-06 09:25:43 -08:00
122 changed files with 1523 additions and 4182 deletions

View File

@@ -4,5 +4,3 @@ Before opening this Pull Request, please read the dedicated "Contributing" markd
https://github.com/openai/codex/blob/main/docs/contributing.md
If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes.
Include a link to a bug report or enhancement request.

View File

@@ -16,27 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: contributor-assistant/github-action@v2.6.1
# Run on close only if the PR was merged. This will lock the PR to preserve
# the CLA agreement. We don't want to lock PRs that have been closed without
# merging because the contributor may want to respond with additional comments.
# This action has a "lock-pullrequest-aftermerge" option that can be set to false,
# but that would unconditionally skip locking even in cases where the PR was merged.
if: |
(
github.event_name == 'pull_request_target' &&
(
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
(github.event.action == 'closed' && github.event.pull_request.merged == true)
)
) ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'
)
)
github.event_name == 'pull_request_target' ||
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@@ -22,6 +22,6 @@ jobs:
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
- name: Codespell
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1
with:
ignore_words_file: .codespellignore

View File

@@ -26,36 +26,21 @@ jobs:
prompt: |
You are an assistant that reviews GitHub issues for the repository.
Your job is to choose the most appropriate labels for the issue described later in this prompt.
Your job is to choose the most appropriate existing labels for the issue described later in this prompt.
Follow these rules:
- Only pick labels out of the list below.
- Prefer a small set of precise labels over many broad ones.
- Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure.
Labels to apply:
1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth).
2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks.
3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
- If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to.
1. CLI — the Codex command line interface.
2. extension — VS Code (or other IDE) extension-specific issues.
3. codex-web — Issues targeting the Codex web UI/Cloud experience.
4. github-actionIssues with the Codex GitHub action.
5. iOS — Issues with the Codex iOS app.
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
2. mcp — Topics involving Model Context Protocol servers/clients.
3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server.
4. azure — Problems or requests tied to Azure OpenAI deployments.
5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
6. code-review — Issues related to the code review feature or functionality.
7. auth - Problems related to authentication, login, or access tokens.
8. codex-exec - Problems related to the "codex exec" command or functionality.
9. context-management - Problems related to compaction, context windows, or available context reporting.
10. custom-model - Problems that involve using custom model providers, local models, or OSS models.
11. rate-limits - Problems related to token limits, rate limits, or token usage reporting.
12. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions.
13. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs.
14. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues.
3. extension — VS Code (or other IDE) extension-specific issues.
4. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
5. mcp — Topics involving Model Context Protocol servers/clients.
6. codex-web — Issues targeting the Codex web UI/Cloud experience.
8. azure — Problems or requests tied to Azure OpenAI deployments.
9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
10. model-behaviorUndesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
Issue number: ${{ github.event.issue.number }}

View File

@@ -76,7 +76,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.90
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: cargo-shear
version: 1.5.1
@@ -170,7 +170,7 @@ jobs:
# Install and restore sccache cache
- name: Install sccache
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: sccache
version: 0.7.5
@@ -228,7 +228,7 @@ jobs:
- name: Install cargo-chef
if: ${{ matrix.profile == 'release' }}
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: cargo-chef
version: 0.1.71
@@ -370,7 +370,7 @@ jobs:
cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- name: Install sccache
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: sccache
version: 0.7.5
@@ -399,7 +399,7 @@ jobs:
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
with:
tool: nextest
version: 0.9.103

View File

@@ -84,7 +84,6 @@ If you dont have the tool:
- Use `ResponseMock::single_request()` when a test should only issue one POST, or `ResponseMock::requests()` to inspect every captured `ResponsesRequest`.
- `ResponsesRequest` exposes helpers (`body_json`, `input`, `function_call_output`, `custom_tool_call_output`, `call_output`, `header`, `path`, `query_param`) so assertions can target structured payloads instead of manual JSON digging.
- Build SSE payloads with the provided `ev_*` constructors and the `sse(...)`.
- Prefer `wait_for_event` over `wait_for_event_with_timeout`.
- Typical pattern:

81
codex-rs/Cargo.lock generated
View File

@@ -237,44 +237,46 @@ dependencies = [
[[package]]
name = "askama"
version = "0.14.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
dependencies = [
"askama_derive",
"itoa",
"askama_escape",
"humansize",
"num-traits",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.14.0"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"mime",
"mime_guess",
"proc-macro2",
"quote",
"rustc-hash 2.1.1",
"serde",
"serde_derive",
"syn 2.0.104",
]
[[package]]
name = "askama_parser"
version = "0.14.0"
name = "askama_escape"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
[[package]]
name = "askama_parser"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow",
"nom",
]
[[package]]
@@ -979,6 +981,7 @@ dependencies = [
"codex-mcp-server",
"codex-process-hardening",
"codex-protocol",
"codex-protocol-ts",
"codex-responses-api-proxy",
"codex-rmcp-client",
"codex-stdio-to-uds",
@@ -1362,6 +1365,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-protocol-ts"
version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"ts-rs",
]
[[package]]
name = "codex-responses-api-proxy"
version = "0.0.0"
@@ -1439,10 +1452,8 @@ dependencies = [
"codex-login",
"codex-ollama",
"codex-protocol",
"codex-windows-sandbox",
"color-eyre",
"crossterm",
"derive_more 2.0.1",
"diffy",
"dirs",
"dunce",
@@ -1553,7 +1564,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"dirs-next",
"dunce",
"rand 0.8.5",
"serde",
"serde_json",
@@ -1647,15 +1657,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -2001,7 +2002,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"convert_case 0.6.0",
"convert_case",
"proc-macro2",
"quote",
"syn 2.0.104",
@@ -2014,7 +2015,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"convert_case 0.7.1",
"proc-macro2",
"quote",
"syn 2.0.104",
@@ -2878,6 +2878,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.7.0"
@@ -3521,6 +3530,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.6"
@@ -7776,9 +7791,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]

View File

@@ -25,6 +25,7 @@ members = [
"ollama",
"process-hardening",
"protocol",
"protocol-ts",
"rmcp-client",
"responses-api-proxy",
"stdio-to-uds",
@@ -74,6 +75,7 @@ codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-protocol-ts = { path = "protocol-ts" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-rmcp-client = { path = "rmcp-client" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
@@ -85,7 +87,7 @@ codex-utils-pty = { path = "utils/pty" }
codex-utils-readiness = { path = "utils/readiness" }
codex-utils-string = { path = "utils/string" }
codex-utils-tokenizer = { path = "utils/tokenizer" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
codex-windows-sandbox = { path = "windows-sandbox" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
mcp_test_support = { path = "mcp-server/tests/common" }
@@ -95,7 +97,7 @@ allocative = "0.3.3"
ansi-to-tui = "7.0.0"
anyhow = "1"
arboard = "3"
askama = "0.14"
askama = "0.12"
assert_cmd = "2"
assert_matches = "1.5.0"
async-channel = "2.3.1"
@@ -211,7 +213,7 @@ which = "6"
wildmatch = "2.5.0"
wiremock = "0.6"
zeroize = "1.8.2"
zeroize = "1.8.1"
[workspace.lints]
rust = {}

View File

@@ -666,8 +666,6 @@ fn ts_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
Ok(files)
}
/// Generate an index.ts file that re-exports all generated types.
/// This allows consumers to import all types from a single file.
fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
let mut entries: Vec<String> = Vec::new();
let mut stems: Vec<String> = ts_files_in(out_dir)?

View File

@@ -191,7 +191,7 @@ client_request_definitions! {
#[serde(rename = "account/read")]
#[ts(rename = "account/read")]
GetAccount {
params: v2::GetAccountParams,
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
response: v2::GetAccountResponse,
},
@@ -263,7 +263,6 @@ client_request_definitions! {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
response: v1::LogoutChatGptResponse,
},
/// DEPRECATED in favor of GetAccount
GetAuthStatus {
params: v1::GetAuthStatusParams,
response: v1::GetAuthStatusResponse,
@@ -759,17 +758,12 @@ mod tests {
fn serialize_get_account() -> Result<()> {
let request = ClientRequest::GetAccount {
request_id: RequestId::Integer(5),
params: v2::GetAccountParams {
refresh_token: false,
},
params: None,
};
assert_eq!(
json!({
"method": "account/read",
"id": 5,
"params": {
"refreshToken": false
}
}),
serde_json::to_value(&request)?,
);
@@ -778,16 +772,19 @@ mod tests {
#[test]
fn account_serializes_fields_in_camel_case() -> Result<()> {
let api_key = v2::Account::ApiKey {};
let api_key = v2::Account::ApiKey {
api_key: "secret".to_string(),
};
assert_eq!(
json!({
"type": "apiKey",
"apiKey": "secret",
}),
serde_json::to_value(&api_key)?,
);
let chatgpt = v2::Account::Chatgpt {
email: "user@example.com".to_string(),
email: Some("user@example.com".to_string()),
plan_type: PlanType::Plus,
};
assert_eq!(

View File

@@ -11,7 +11,6 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnAbortReason;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -114,18 +113,6 @@ pub struct ConversationSummary {
pub preview: String,
pub timestamp: Option<String>,
pub model_provider: String,
pub cwd: PathBuf,
pub cli_version: String,
pub source: SessionSource,
pub git_info: Option<ConversationGitInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub struct ConversationGitInfo {
pub sha: Option<String>,
pub branch: Option<String>,
pub origin_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -123,11 +123,14 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
pub enum Account {
#[serde(rename = "apiKey", rename_all = "camelCase")]
#[ts(rename = "apiKey", rename_all = "camelCase")]
ApiKey {},
ApiKey { api_key: String },
#[serde(rename = "chatgpt", rename_all = "camelCase")]
#[ts(rename = "chatgpt", rename_all = "camelCase")]
Chatgpt { email: String, plan_type: PlanType },
Chatgpt {
email: Option<String>,
plan_type: PlanType,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -190,20 +193,11 @@ pub struct GetAccountRateLimitsResponse {
pub rate_limits: RateLimitSnapshot,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountParams {
#[serde(default)]
pub refresh_token: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountResponse {
pub account: Option<Account>,
pub requires_openai_auth: bool,
pub account: Account,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
@@ -354,11 +348,6 @@ pub struct ThreadCompactResponse {}
#[ts(export_to = "v2/")]
pub struct Thread {
pub id: String,
/// Usually the first user message in the thread, if available.
pub preview: String,
pub model_provider: String,
/// Unix timestamp (in seconds) when the thread was created.
pub created_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View File

@@ -1,6 +1,6 @@
# codex-app-server
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable.
`codex app-server` is the harness Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable.
## Protocol
@@ -8,253 +8,8 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
## Message Schema
Currently, you can dump a TypeScript version of the schema using `codex app-server generate-ts`, or a JSON Schema bundle via `codex app-server generate-json-schema`. Each output is specific to the version of Codex you used to run the command, so the generated artifacts are guaranteed to match that version.
Currently, you can dump a TypeScript version of the schema using `codex generate-ts`. It is specific to the version of Codex you used to run `generate-ts`, so the two are guaranteed to be compatible.
```
codex app-server generate-ts --out DIR
codex app-server generate-json-schema --out DIR
codex generate-ts --out DIR
```
## Initialization
Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error.
Example:
```json
{ "method": "initialize", "id": 0, "params": {
"clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" }
} }
{ "id": 0, "result": { "userAgent": "codex-app-server/0.1.0 codex-vscode/0.1.0" } }
{ "method": "initialized" }
```
## Core primitives
We have 3 top level primitives:
- Thread - a conversation between the Codex agent and a user. Each thread contains multiple turns.
- Turn - one turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items.
- Item - represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations.
## Thread & turn endpoints
The JSON-RPC API exposes dedicated methods for managing Codex conversations. Threads store long-lived conversation metadata, and turns store the per-message exchange (input → Codex output, including streamed items). Use the thread APIs to create, list, or archive sessions, then drive the conversation with turn APIs and notifications.
### Quick reference
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
- `thread/archive` — move a threads rollout file into the archived directory; returns `{}` 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.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
### 1) Start or resume a thread
Start a fresh thread when you need a new Codex conversation.
```json
{ "method": "thread/start", "id": 10, "params": {
// Optionally set config settings. If not specified, will use the user's
// current config settings.
"model": "gpt-5-codex",
"cwd": "/Users/me/project",
"approvalPolicy": "never",
"sandbox": "workspaceWrite",
} }
{ "id": 10, "result": {
"thread": {
"id": "thr_123",
"preview": "",
"modelProvider": "openai",
"createdAt": 1730910000
}
} }
{ "method": "thread/started", "params": { "thread": { } } }
```
To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted:
```json
{ "method": "thread/resume", "id": 11, "params": { "threadId": "thr_123" } }
{ "id": 11, "result": { "thread": { "id": "thr_123", } } }
```
### 2) List threads (pagination & filters)
`thread/list` lets you render a history UI. Pass any combination of:
- `cursor` — opaque string from a prior response; omit for the first page.
- `limit` — server defaults to a reasonable page size if unset.
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
Example:
```json
{ "method": "thread/list", "id": 20, "params": {
"cursor": null,
"limit": 25,
} }
{ "id": 20, "result": {
"data": [
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111 },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000 }
],
"nextCursor": "opaque-token-or-null"
} }
```
When `nextCursor` is `null`, youve reached the final page.
### 3) Archive a thread
Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory.
```json
{ "method": "thread/archive", "id": 21, "params": { "threadId": "thr_b" } }
{ "id": 21, "result": {} }
```
An archived thread will not appear in future calls to `thread/list`.
### 4) Start a turn (send user input)
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
- `{"type":"text","text":"Explain this diff"}`
- `{"type":"image","url":"https://…png"}`
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread.
```json
{ "method": "turn/start", "id": 30, "params": {
"threadId": "thr_123",
"input": [ { "type": "text", "text": "Run tests" } ],
// Below are optional config overrides
"cwd": "/Users/me/project",
"approvalPolicy": "unlessTrusted",
"sandboxPolicy": {
"mode": "workspaceWrite",
"writableRoots": ["/Users/me/project"],
"networkAccess": true
},
"model": "gpt-5-codex",
"effort": "medium",
"summary": "concise"
} }
{ "id": 30, "result": { "turn": {
"id": "turn_456",
"status": "inProgress",
"items": [],
"error": null
} } }
```
### 5) Interrupt an active turn
You can cancel a running Turn with `turn/interrupt`.
```json
{ "method": "turn/interrupt", "id": 31, "params": {
"threadId": "thr_123",
"turnId": "turn_456"
} }
{ "id": 31, "result": {} }
```
The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done.
## 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.
### Quick reference
- `account/read` — fetch current account info; optionally refresh tokens.
- `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`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`).
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
### 1) Check auth state
Request:
```json
{ "method": "account/read", "id": 1, "params": { "refreshToken": false } }
```
Response examples:
```json
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } }
```
Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
### 2) Log in with an API key
1. Send:
```json
{ "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } }
```
2. Expect:
```json
{ "id": 2, "result": { "type": "apiKey" } }
```
3. Notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "apikey" } }
```
### 3) Log in with ChatGPT (browser flow)
1. Start:
```json
{ "method": "account/login/start", "id": 3, "params": { "type": "chatgpt" } }
{ "id": 3, "result": { "type": "chatgpt", "loginId": "<uuid>", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A<port>%2Fauth%2Fcallback" } }
```
2. Open `authUrl` in a browser; the app-server hosts the local callback.
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt" } }
```
### 4) Cancel a ChatGPT login
```json
{ "method": "account/login/cancel", "id": 4, "params": { "loginId": "<uuid>" } }
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": false, "error": "…" } }
```
### 5) Logout
```json
{ "method": "account/logout", "id": 5 }
{ "id": 5, "result": {} }
{ "method": "account/updated", "params": { "authMode": null } }
```
### 6) Rate limits (ChatGPT)
```json
{ "method": "account/rateLimits/read", "id": 6 }
{ "id": 6, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } }
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
Field notes:
- `usedPercent` is current usage within the OpenAI quota window.
- `windowDurationMins` is the quota window length.
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
### Dev notes
- `codex app-server generate-ts --out <dir>` emits v2 types under `v2/`.
- `codex app-server generate-json-schema --out <dir>` outputs `codex_app_server_protocol.schemas.json`.
- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs.

View File

@@ -4,9 +4,6 @@ use crate::fuzzy_file_search::run_fuzzy_file_search;
use crate::models::supported_models;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
@@ -23,7 +20,6 @@ use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginChatGptResponse;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::ExecCommandApprovalParams;
use codex_app_server_protocol::ExecCommandApprovalResponse;
@@ -33,9 +29,7 @@ use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::FuzzyFileSearchParams;
use codex_app_server_protocol::FuzzyFileSearchResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams;
@@ -136,10 +130,8 @@ use codex_protocol::ConversationId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::user_input::UserInput as CoreInputItem;
use codex_utils_json_to_toml::json_to_toml;
@@ -278,8 +270,12 @@ impl CodexMessageProcessor {
ClientRequest::CancelLoginAccount { request_id, params } => {
self.cancel_login_v2(request_id, params).await;
}
ClientRequest::GetAccount { request_id, params } => {
self.get_account(request_id, params).await;
ClientRequest::GetAccount {
request_id,
params: _,
} => {
self.send_unimplemented_error(request_id, "account/read")
.await;
}
ClientRequest::ResumeConversation { request_id, params } => {
self.handle_resume_conversation(request_id, params).await;
@@ -802,17 +798,13 @@ impl CodexMessageProcessor {
}
}
async fn refresh_token_if_requested(&self, do_refresh: bool) {
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
tracing::warn!("failed to refresh token whilte getting account: {err}");
}
}
async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) {
let include_token = params.include_token.unwrap_or(false);
let do_refresh = params.refresh_token.unwrap_or(false);
self.refresh_token_if_requested(do_refresh).await;
if do_refresh && let Err(err) = self.auth_manager.refresh_token().await {
tracing::warn!("failed to refresh token while getting auth status: {err}");
}
// Determine whether auth is required based on the active model provider.
// If a custom provider is configured with `requires_openai_auth == false`,
@@ -857,56 +849,6 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
async fn get_account(&self, request_id: RequestId, params: GetAccountParams) {
let do_refresh = params.refresh_token;
self.refresh_token_if_requested(do_refresh).await;
// Whether auth is required for the active model provider.
let requires_openai_auth = self.config.model_provider.requires_openai_auth;
if !requires_openai_auth {
let response = GetAccountResponse {
account: None,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
return;
}
let account = match self.auth_manager.auth() {
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();
match (email, plan_type) {
(Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type },
_ => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message:
"email and plan type are required for chatgpt authentication"
.to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
}
}
}),
None => None,
};
let response = GetAccountResponse {
account,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
}
async fn get_user_agent(&self, request_id: RequestId) {
let user_agent = get_codex_user_agent();
let response = GetUserAgentResponse { user_agent };
@@ -1204,31 +1146,8 @@ impl CodexMessageProcessor {
match self.conversation_manager.new_conversation(config).await {
Ok(new_conv) => {
let conversation_id = new_conv.conversation_id;
let rollout_path = new_conv.session_configured.rollout_path.clone();
let fallback_provider = self.config.model_provider_id.as_str();
// A bit hacky, but the summary contains a lot of useful information for the thread
// that unfortunately does not get returned from conversation_manager.new_conversation().
let thread = match read_summary_from_rollout(
rollout_path.as_path(),
fallback_provider,
)
.await
{
Ok(summary) => summary_to_thread(summary),
Err(err) => {
warn!(
"failed to load summary for new thread {}: {}",
conversation_id, err
);
Thread {
id: conversation_id.to_string(),
preview: String::new(),
model_provider: self.config.model_provider_id.clone(),
created_at: chrono::Utc::now().timestamp(),
}
}
let thread = Thread {
id: new_conv.conversation_id.to_string(),
};
let response = ThreadStartResponse {
@@ -1238,12 +1157,12 @@ impl CodexMessageProcessor {
// Auto-attach a conversation listener when starting a thread.
// Use the same behavior as the v1 API with experimental_raw_events=false.
if let Err(err) = self
.attach_conversation_listener(conversation_id, false)
.attach_conversation_listener(new_conv.conversation_id, false)
.await
{
tracing::warn!(
"failed to attach listener for conversation {}: {}",
conversation_id,
new_conv.conversation_id,
err.message
);
}
@@ -1341,7 +1260,12 @@ impl CodexMessageProcessor {
}
};
let data = summaries.into_iter().map(summary_to_thread).collect();
let data = summaries
.into_iter()
.map(|s| Thread {
id: s.conversation_id.to_string(),
})
.collect();
let response = ThreadListResponse { data, next_cursor };
self.outgoing.send_response(request_id, response).await;
@@ -1428,8 +1352,6 @@ impl CodexMessageProcessor {
.await
{
Ok(_) => {
let thread = summary_to_thread(summary);
// Auto-attach a conversation listener when resuming a thread.
if let Err(err) = self
.attach_conversation_listener(conversation_id, false)
@@ -1442,7 +1364,11 @@ impl CodexMessageProcessor {
);
}
let response = ThreadResumeResponse { thread };
let response = ThreadResumeResponse {
thread: Thread {
id: conversation_id.to_string(),
},
};
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
@@ -1584,18 +1510,7 @@ impl CodexMessageProcessor {
let items = page
.items
.into_iter()
.filter_map(|it| {
let session_meta_line = it.head.first().and_then(|first| {
serde_json::from_value::<SessionMetaLine>(first.clone()).ok()
})?;
extract_conversation_summary(
it.path,
&it.head,
&session_meta_line.meta,
session_meta_line.git.as_ref(),
fallback_provider.as_str(),
)
})
.filter_map(|it| extract_conversation_summary(it.path, &it.head, &fallback_provider))
.collect::<Vec<_>>();
// Encode next_cursor as a plain string
@@ -2756,25 +2671,16 @@ async fn read_summary_from_rollout(
)));
};
let session_meta_line =
serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| {
IoError::other(format!(
"rollout at {} does not start with session metadata",
path.display()
))
})?;
let SessionMetaLine {
meta: session_meta,
git,
} = session_meta_line;
let session_meta = serde_json::from_value::<SessionMeta>(first.clone()).map_err(|_| {
IoError::other(format!(
"rollout at {} does not start with session metadata",
path.display()
))
})?;
if let Some(summary) = extract_conversation_summary(
path.to_path_buf(),
&head,
&session_meta,
git.as_ref(),
fallback_provider,
) {
if let Some(summary) =
extract_conversation_summary(path.to_path_buf(), &head, fallback_provider)
{
return Ok(summary);
}
@@ -2785,9 +2691,7 @@ async fn read_summary_from_rollout(
};
let model_provider = session_meta
.model_provider
.clone()
.unwrap_or_else(|| fallback_provider.to_string());
let git_info = git.as_ref().map(map_git_info);
Ok(ConversationSummary {
conversation_id: session_meta.id,
@@ -2795,20 +2699,19 @@ async fn read_summary_from_rollout(
path: path.to_path_buf(),
preview: String::new(),
model_provider,
cwd: session_meta.cwd,
cli_version: session_meta.cli_version,
source: session_meta.source,
git_info,
})
}
fn extract_conversation_summary(
path: PathBuf,
head: &[serde_json::Value],
session_meta: &SessionMeta,
git: Option<&GitInfo>,
fallback_provider: &str,
) -> Option<ConversationSummary> {
let session_meta = match head.first() {
Some(first_line) => serde_json::from_value::<SessionMeta>(first_line.clone()).ok()?,
None => return None,
};
let preview = head
.iter()
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
@@ -2830,9 +2733,7 @@ fn extract_conversation_summary(
let conversation_id = session_meta.id;
let model_provider = session_meta
.model_provider
.clone()
.unwrap_or_else(|| fallback_provider.to_string());
let git_info = git.map(map_git_info);
Some(ConversationSummary {
conversation_id,
@@ -2840,53 +2741,13 @@ fn extract_conversation_summary(
path,
preview: preview.to_string(),
model_provider,
cwd: session_meta.cwd.clone(),
cli_version: session_meta.cli_version.clone(),
source: session_meta.source.clone(),
git_info,
})
}
fn map_git_info(git_info: &GitInfo) -> ConversationGitInfo {
ConversationGitInfo {
sha: git_info.commit_hash.clone(),
branch: git_info.branch.clone(),
origin_url: git_info.repository_url.clone(),
}
}
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc))
})
}
fn summary_to_thread(summary: ConversationSummary) -> Thread {
let ConversationSummary {
conversation_id,
preview,
timestamp,
model_provider,
..
} = summary;
let created_at = parse_datetime(timestamp.as_deref());
Thread {
id: conversation_id.to_string(),
preview,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use codex_protocol::protocol::SessionSource;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
@@ -2925,11 +2786,8 @@ mod tests {
}),
];
let session_meta = serde_json::from_value::<SessionMeta>(head[0].clone())?;
let summary =
extract_conversation_summary(path.clone(), &head, &session_meta, None, "test-provider")
.expect("summary");
extract_conversation_summary(path.clone(), &head, "test-provider").expect("summary");
let expected = ConversationSummary {
conversation_id,
@@ -2937,10 +2795,6 @@ mod tests {
path,
preview: "Count to 5".to_string(),
model_provider: "test-provider".to_string(),
cwd: PathBuf::from("/"),
cli_version: "0.0.0".to_string(),
source: SessionSource::VSCode,
git_info: None,
};
assert_eq!(summary, expected);
@@ -2985,10 +2839,6 @@ mod tests {
path: path.clone(),
preview: String::new(),
model_provider: "fallback".to_string(),
cwd: PathBuf::new(),
cli_version: String::new(),
source: SessionSource::VSCode,
git_info: None,
};
assert_eq!(summary, expected);

View File

@@ -19,7 +19,6 @@ use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InterruptConversationParams;
@@ -250,15 +249,6 @@ impl McpProcess {
self.send_request("account/rateLimits/read", None).await
}
/// Send an `account/read` JSON-RPC request.
pub async fn send_get_account_request(
&mut self,
params: GetAccountParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("account/read", params).await
}
/// Send a `feedback/upload` JSON-RPC request.
pub async fn send_feedback_upload_request(
&mut self,

View File

@@ -7,6 +7,8 @@ mod fuzzy_file_search;
mod interrupt;
mod list_resume;
mod login;
mod model_list;
mod rate_limits;
mod send_message;
mod set_default_model;
mod user_agent;

View File

@@ -19,7 +19,7 @@ use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -106,7 +106,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
Ok(())
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_models_pagination_works() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -159,7 +159,7 @@ async fn list_models_pagination_works() -> Result<()> {
Ok(())
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_models_rejects_invalid_cursor() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;

View File

@@ -26,7 +26,7 @@ use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_account_rate_limits_requires_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -51,7 +51,7 @@ async fn get_account_rate_limits_requires_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -78,7 +78,7 @@ async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
let codex_home = TempDir::new()?;
write_chatgpt_auth(

View File

@@ -2,15 +2,11 @@ use anyhow::Result;
use anyhow::bail;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
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::GetAccountParams;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
@@ -19,7 +15,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_login::login_with_api_key;
use codex_protocol::account::PlanType as AccountPlanType;
use pretty_assertions::assert_eq;
use serial_test::serial;
use std::path::Path;
@@ -30,30 +25,22 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
// Helper to create a minimal config.toml for the app server
#[derive(Default)]
struct CreateConfigTomlParams {
forced_method: Option<String>,
forced_workspace_id: Option<String>,
requires_openai_auth: Option<bool>,
}
fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> {
fn create_config_toml(
codex_home: &Path,
forced_method: Option<&str>,
forced_workspace_id: Option<&str>,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
let forced_line = if let Some(method) = params.forced_method {
let forced_line = if let Some(method) = forced_method {
format!("forced_login_method = \"{method}\"\n")
} else {
String::new()
};
let forced_workspace_line = if let Some(ws) = params.forced_workspace_id {
let forced_workspace_line = if let Some(ws) = forced_workspace_id {
format!("forced_chatgpt_workspace_id = \"{ws}\"\n")
} else {
String::new()
};
let requires_line = match params.requires_openai_auth {
Some(true) => "requires_openai_auth = true\n".to_string(),
Some(false) => String::new(),
None => String::new(),
};
let contents = format!(
r#"
model = "mock-model"
@@ -70,7 +57,6 @@ base_url = "http://127.0.0.1:0/v1"
wire_api = "chat"
request_max_retries = 0
stream_max_retries = 0
{requires_line}
"#
);
std::fs::write(config_toml, contents)
@@ -79,7 +65,7 @@ stream_max_retries = 0
#[tokio::test]
async fn logout_account_removes_auth_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
create_config_toml(codex_home.path(), None, None)?;
login_with_api_key(
codex_home.path(),
@@ -118,25 +104,27 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> {
"auth.json should be deleted"
);
let get_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
let status_id = mcp
.send_get_auth_status_request(GetAuthStatusParams {
include_token: Some(true),
refresh_token: Some(false),
})
.await?;
let get_resp: JSONRPCResponse = timeout(
let status_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(get_id)),
mcp.read_stream_until_response_message(RequestId::Integer(status_id)),
)
.await??;
let account: GetAccountResponse = to_response(get_resp)?;
assert_eq!(account.account, None);
let status: GetAuthStatusResponse = to_response(status_resp)?;
assert_eq!(status.auth_method, None);
assert_eq!(status.auth_token, None);
Ok(())
}
#[tokio::test]
async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
create_config_toml(codex_home.path(), None, None)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -183,13 +171,7 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {
#[tokio::test]
async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
forced_method: Some("chatgpt".to_string()),
..Default::default()
},
)?;
create_config_toml(codex_home.path(), Some("chatgpt"), None)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -213,13 +195,7 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {
#[tokio::test]
async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
forced_method: Some("api".to_string()),
..Default::default()
},
)?;
create_config_toml(codex_home.path(), Some("api"), None)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -243,7 +219,7 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {
#[serial(login_port)]
async fn login_account_chatgpt_start() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;
create_config_toml(codex_home.path(), None, None)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -309,13 +285,7 @@ async fn login_account_chatgpt_start() -> Result<()> {
#[serial(login_port)]
async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
forced_workspace_id: Some("ws-forced".to_string()),
..Default::default()
},
)?;
create_config_toml(codex_home.path(), None, Some("ws-forced"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
@@ -337,156 +307,3 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result
);
Ok(())
}
#[tokio::test]
async fn get_account_no_auth() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let account: GetAccountResponse = to_response(resp)?;
assert_eq!(account.account, None, "expected no account");
assert_eq!(account.requires_openai_auth, true);
Ok(())
}
#[tokio::test]
async fn get_account_with_api_key() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_login_account_api_key_request("sk-test-key")
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let _login_ok = to_response::<LoginAccountResponse>(resp)?;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::ApiKey {}),
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_when_auth_not_required() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(false),
..Default::default()
},
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: None,
requires_openai_auth: false,
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
..Default::default()
},
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.email("user@example.com")
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}

View File

@@ -1,6 +1,4 @@
mod account;
mod model_list;
mod rate_limits;
mod thread_archive;
mod thread_list;
mod thread_resume;

View File

@@ -102,11 +102,6 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
next_cursor: cursor1,
} = to_response::<ThreadListResponse>(page1_resp)?;
assert_eq!(data1.len(), 2);
for thread in &data1 {
assert_eq!(thread.preview, "Hello");
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.created_at > 0);
}
let cursor1 = cursor1.expect("expected nextCursor on first page");
// Page 2: with cursor → expect next_cursor None when no more results.
@@ -127,11 +122,6 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
next_cursor: cursor2,
} = to_response::<ThreadListResponse>(page2_resp)?;
assert!(data2.len() <= 2);
for thread in &data2 {
assert_eq!(thread.preview, "Hello");
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.created_at > 0);
}
assert_eq!(cursor2, None, "expected nextCursor to be null on last page");
Ok(())
@@ -210,11 +200,6 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
let ThreadListResponse { data, next_cursor } = to_response::<ThreadListResponse>(resp)?;
assert_eq!(data.len(), 1);
assert_eq!(next_cursor, None);
let thread = &data[0];
assert_eq!(thread.preview, "X");
assert_eq!(thread.model_provider, "other_provider");
let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp();
assert_eq!(thread.created_at, expected_ts);
Ok(())
}

View File

@@ -49,7 +49,7 @@ async fn thread_resume_returns_existing_thread() -> Result<()> {
.await??;
let ThreadResumeResponse { thread: resumed } =
to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resumed, thread);
assert_eq!(resumed.id, thread.id);
Ok(())
}

View File

@@ -42,15 +42,6 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
.await??;
let ThreadStartResponse { thread } = to_response::<ThreadStartResponse>(resp)?;
assert!(!thread.id.is_empty(), "thread id should not be empty");
assert!(
thread.preview.is_empty(),
"new threads should start with an empty preview"
);
assert_eq!(thread.model_provider, "mock_provider");
assert!(
thread.created_at > 0,
"created_at should be a positive UNIX timestamp"
);
// A corresponding thread/started notification should arrive.
let notif: JSONRPCNotification = timeout(
@@ -60,7 +51,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
.await??;
let started: ThreadStartedNotification =
serde_json::from_value(notif.params.expect("params must be present"))?;
assert_eq!(started.thread, thread);
assert_eq!(started.thread.id, thread.id);
Ok(())
}

View File

@@ -288,7 +288,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp
path,
ApplyPatchFileChange::Update {
unified_diff,
move_path: move_path.map(|p| effective_cwd.join(p)),
move_path: move_path.map(|p| cwd.join(p)),
new_content: contents,
},
);
@@ -1603,53 +1603,6 @@ g
);
}
#[test]
fn test_apply_patch_resolves_move_path_with_effective_cwd() {
let session_dir = tempdir().unwrap();
let worktree_rel = "alt";
let worktree_dir = session_dir.path().join(worktree_rel);
fs::create_dir_all(&worktree_dir).unwrap();
let source_name = "old.txt";
let dest_name = "renamed.txt";
let source_path = worktree_dir.join(source_name);
fs::write(&source_path, "before\n").unwrap();
let patch = wrap_patch(&format!(
r#"*** Update File: {source_name}
*** Move to: {dest_name}
@@
-before
+after"#
));
let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH");
let argv = vec!["bash".into(), "-lc".into(), shell_script];
let result = maybe_parse_apply_patch_verified(&argv, session_dir.path());
let action = match result {
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected verified body, got {other:?}"),
};
assert_eq!(action.cwd, worktree_dir);
let change = action
.changes()
.get(&worktree_dir.join(source_name))
.expect("source file change present");
match change {
ApplyPatchFileChange::Update { move_path, .. } => {
assert_eq!(
move_path.as_deref(),
Some(worktree_dir.join(dest_name).as_path())
);
}
other => panic!("expected update change, got {other:?}"),
}
}
#[test]
fn test_apply_patch_fails_on_write_error() {
let dir = tempdir().unwrap();

View File

@@ -30,6 +30,7 @@ codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-stdio-to-uds = { workspace = true }

View File

@@ -5,7 +5,6 @@ use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::exec_env::create_env;
use codex_core::landlock::spawn_command_under_linux_sandbox;
#[cfg(target_os = "macos")]
use codex_core::seatbelt::spawn_command_under_seatbelt;
use codex_core::spawn::StdioPolicy;
use codex_protocol::config_types::SandboxMode;
@@ -15,7 +14,6 @@ use crate::SeatbeltCommand;
use crate::WindowsCommand;
use crate::exit_status::handle_exit_status;
#[cfg(target_os = "macos")]
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -35,14 +33,6 @@ pub async fn run_command_under_seatbelt(
.await
}
#[cfg(not(target_os = "macos"))]
pub async fn run_command_under_seatbelt(
_command: SeatbeltCommand,
_codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
anyhow::bail!("Seatbelt sandbox is only available on macOS");
}
pub async fn run_command_under_landlock(
command: LandlockCommand,
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -82,7 +72,6 @@ pub async fn run_command_under_windows(
}
enum SandboxType {
#[cfg(target_os = "macos")]
Seatbelt,
Landlock,
Windows,
@@ -136,8 +125,6 @@ async fn run_command_under_sandbox(
let env_map = env.clone();
let command_vec = command.clone();
let base_dir = config.codex_home.clone();
// Preflight audit is invoked elsewhere at the appropriate times.
let res = tokio::task::spawn_blocking(move || {
run_windows_sandbox_capture(
policy_str,
@@ -181,7 +168,6 @@ async fn run_command_under_sandbox(
}
let mut child = match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => {
spawn_command_under_seatbelt(
command,

View File

@@ -1,4 +1,3 @@
use clap::Args;
use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
@@ -21,17 +20,14 @@ use codex_exec::Cli as ExecCli;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use codex_tui::update_action::UpdateAction;
use codex_tui::updates::UpdateAction;
use owo_colors::OwoColorize;
use std::path::PathBuf;
use supports_color::Stream;
mod mcp_cmd;
#[cfg(not(windows))]
mod wsl_paths;
use crate::mcp_cmd::McpCli;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::features::is_known_feature_key;
@@ -83,8 +79,8 @@ enum Subcommand {
/// [experimental] Run the Codex MCP server (stdio transport).
McpServer,
/// [experimental] Run the app server or related tooling.
AppServer(AppServerCommand),
/// [experimental] Run the app server.
AppServer,
/// Generate shell completion scripts.
Completion(CompletionCommand),
@@ -100,6 +96,9 @@ enum Subcommand {
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
Resume(ResumeCommand),
/// Internal: generate TypeScript protocol bindings.
#[clap(hide = true)]
GenerateTs(GenerateTsCommand),
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
#[clap(name = "cloud", alias = "cloud-tasks")]
Cloud(CloudTasksCli),
@@ -206,22 +205,6 @@ struct LogoutCommand {
}
#[derive(Debug, Parser)]
struct AppServerCommand {
/// Omit to run the app server; specify a subcommand for tooling.
#[command(subcommand)]
subcommand: Option<AppServerSubcommand>,
}
#[derive(Debug, clap::Subcommand)]
enum AppServerSubcommand {
/// [experimental] Generate TypeScript bindings for the app server protocol.
GenerateTs(GenerateTsCommand),
/// [experimental] Generate JSON Schema for the app server protocol.
GenerateJsonSchema(GenerateJsonSchemaCommand),
}
#[derive(Debug, Args)]
struct GenerateTsCommand {
/// Output directory where .ts files will be written
#[arg(short = 'o', long = "out", value_name = "DIR")]
@@ -232,13 +215,6 @@ struct GenerateTsCommand {
prettier: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct GenerateJsonSchemaCommand {
/// Output directory where the schema bundle will be written
#[arg(short = 'o', long = "out", value_name = "DIR")]
out_dir: PathBuf,
}
#[derive(Debug, Parser)]
struct StdioToUdsCommand {
/// Path to the Unix domain socket to connect to.
@@ -291,30 +267,10 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
/// Run the update action and print the result.
fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
println!();
let (cmd, args) = action.command_args();
let cmd_str = action.command_str();
println!("Updating Codex via `{cmd_str}`...");
let status = {
#[cfg(windows)]
{
// On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics).
std::process::Command::new("cmd")
.args(["/C", &cmd_str])
.status()?
}
#[cfg(not(windows))]
{
let (cmd, args) = action.command_args();
let command_path = crate::wsl_paths::normalize_for_wsl(cmd);
let normalized_args: Vec<String> = args
.iter()
.map(crate::wsl_paths::normalize_for_wsl)
.collect();
std::process::Command::new(&command_path)
.args(&normalized_args)
.status()?
}
};
let status = std::process::Command::new(cmd).args(args).status()?;
if !status.success() {
anyhow::bail!("`{cmd_str}` failed with status {status}");
}
@@ -431,20 +387,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand {
None => {
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
codex_app_server_protocol::generate_ts(
&gen_cli.out_dir,
gen_cli.prettier.as_deref(),
)?;
}
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
codex_app_server_protocol::generate_json(&gen_cli.out_dir)?;
}
},
Some(Subcommand::AppServer) => {
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
}
Some(Subcommand::Resume(ResumeCommand {
session_id,
last,
@@ -559,6 +504,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path()))
.await??;
}
Some(Subcommand::GenerateTs(gen_cli)) => {
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
}
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
// Respect root-level `-c` overrides plus top-level flags like `--profile`.

View File

@@ -1,76 +0,0 @@
use std::ffi::OsStr;
/// WSL-specific path helpers used by the updater logic.
///
/// See https://github.com/openai/codex/issues/6086.
pub fn is_wsl() -> bool {
#[cfg(target_os = "linux")]
{
if std::env::var_os("WSL_DISTRO_NAME").is_some() {
return true;
}
match std::fs::read_to_string("/proc/version") {
Ok(version) => version.to_lowercase().contains("microsoft"),
Err(_) => false,
}
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
/// Convert a Windows absolute path (`C:\foo\bar` or `C:/foo/bar`) to a WSL mount path (`/mnt/c/foo/bar`).
/// Returns `None` if the input does not look like a Windows drive path.
pub fn win_path_to_wsl(path: &str) -> Option<String> {
let bytes = path.as_bytes();
if bytes.len() < 3
|| bytes[1] != b':'
|| !(bytes[2] == b'\\' || bytes[2] == b'/')
|| !bytes[0].is_ascii_alphabetic()
{
return None;
}
let drive = (bytes[0] as char).to_ascii_lowercase();
let tail = path[3..].replace('\\', "/");
if tail.is_empty() {
return Some(format!("/mnt/{drive}"));
}
Some(format!("/mnt/{drive}/{tail}"))
}
/// If under WSL and given a Windows-style path, return the equivalent `/mnt/<drive>/…` path.
/// Otherwise returns the input unchanged.
pub fn normalize_for_wsl<P: AsRef<OsStr>>(path: P) -> String {
let value = path.as_ref().to_string_lossy().to_string();
if !is_wsl() {
return value;
}
if let Some(mapped) = win_path_to_wsl(&value) {
return mapped;
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn win_to_wsl_basic() {
assert_eq!(
win_path_to_wsl(r"C:\Temp\codex.zip").as_deref(),
Some("/mnt/c/Temp/codex.zip")
);
assert_eq!(
win_path_to_wsl("D:/Work/codex.tgz").as_deref(),
Some("/mnt/d/Work/codex.tgz")
);
assert!(win_path_to_wsl("/home/user/codex").is_none());
}
#[test]
fn normalize_is_noop_on_unix_paths() {
assert_eq!(normalize_for_wsl("/home/u/x"), "/home/u/x");
}
}

View File

@@ -8,7 +8,6 @@ pub mod util;
pub use cli::Cli;
use anyhow::anyhow;
use codex_login::AuthManager;
use std::io::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
@@ -57,8 +56,20 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
};
append_error_log(format!("startup: base_url={base_url} path_style={style}"));
let auth_manager = util::load_auth_manager().await;
let auth = match auth_manager.as_ref().and_then(AuthManager::auth) {
let auth = match codex_core::config::find_codex_home()
.ok()
.map(|home| {
let store_mode = codex_core::config::Config::load_from_base_config_with_overrides(
codex_core::config::ConfigToml::default(),
codex_core::config::ConfigOverrides::default(),
home.clone(),
)
.map(|cfg| cfg.cli_auth_credentials_store_mode)
.unwrap_or_default();
codex_login::AuthManager::new(home, false, store_mode)
})
.and_then(|am| am.auth())
{
Some(auth) => auth,
None => {
eprintln!(

View File

@@ -2,10 +2,6 @@ use base64::Engine as _;
use chrono::Utc;
use reqwest::header::HeaderMap;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_login::AuthManager;
pub fn set_user_agent_suffix(suffix: &str) {
if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() {
guard.replace(suffix.to_string());
@@ -58,18 +54,6 @@ pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
.map(str::to_string)
}
pub async fn load_auth_manager() -> Option<AuthManager> {
// TODO: pass in cli overrides once cloud tasks properly support them.
let config = Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default())
.await
.ok()?;
Some(AuthManager::new(
config.codex_home,
false,
config.cli_auth_credentials_store_mode,
))
}
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
/// and optional `ChatGPT-Account-Id`.
pub async fn build_chatgpt_headers() -> HeaderMap {
@@ -85,22 +69,31 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Some(am) = load_auth_manager().await
&& let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await
&& !tok.is_empty()
{
let v = format!("Bearer {tok}");
if let Ok(hv) = HeaderValue::from_str(&v) {
headers.insert(AUTHORIZATION, hv);
}
if let Some(acc) = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&tok))
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(&acc)
if let Ok(home) = codex_core::config::find_codex_home() {
let store_mode = codex_core::config::Config::load_from_base_config_with_overrides(
codex_core::config::ConfigToml::default(),
codex_core::config::ConfigOverrides::default(),
home.clone(),
)
.map(|cfg| cfg.cli_auth_credentials_store_mode)
.unwrap_or_default();
let am = codex_login::AuthManager::new(home, false, store_mode);
if let Some(auth) = am.auth()
&& let Ok(tok) = auth.get_token().await
&& !tok.is_empty()
{
headers.insert(name, hv);
let v = format!("Bearer {tok}");
if let Ok(hv) = HeaderValue::from_str(&v) {
headers.insert(AUTHORIZATION, hv);
}
if let Some(acc) = auth
.get_account_id()
.or_else(|| extract_chatgpt_account_id(&tok))
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
&& let Ok(hv) = HeaderValue::from_str(&acc)
{
headers.insert(name, hv);
}
}
}
headers

View File

@@ -19,8 +19,8 @@ use toml::Value;
pub struct CliConfigOverrides {
/// Override a configuration value that would otherwise be loaded from
/// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
/// nested values. The `value` portion is parsed as TOML. If it fails to
/// parse as TOML, the raw string is used as a literal.
/// nested values. The `value` portion is parsed as JSON. If it fails to
/// parse as JSON, the raw string is used as a literal.
///
/// Examples:
/// - `-c model="o3"`
@@ -59,7 +59,7 @@ impl CliConfigOverrides {
return Err(format!("Empty key in override: {s}"));
}
// Attempt to parse as TOML. If that fails, treat it as a raw
// Attempt to parse as JSON. If that fails, treat it as a raw
// string. This allows convenient usage such as
// `-c model=o3` without the quotes.
let value: Value = match parse_toml_value(value_str) {

View File

@@ -2,6 +2,8 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
## General
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
@@ -14,7 +16,6 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.

View File

@@ -26,12 +26,10 @@ use crate::config::Config;
use crate::default_client::CodexHttpClient;
use crate::error::RefreshTokenFailedError;
use crate::error::RefreshTokenFailedReason;
use crate::token_data::KnownPlan as InternalKnownPlan;
use crate::token_data::PlanType as InternalPlanType;
use crate::token_data::PlanType;
use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
use crate::util::try_parse_error_message;
use codex_protocol::account::PlanType as AccountPlanType;
use serde_json::Value;
use thiserror::Error;
@@ -204,34 +202,7 @@ impl CodexAuth {
self.get_current_token_data().and_then(|t| t.id_token.email)
}
/// Account-facing plan classification derived from the current token.
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
/// mapped from the ID token's internal plan value. Prefer this when you
/// need to make UI or product decisions based on the user's subscription.
pub fn account_plan_type(&self) -> Option<AccountPlanType> {
let map_known = |kp: &InternalKnownPlan| match kp {
InternalKnownPlan::Free => AccountPlanType::Free,
InternalKnownPlan::Plus => AccountPlanType::Plus,
InternalKnownPlan::Pro => AccountPlanType::Pro,
InternalKnownPlan::Team => AccountPlanType::Team,
InternalKnownPlan::Business => AccountPlanType::Business,
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
InternalKnownPlan::Edu => AccountPlanType::Edu,
};
self.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_plan_type)
.map(|pt| match pt {
InternalPlanType::Known(k) => map_known(&k),
InternalPlanType::Unknown(_) => AccountPlanType::Unknown,
})
}
/// Raw internal plan value from the ID token.
/// Exposes the underlying `token_data::PlanType` without mapping it to the
/// public `AccountPlanType`. Use this when downstream code needs to inspect
/// internal/unknown plan strings exactly as issued in the token.
pub(crate) fn get_plan_type(&self) -> Option<InternalPlanType> {
pub(crate) fn get_plan_type(&self) -> Option<PlanType> {
self.get_current_token_data()
.and_then(|t| t.id_token.chatgpt_plan_type)
}
@@ -638,9 +609,8 @@ mod tests {
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::token_data::IdTokenInfo;
use crate::token_data::KnownPlan as InternalKnownPlan;
use crate::token_data::PlanType as InternalPlanType;
use codex_protocol::account::PlanType as AccountPlanType;
use crate::token_data::KnownPlan;
use crate::token_data::PlanType;
use base64::Engine;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -757,7 +727,7 @@ mod tests {
tokens: Some(TokenData {
id_token: IdTokenInfo {
email: Some("user@example.com".to_string()),
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
chatgpt_account_id: None,
raw_jwt: fake_jwt,
},
@@ -1011,54 +981,6 @@ mod tests {
.contains("ChatGPT login is required, but an API key is currently being used.")
);
}
#[test]
fn plan_type_maps_known_plan() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "pro".to_string(),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
pretty_assertions::assert_eq!(
auth.get_plan_type(),
Some(InternalPlanType::Known(InternalKnownPlan::Pro))
);
}
#[test]
fn plan_type_maps_unknown_to_unknown() {
let codex_home = tempdir().unwrap();
let _jwt = write_auth_file(
AuthFileParams {
openai_api_key: None,
chatgpt_plan_type: "mystery-tier".to_string(),
chatgpt_account_id: None,
},
codex_home.path(),
)
.expect("failed to write auth file");
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
.expect("load auth")
.expect("auth available");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown));
pretty_assertions::assert_eq!(
auth.get_plan_type(),
Some(InternalPlanType::Unknown("mystery-tier".to_string()))
);
}
}
/// Central manager providing a single source of truth for auth.json derived

View File

@@ -447,8 +447,6 @@ impl ModelClient {
return Err(StreamAttemptError::Fatal(codex_err));
} else if error.r#type.as_deref() == Some("usage_not_included") {
return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded));
} else if is_quota_exceeded_error(&error) {
return Err(StreamAttemptError::Fatal(CodexErr::QuotaExceeded));
}
}
}
@@ -682,6 +680,33 @@ fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name)?.to_str().ok()
}
async fn emit_completed(
tx_event: &mpsc::Sender<Result<ResponseEvent>>,
otel_event_manager: &OtelEventManager,
completed: ResponseCompleted,
) {
if let Some(token_usage) = &completed.usage {
otel_event_manager.sse_event_completed(
token_usage.input_tokens,
token_usage.output_tokens,
token_usage
.input_tokens_details
.as_ref()
.map(|d| d.cached_tokens),
token_usage
.output_tokens_details
.as_ref()
.map(|d| d.reasoning_tokens),
token_usage.total_tokens,
);
}
let event = ResponseEvent::Completed {
response_id: completed.id.clone(),
token_usage: completed.usage.map(Into::into),
};
let _ = tx_event.send(Ok(event)).await;
}
async fn process_sse<S>(
stream: S,
tx_event: mpsc::Sender<Result<ResponseEvent>>,
@@ -694,7 +719,7 @@ async fn process_sse<S>(
// If the stream stays completely silent for an extended period treat it as disconnected.
// The response id returned from the "complete" message.
let mut response_completed: Option<ResponseCompleted> = None;
let response_completed: Option<ResponseCompleted> = None;
let mut response_error: Option<CodexErr> = None;
loop {
@@ -713,30 +738,8 @@ async fn process_sse<S>(
}
Ok(None) => {
match response_completed {
Some(ResponseCompleted {
id: response_id,
usage,
}) => {
if let Some(token_usage) = &usage {
otel_event_manager.sse_event_completed(
token_usage.input_tokens,
token_usage.output_tokens,
token_usage
.input_tokens_details
.as_ref()
.map(|d| d.cached_tokens),
token_usage
.output_tokens_details
.as_ref()
.map(|d| d.reasoning_tokens),
token_usage.total_tokens,
);
}
let event = ResponseEvent::Completed {
response_id,
token_usage: usage.map(Into::into),
};
let _ = tx_event.send(Ok(event)).await;
Some(completed) => {
emit_completed(&tx_event, &otel_event_manager, completed).await
}
None => {
let error = response_error.unwrap_or(CodexErr::Stream(
@@ -846,8 +849,6 @@ async fn process_sse<S>(
Ok(error) => {
if is_context_window_error(&error) {
response_error = Some(CodexErr::ContextWindowExceeded);
} else if is_quota_exceeded_error(&error) {
response_error = Some(CodexErr::QuotaExceeded);
} else {
let delay = try_parse_retry_after(&error);
let message = error.message.clone().unwrap_or_default();
@@ -868,7 +869,8 @@ async fn process_sse<S>(
if let Some(resp_val) = event.response {
match serde_json::from_value::<ResponseCompleted>(resp_val) {
Ok(r) => {
response_completed = Some(r);
emit_completed(&tx_event, &otel_event_manager, r).await;
return;
}
Err(e) => {
let error = format!("failed to parse ResponseCompleted: {e}");
@@ -979,10 +981,6 @@ fn is_context_window_error(error: &Error) -> bool {
error.code.as_deref() == Some("context_length_exceeded")
}
fn is_quota_exceeded_error(error: &Error) -> bool {
error.code.as_deref() == Some("insufficient_quota")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1315,41 +1313,6 @@ mod tests {
}
}
#[tokio::test]
async fn quota_exceeded_error_is_fatal() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."},"incomplete_details":null}}"#;
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
let provider = ModelProviderInfo {
name: "test".to_string(),
base_url: Some("https://test.com".to_string()),
env_key: Some("TEST_API_KEY".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: Some(0),
stream_max_retries: Some(0),
stream_idle_timeout_ms: Some(1000),
requires_openai_auth: false,
};
let otel_event_manager = otel_event_manager();
let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await;
assert_eq!(events.len(), 1);
match &events[0] {
Err(err @ CodexErr::QuotaExceeded) => {
assert_eq!(err.to_string(), CodexErr::QuotaExceeded.to_string());
}
other => panic!("unexpected quota exceeded event: {other:?}"),
}
}
// ────────────────────────────
// Table-driven test from `main`
// ────────────────────────────

View File

@@ -6,7 +6,6 @@ use std::sync::atomic::AtomicU64;
use crate::AuthManager;
use crate::client_common::REVIEW_PROMPT;
use crate::compact;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::mcp::auth::McpAuthStatusEntry;
@@ -67,8 +66,6 @@ use crate::error::Result as CodexResult;
use crate::exec::StreamOutput;
// Removed: legacy executor wiring replaced by ToolOrchestrator flows.
// legacy normalize_exec_result no longer used after orchestrator migration
use crate::compact::build_compacted_history;
use crate::compact::collect_user_messages;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_family::find_family_for_model;
@@ -132,6 +129,10 @@ use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness;
use codex_utils_readiness::ReadinessFlag;
pub mod compact;
use self::compact::build_compacted_history;
use self::compact::collect_user_messages;
/// The high-level interface to the Codex system.
/// It operates as a queue pair where you send submissions and receive events.
pub struct Codex {
@@ -967,7 +968,7 @@ impl Session {
}
/// Append ResponseItems to the in-memory conversation history only.
pub(crate) async fn record_into_history(&self, items: &[ResponseItem]) {
async fn record_into_history(&self, items: &[ResponseItem]) {
let mut state = self.state.lock().await;
state.record_items(items.iter());
}
@@ -1019,7 +1020,7 @@ impl Session {
items
}
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) {
async fn persist_rollout_items(&self, items: &[RolloutItem]) {
let recorder = {
let guard = self.services.rollout.lock().await;
guard.clone()
@@ -1036,7 +1037,7 @@ impl Session {
state.clone_history()
}
pub(crate) async fn update_token_usage_info(
async fn update_token_usage_info(
&self,
turn_context: &TurnContext,
token_usage: Option<&TokenUsage>,
@@ -1053,7 +1054,7 @@ impl Session {
self.send_token_count_event(turn_context).await;
}
pub(crate) async fn update_rate_limits(
async fn update_rate_limits(
&self,
turn_context: &TurnContext,
new_rate_limits: RateLimitSnapshot,
@@ -1074,7 +1075,7 @@ impl Session {
self.send_event(turn_context, event).await;
}
pub(crate) async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
let context_window = turn_context.client.get_model_context_window();
if let Some(context_window) = context_window {
{
@@ -1117,11 +1118,7 @@ impl Session {
self.send_event(turn_context, event).await;
}
pub(crate) async fn notify_stream_error(
&self,
turn_context: &TurnContext,
message: impl Into<String>,
) {
async fn notify_stream_error(&self, turn_context: &TurnContext, message: impl Into<String>) {
let event = EventMsg::StreamError(StreamErrorEvent {
message: message.into(),
});
@@ -1646,7 +1643,8 @@ async fn spawn_review_thread(
let mut review_features = config.features.clone();
review_features
.disable(crate::features::Feature::WebSearchRequest)
.disable(crate::features::Feature::ViewImageTool);
.disable(crate::features::Feature::ViewImageTool)
.disable(crate::features::Feature::StreamableShell);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_family: &review_model_family,
features: &review_features,
@@ -1930,7 +1928,6 @@ async fn run_turn(
return Err(CodexErr::UsageLimitReached(e));
}
Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded),
Err(e @ CodexErr::QuotaExceeded) => return Err(e),
Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e),
Err(e) => {
// Use the configured provider-specific stream retry budget.

View File

@@ -1,10 +1,10 @@
use std::sync::Arc;
use super::Session;
use super::TurnContext;
use super::get_last_assistant_message_from_turn;
use crate::Prompt;
use crate::client_common::ResponseEvent;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::codex::get_last_assistant_message_from_turn;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::protocol::AgentMessageEvent;
@@ -25,7 +25,7 @@ use codex_protocol::user_input::UserInput;
use futures::prelude::*;
use tracing::error;
pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md");
pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
pub(crate) async fn run_inline_auto_compact_task(
@@ -164,7 +164,7 @@ async fn run_compact_task_inner(
sess.send_event(&turn_context, event).await;
let warning = EventMsg::Warning(WarningEvent {
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(),
});
sess.send_event(&turn_context, warning).await;
}

View File

@@ -1,38 +1,4 @@
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use crate::bash::parse_shell_lc_plain_commands;
use crate::is_safe_command::is_known_safe_command;
pub fn requires_initial_appoval(
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
command: &[String],
with_escalated_permissions: bool,
) -> bool {
if is_known_safe_command(command) {
return false;
}
match policy {
AskForApproval::Never | AskForApproval::OnFailure => false,
AskForApproval::OnRequest => {
// In DangerFullAccess, only prompt if the command looks dangerous.
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
return command_might_be_dangerous(command);
}
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
// nonescalated, nondangerous commands — let the sandbox enforce
// restrictions (e.g., block network/write) without a user prompt.
let wants_escalation: bool = with_escalated_permissions;
if wants_escalation {
return true;
}
command_might_be_dangerous(command)
}
AskForApproval::UnlessTrusted => !is_known_safe_command(command),
}
}
pub fn command_might_be_dangerous(command: &[String]) -> bool {
if is_dangerous_to_call_with_exec(command) {

View File

@@ -23,10 +23,6 @@ pub enum ConfigEdit {
},
/// Toggle the acknowledgement flag under `[notice]`.
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
SetNoticeHideWorldWritableWarning(bool),
/// Toggle the rate limit model nudge acknowledgement flag.
SetNoticeHideRateLimitModelNudge(bool),
/// Toggle the Windows onboarding acknowledgement flag.
SetWindowsWslSetupAcknowledged(bool),
/// Replace the entire `[mcp_servers]` table.
@@ -243,16 +239,6 @@ impl ConfigDocument {
&[Notice::TABLE_KEY, "hide_full_access_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(
Scope::Global,
&[Notice::TABLE_KEY, "hide_world_writable_warning"],
value(*acknowledged),
)),
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
Scope::Global,
&[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"],
value(*acknowledged),
)),
ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value(
Scope::Global,
&["windows_wsl_setup_acknowledged"],
@@ -487,18 +473,6 @@ impl ConfigEditsBuilder {
self
}
pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged));
self
}
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
self
}
pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged));
@@ -746,34 +720,6 @@ hide_full_access_warning = true
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_rate_limit_model_nudge_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
None,
&[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)],
)
.expect("persist");
let contents =
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
hide_rate_limit_model_nudge = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_replace_mcp_servers_round_trips() {
let tmp = tempdir().expect("tmpdir");

View File

@@ -241,6 +241,8 @@ pub struct Config {
/// When `true`, run a model-based assessment for commands denied by the sandbox.
pub experimental_sandbox_command_assessment: bool,
pub use_experimental_streamable_shell_tool: bool,
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
@@ -653,6 +655,7 @@ pub struct ConfigToml {
/// Legacy, now use features
pub experimental_instructions_file: Option<PathBuf>,
pub experimental_compact_prompt_file: Option<PathBuf>,
pub experimental_use_exec_command_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
@@ -996,6 +999,7 @@ impl Config {
let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform);
let tools_web_search_request = features.enabled(Feature::WebSearchRequest);
let use_experimental_streamable_shell_tool = features.enabled(Feature::StreamableShell);
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient);
let experimental_sandbox_command_assessment =
@@ -1152,6 +1156,7 @@ impl Config {
include_apply_patch_tool: include_apply_patch_tool_flag,
tools_web_search_request,
experimental_sandbox_command_assessment,
use_experimental_streamable_shell_tool,
use_experimental_unified_exec_tool,
use_experimental_use_rmcp_client,
features,
@@ -1710,6 +1715,7 @@ trust_level = "trusted"
fn legacy_toggles_map_to_features() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
experimental_use_exec_command_tool: Some(true),
experimental_use_unified_exec_tool: Some(true),
experimental_use_rmcp_client: Some(true),
experimental_use_freeform_apply_patch: Some(true),
@@ -1723,11 +1729,12 @@ trust_level = "trusted"
)?;
assert!(config.features.enabled(Feature::ApplyPatchFreeform));
assert!(config.features.enabled(Feature::StreamableShell));
assert!(config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::RmcpClient));
assert!(config.include_apply_patch_tool);
assert!(config.use_experimental_streamable_shell_tool);
assert!(config.use_experimental_unified_exec_tool);
assert!(config.use_experimental_use_rmcp_client);
@@ -2895,6 +2902,7 @@ model_verbosity = "high"
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
features: Features::with_defaults(),
@@ -2966,6 +2974,7 @@ model_verbosity = "high"
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
features: Features::with_defaults(),
@@ -3052,6 +3061,7 @@ model_verbosity = "high"
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
features: Features::with_defaults(),
@@ -3124,6 +3134,7 @@ model_verbosity = "high"
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
features: Features::with_defaults(),

View File

@@ -25,6 +25,7 @@ pub struct ConfigProfile {
pub experimental_compact_prompt_file: Option<PathBuf>,
pub include_apply_patch_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_exec_command_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,

View File

@@ -358,10 +358,6 @@ pub struct Tui {
pub struct Notice {
/// Tracks whether the user has acknowledged the full access warning prompt.
pub hide_full_access_warning: Option<bool>,
/// Tracks whether the user has acknowledged the Windows world-writable directories warning.
pub hide_world_writable_warning: Option<bool>,
/// Tracks whether the user opted out of the rate limit model switch reminder.
pub hide_rate_limit_model_nudge: Option<bool>,
}
impl Notice {

View File

@@ -109,9 +109,6 @@ pub enum CodexErr {
#[error("{0}")]
ConnectionFailed(ConnectionFailedError),
#[error("Quota exceeded. Check your plan and billing details.")]
QuotaExceeded,
#[error(
"To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing."
)]
@@ -238,44 +235,18 @@ pub struct UnexpectedResponseError {
pub request_id: Option<String>,
}
const CLOUDFLARE_BLOCKED_MESSAGE: &str =
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region";
impl UnexpectedResponseError {
fn friendly_message(&self) -> Option<String> {
if self.status != StatusCode::FORBIDDEN {
return None;
}
if !self.body.contains("Cloudflare") || !self.body.contains("blocked") {
return None;
}
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {})", self.status);
if let Some(id) = &self.request_id {
message.push_str(&format!(", request id: {id}"));
}
Some(message)
}
}
impl std::fmt::Display for UnexpectedResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(friendly) = self.friendly_message() {
write!(f, "{friendly}")
} else {
write!(
f,
"unexpected status {}: {}{}",
self.status,
self.body,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
write!(
f,
"unexpected status {}: {}{}",
self.status,
self.body,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
@@ -691,35 +662,6 @@ mod tests {
});
}
#[test]
fn unexpected_status_cloudflare_html_is_simplified() {
let err = UnexpectedResponseError {
status: StatusCode::FORBIDDEN,
body: "<html><body>Cloudflare error: Sorry, you have been blocked</body></html>"
.to_string(),
request_id: Some("ray-id".to_string()),
};
let status = StatusCode::FORBIDDEN.to_string();
assert_eq!(
err.to_string(),
format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), request id: ray-id")
);
}
#[test]
fn unexpected_status_non_html_is_unchanged() {
let err = UnexpectedResponseError {
status: StatusCode::FORBIDDEN,
body: "plain text error".to_string(),
request_id: None,
};
let status = StatusCode::FORBIDDEN.to_string();
assert_eq!(
err.to_string(),
format!("unexpected status {status}: plain text error")
);
}
#[test]
fn usage_limit_reached_includes_hours_and_minutes() {
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();

View File

@@ -14,7 +14,6 @@ use tracing::warn;
use uuid::Uuid;
use crate::user_instructions::UserInstructions;
use crate::user_shell_command::is_user_shell_command_text;
fn is_session_prefix(text: &str) -> bool {
let trimmed = text.trim_start();
@@ -32,7 +31,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
for content_item in message.iter() {
match content_item {
ContentItem::InputText { text } => {
if is_session_prefix(text) || is_user_shell_command_text(text) {
if is_session_prefix(text) {
return None;
}
content.push(UserInput::Text { text: text.clone() });
@@ -198,14 +197,7 @@ mod tests {
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
}],
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<user_shell_command>echo 42</user_shell_command>".to_string(),
}],
},
];
];
for item in items {
let turn_item = parse_turn_item(&item);

View File

@@ -313,10 +313,6 @@ pub(crate) mod errors {
SandboxTransformError::MissingLinuxSandboxExecutable => {
CodexErr::LandlockSandboxExecutableNotProvided
}
#[cfg(not(target_os = "macos"))]
SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation(
"seatbelt sandbox is only available on macOS".to_string(),
),
}
}
}
@@ -518,7 +514,6 @@ async fn consume_truncated_output(
}
Err(_) => {
// timeout
kill_child_process_group(&mut child)?;
child.start_kill()?;
// Debatable whether `child.wait().await` should be called here.
(synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true)
@@ -526,7 +521,6 @@ async fn consume_truncated_output(
}
}
_ = tokio::signal::ctrl_c() => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
(synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + SIGKILL_CODE), false)
}
@@ -623,38 +617,6 @@ fn synthetic_exit_status(code: i32) -> ExitStatus {
std::process::ExitStatus::from_raw(code as u32)
}
#[cfg(unix)]
fn kill_child_process_group(child: &mut Child) -> io::Result<()> {
use std::io::ErrorKind;
if let Some(pid) = child.id() {
let pid = pid as libc::pid_t;
let pgid = unsafe { libc::getpgid(pid) };
if pgid == -1 {
let err = std::io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
return Ok(());
}
let result = unsafe { libc::killpg(pgid, libc::SIGKILL) };
if result == -1 {
let err = std::io::Error::last_os_error();
if err.kind() != ErrorKind::NotFound {
return Err(err);
}
}
}
Ok(())
}
#[cfg(not(unix))]
fn kill_child_process_group(_: &mut Child) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -727,51 +689,4 @@ mod tests {
let output = make_exec_output(exit_code, "", "", "");
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
}
#[cfg(unix)]
#[tokio::test]
async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> {
let command = vec![
"/bin/bash".to_string(),
"-c".to_string(),
"sleep 60 & echo $!; sleep 60".to_string(),
];
let env: HashMap<String, String> = std::env::vars().collect();
let params = ExecParams {
command,
cwd: std::env::current_dir()?,
timeout_ms: Some(500),
env,
with_escalated_permissions: None,
justification: None,
arg0: None,
};
let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?;
assert!(output.timed_out);
let stdout = output.stdout.from_utf8_lossy().text;
let pid_line = stdout.lines().next().unwrap_or("").trim();
let pid: i32 = pid_line.parse().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse pid from stdout '{pid_line}': {error}"),
)
})?;
let mut killed = false;
for _ in 0..20 {
// Use kill(pid, 0) to check if the process is alive.
if unsafe { libc::kill(pid, 0) } == -1
&& let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error()
{
killed = true;
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(killed, "grandchild process with pid {pid} is still alive");
Ok(())
}
}

View File

@@ -29,6 +29,8 @@ pub enum Stage {
pub enum Feature {
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Use the streamable exec-command/write-stdin tool pair.
StreamableShell,
/// Enable experimental RMCP features such as OAuth login.
RmcpClient,
/// Include the freeform apply_patch tool.
@@ -116,9 +118,8 @@ impl Features {
self.enabled.contains(&f)
}
pub fn enable(&mut self, f: Feature) -> &mut Self {
pub fn enable(&mut self, f: Feature) {
self.enabled.insert(f);
self
}
pub fn disable(&mut self, f: Feature) -> &mut Self {
@@ -177,6 +178,7 @@ impl Features {
let base_legacy = LegacyFeatureToggles {
experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment,
experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch,
experimental_use_exec_command_tool: cfg.experimental_use_exec_command_tool,
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
experimental_use_rmcp_client: cfg.experimental_use_rmcp_client,
tools_web_search: cfg.tools.as_ref().and_then(|t| t.web_search),
@@ -195,7 +197,7 @@ impl Features {
.experimental_sandbox_command_assessment,
experimental_use_freeform_apply_patch: config_profile
.experimental_use_freeform_apply_patch,
experimental_use_exec_command_tool: config_profile.experimental_use_exec_command_tool,
experimental_use_unified_exec_tool: config_profile.experimental_use_unified_exec_tool,
experimental_use_rmcp_client: config_profile.experimental_use_rmcp_client,
tools_web_search: config_profile.tools_web_search,
@@ -250,6 +252,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::StreamableShell,
key: "streamable_shell",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::RmcpClient,
key: "rmcp_client",

View File

@@ -17,6 +17,10 @@ const ALIASES: &[Alias] = &[
legacy_key: "experimental_use_unified_exec_tool",
feature: Feature::UnifiedExec,
},
Alias {
legacy_key: "experimental_use_exec_command_tool",
feature: Feature::StreamableShell,
},
Alias {
legacy_key: "experimental_use_rmcp_client",
feature: Feature::RmcpClient,
@@ -50,6 +54,7 @@ pub struct LegacyFeatureToggles {
pub include_apply_patch_tool: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_use_exec_command_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,
pub tools_web_search: Option<bool>,
@@ -76,6 +81,12 @@ impl LegacyFeatureToggles {
self.experimental_use_freeform_apply_patch,
"experimental_use_freeform_apply_patch",
);
set_if_some(
features,
Feature::StreamableShell,
self.experimental_use_exec_command_tool,
"experimental_use_exec_command_tool",
);
set_if_some(
features,
Feature::UnifiedExec,

View File

@@ -81,7 +81,6 @@ mod function_tool;
mod state;
mod tasks;
mod user_notification;
mod user_shell_command;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
@@ -100,12 +99,11 @@ pub use client_common::Prompt;
pub use client_common::REVIEW_PROMPT;
pub use client_common::ResponseEvent;
pub use client_common::ResponseStream;
pub use codex::compact::content_items_to_text;
pub use codex_protocol::models::ContentItem;
pub use codex_protocol::models::LocalShellAction;
pub use codex_protocol::models::LocalShellExecAction;
pub use codex_protocol::models::LocalShellStatus;
pub use codex_protocol::models::ResponseItem;
pub use compact::content_items_to_text;
pub use event_mapping::parse_turn_item;
pub mod compact;
pub mod otel_init;

View File

@@ -1,6 +1,5 @@
use crate::config::types::ReasoningSummaryFormat;
use crate::tools::handlers::apply_patch::ApplyPatchToolType;
use crate::tools::spec::ConfigShellToolType;
/// The `instructions` field in the payload sent to a model should always start
/// with this content.
@@ -30,6 +29,12 @@ pub struct ModelFamily {
// Define if we need a special handling of reasoning summary
pub reasoning_summary_format: ReasoningSummaryFormat,
// This should be set to true when the model expects a tool named
// "local_shell" to be provided. Its contract must be understood natively by
// the model such that its description can be omitted.
// See https://platform.openai.com/docs/guides/tools-local-shell
pub uses_local_shell_tool: bool,
/// Whether this model supports parallel tool calls when using the
/// Responses API.
pub supports_parallel_tool_calls: bool,
@@ -52,9 +57,6 @@ pub struct ModelFamily {
/// If the model family supports setting the verbosity level when using Responses API.
pub support_verbosity: bool,
/// Preferred shell tool type for this model family when features do not override it.
pub shell_type: ConfigShellToolType,
}
macro_rules! model_family {
@@ -62,20 +64,19 @@ macro_rules! model_family {
$slug:expr, $family:expr $(, $key:ident : $value:expr )* $(,)?
) => {{
// defaults
#[allow(unused_mut)]
let mut mf = ModelFamily {
slug: $slug.to_string(),
family: $family.to_string(),
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
supports_parallel_tool_calls: false,
apply_patch_tool_type: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
experimental_supported_tools: Vec::new(),
effective_context_window_percent: 95,
support_verbosity: false,
shell_type: ConfigShellToolType::Default,
};
// apply overrides
$(
@@ -104,8 +105,8 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, "codex-mini-latest",
supports_reasoning_summaries: true,
uses_local_shell_tool: true,
needs_special_apply_patch_instructions: true,
shell_type: ConfigShellToolType::Local,
)
} else if slug.starts_with("gpt-4.1") {
model_family!(
@@ -118,8 +119,6 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(slug, "gpt-4o", needs_special_apply_patch_instructions: true)
} else if slug.starts_with("gpt-3.5") {
model_family!(slug, "gpt-3.5", needs_special_apply_patch_instructions: true)
} else if slug.starts_with("porcupine") {
model_family!(slug, "porcupine", shell_type: ConfigShellToolType::UnifiedExec)
} else if slug.starts_with("test-gpt-5-codex") {
model_family!(
slug, slug,
@@ -182,12 +181,12 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
reasoning_summary_format: ReasoningSummaryFormat::None,
uses_local_shell_tool: false,
supports_parallel_tool_calls: false,
apply_patch_tool_type: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
experimental_supported_tools: Vec::new(),
effective_context_window_percent: 95,
support_verbosity: false,
shell_type: ConfigShellToolType::Default,
}
}

View File

@@ -14,11 +14,8 @@ use crate::exec::StdoutStream;
use crate::exec::execute_exec_env;
use crate::landlock::create_linux_sandbox_command_args;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
#[cfg(target_os = "macos")]
use crate::seatbelt::create_seatbelt_command_args;
#[cfg(target_os = "macos")]
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use crate::tools::sandboxing::SandboxablePreference;
@@ -59,9 +56,6 @@ pub enum SandboxPreference {
pub(crate) enum SandboxTransformError {
#[error("missing codex-linux-sandbox executable path")]
MissingLinuxSandboxExecutable,
#[cfg(not(target_os = "macos"))]
#[error("seatbelt sandbox is only available on macOS")]
SeatbeltUnavailable,
}
#[derive(Default)]
@@ -113,7 +107,6 @@ impl SandboxManager {
let (command, sandbox_env, arg0_override) = match sandbox {
SandboxType::None => (command, HashMap::new(), None),
#[cfg(target_os = "macos")]
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
@@ -124,8 +117,6 @@ impl SandboxManager {
full_command.append(&mut args);
(full_command, seatbelt_env, None)
}
#[cfg(not(target_os = "macos"))]
SandboxType::MacosSeatbelt => return Err(SandboxTransformError::SeatbeltUnavailable),
SandboxType::LinuxSeccomp => {
let exe = codex_linux_sandbox_exe
.ok_or(SandboxTransformError::MissingLinuxSandboxExecutable)?;

View File

@@ -1,7 +1,4 @@
#![cfg(target_os = "macos")]
use std::collections::HashMap;
use std::ffi::CStr;
use std::path::Path;
use std::path::PathBuf;
use tokio::process::Child;
@@ -12,7 +9,6 @@ use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");
/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
/// to defend against an attacker trying to inject a malicious version on the
@@ -48,24 +44,27 @@ pub(crate) fn create_seatbelt_command_args(
sandbox_policy: &SandboxPolicy,
sandbox_policy_cwd: &Path,
) -> Vec<String> {
let (file_write_policy, file_write_dir_params) = {
let (file_write_policy, extra_cli_args) = {
if sandbox_policy.has_full_disk_write_access() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::new(),
Vec::<String>::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
let mut writable_folder_policies: Vec<String> = Vec::new();
let mut file_write_params = Vec::new();
let mut cli_args: Vec<String> = Vec::new();
for (index, wr) in writable_roots.iter().enumerate() {
// Canonicalize to avoid mismatches like /var vs /private/var on macOS.
let canonical_root = wr.root.canonicalize().unwrap_or_else(|_| wr.root.clone());
let root_param = format!("WRITABLE_ROOT_{index}");
file_write_params.push((root_param.clone(), canonical_root));
cli_args.push(format!(
"-D{root_param}={}",
canonical_root.to_string_lossy()
));
if wr.read_only_subpaths.is_empty() {
writable_folder_policies.push(format!("(subpath (param \"{root_param}\"))"));
@@ -77,9 +76,9 @@ pub(crate) fn create_seatbelt_command_args(
for (subpath_index, ro) in wr.read_only_subpaths.iter().enumerate() {
let canonical_ro = ro.canonicalize().unwrap_or_else(|_| ro.clone());
let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
cli_args.push(format!("-D{ro_param}={}", canonical_ro.to_string_lossy()));
require_parts
.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
file_write_params.push((ro_param, canonical_ro));
}
let policy_component = format!("(require-all {} )", require_parts.join(" "));
writable_folder_policies.push(policy_component);
@@ -87,13 +86,13 @@ pub(crate) fn create_seatbelt_command_args(
}
if writable_folder_policies.is_empty() {
("".to_string(), Vec::new())
("".to_string(), Vec::<String>::new())
} else {
let file_write_policy = format!(
"(allow file-write*\n{}\n)",
writable_folder_policies.join(" ")
);
(file_write_policy, file_write_params)
(file_write_policy, cli_args)
}
}
};
@@ -106,7 +105,7 @@ pub(crate) fn create_seatbelt_command_args(
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
let network_policy = if sandbox_policy.has_full_network_access() {
MACOS_SEATBELT_NETWORK_POLICY
"(allow network-outbound)\n(allow network-inbound)\n(allow system-socket)"
} else {
""
};
@@ -115,49 +114,17 @@ pub(crate) fn create_seatbelt_command_args(
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
);
let dir_params = [file_write_dir_params, macos_dir_params()].concat();
let mut seatbelt_args: Vec<String> = vec!["-p".to_string(), full_policy];
let definition_args = dir_params
.into_iter()
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy()));
seatbelt_args.extend(definition_args);
seatbelt_args.extend(extra_cli_args);
seatbelt_args.push("--".to_string());
seatbelt_args.extend(command);
seatbelt_args
}
/// Wraps libc::confstr to return a String.
fn confstr(name: libc::c_int) -> Option<String> {
let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1];
let len = unsafe { libc::confstr(name, buf.as_mut_ptr(), buf.len()) };
if len == 0 {
return None;
}
// confstr guarantees NUL-termination when len > 0.
let cstr = unsafe { CStr::from_ptr(buf.as_ptr()) };
cstr.to_str().ok().map(ToString::to_string)
}
/// Wraps confstr to return a canonicalized PathBuf.
fn confstr_path(name: libc::c_int) -> Option<PathBuf> {
let s = confstr(name)?;
let path = PathBuf::from(s);
path.canonicalize().ok().or(Some(path))
}
fn macos_dir_params() -> Vec<(String, PathBuf)> {
if let Some(p) = confstr_path(libc::_CS_DARWIN_USER_CACHE_DIR) {
return vec![("DARWIN_USER_CACHE_DIR".to_string(), p)];
}
vec![]
}
#[cfg(test)]
mod tests {
use super::MACOS_SEATBELT_BASE_POLICY;
use super::create_seatbelt_command_args;
use super::macos_dir_params;
use crate::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::fs;
@@ -167,6 +134,11 @@ mod tests {
#[test]
fn create_seatbelt_args_with_read_only_git_subpath() {
if cfg!(target_os = "windows") {
// /tmp does not exist on Windows, so skip this test.
return;
}
// Create a temporary workspace with two writable roots: one containing
// a top-level .git directory and one without it.
let tmp = TempDir::new().expect("tempdir");
@@ -227,12 +199,6 @@ mod tests {
format!("-DWRITABLE_ROOT_2={}", cwd.to_string_lossy()),
];
expected_args.extend(
macos_dir_params()
.into_iter()
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
);
expected_args.extend(vec![
"--".to_string(),
"/bin/echo".to_string(),
@@ -244,6 +210,11 @@ mod tests {
#[test]
fn create_seatbelt_args_for_cwd_as_git_repo() {
if cfg!(target_os = "windows") {
// /tmp does not exist on Windows, so skip this test.
return;
}
// Create a temporary workspace with two writable roots: one containing
// a top-level .git directory and one without it.
let tmp = TempDir::new().expect("tempdir");
@@ -321,12 +292,6 @@ mod tests {
expected_args.push(format!("-DWRITABLE_ROOT_2={p}"));
}
expected_args.extend(
macos_dir_params()
.into_iter()
.map(|(key, value)| format!("-D{key}={value}", value = value.to_string_lossy())),
);
expected_args.extend(vec![
"--".to_string(),
"/bin/echo".to_string(),

View File

@@ -49,7 +49,6 @@
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")

View File

@@ -1,30 +0,0 @@
; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl
; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4
(allow network-outbound)
(allow network-inbound)
(allow system-socket)
(allow mach-lookup
; Used to look up the _CS_DARWIN_USER_CACHE_DIR in the sandbox.
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.system.opendirectoryd.membership")
; Communicate with the security server for TLS certificate information.
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd.agent")
; Read network configuration.
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd")
)
(allow sysctl-read
(sysctl-name-regex #"^net.routetable")
)
(allow file-write*
(subpath (param "DARWIN_USER_CACHE_DIR"))
)

View File

@@ -64,32 +64,24 @@ pub(crate) async fn spawn_child_async(
// any child processes that were spawned as part of a `"shell"` tool call
// to also be terminated.
#[cfg(unix)]
// This relies on prctl(2), so it only works on Linux.
#[cfg(target_os = "linux")]
unsafe {
#[cfg(target_os = "linux")]
let parent_pid = libc::getpid();
cmd.pre_exec(move || {
if libc::setpgid(0, 0) == -1 {
// This prctl call effectively requests, "deliver SIGTERM when my
// current parent dies."
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
return Err(std::io::Error::last_os_error());
}
// This relies on prctl(2), so it only works on Linux.
#[cfg(target_os = "linux")]
{
// This prctl call effectively requests, "deliver SIGTERM when my
// current parent dies."
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
return Err(std::io::Error::last_os_error());
}
// Though if there was a race condition and this pre_exec() block is
// run _after_ the parent (i.e., the Codex process) has already
// exited, then parent will be the closest configured "subreaper"
// ancestor process, or PID 1 (init). If the Codex process has exited
// already, so should the child process.
if libc::getppid() != parent_pid {
libc::raise(libc::SIGTERM);
}
// Though if there was a race condition and this pre_exec() block is
// run _after_ the parent (i.e., the Codex process) has already
// exited, then parent will be the closest configured "subreaper"
// ancestor process, or PID 1 (init). If the Codex process has exited
// already, so should the child process.
if libc::getppid() != parent_pid {
libc::raise(libc::SIGTERM);
}
Ok(())
});

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use tokio_util::sync::CancellationToken;
use crate::codex::TurnContext;
use crate::compact;
use crate::codex::compact;
use crate::state::TaskKind;
use codex_protocol::user_input::UserInput;

View File

@@ -75,12 +75,12 @@ async fn start_review_conversation(
// Avoid loading project docs; reviewer only needs findings
sub_agent_config.project_doc_max_bytes = 0;
// Carry over review-only feature restrictions so the delegate cannot
// re-enable blocked tools (web search, view image).
// re-enable blocked tools (web search, view image, streamable shell).
sub_agent_config
.features
.disable(crate::features::Feature::WebSearchRequest)
.disable(crate::features::Feature::ViewImageTool);
.disable(crate::features::Feature::ViewImageTool)
.disable(crate::features::Feature::StreamableShell);
// Set explicit review rubric for the sub-agent
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
(run_codex_conversation_one_shot(

View File

@@ -1,35 +1,28 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::user_input::UserInput;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tracing::error;
use uuid::Uuid;
use crate::codex::TurnContext;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::execute_exec_env;
use crate::exec_env::create_env;
use crate::parse_command::parse_command;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::SandboxPolicy;
use crate::protocol::TaskStartedEvent;
use crate::sandboxing::ExecEnv;
use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
use crate::user_shell_command::user_shell_command_record_item;
use crate::tools::context::ToolPayload;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolRouter;
use crate::turn_diff_tracker::TurnDiffTracker;
use super::SessionTask;
use super::SessionTaskContext;
const USER_SHELL_TOOL_NAME: &str = "local_shell";
#[derive(Clone)]
pub(crate) struct UserShellCommandTask {
command: String,
@@ -85,126 +78,34 @@ impl SessionTask for UserShellCommandTask {
}
};
let call_id = Uuid::new_v4().to_string();
let raw_command = self.command.clone();
let parsed_cmd = parse_command(&shell_invocation);
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: call_id.clone(),
command: shell_invocation.clone(),
cwd: turn_context.cwd.clone(),
parsed_cmd,
is_user_shell_command: true,
}),
)
.await;
let exec_env = ExecEnv {
let params = ShellToolCallParams {
command: shell_invocation,
cwd: turn_context.cwd.clone(),
env: create_env(&turn_context.shell_environment_policy),
workdir: None,
timeout_ms: None,
sandbox: SandboxType::None,
with_escalated_permissions: None,
justification: None,
arg0: None,
};
let stdout_stream = Some(StdoutStream {
sub_id: turn_context.sub_id.clone(),
call_id: call_id.clone(),
tx_event: session.get_tx_event(),
});
let tool_call = ToolCall {
tool_name: USER_SHELL_TOOL_NAME.to_string(),
call_id: Uuid::new_v4().to_string(),
payload: ToolPayload::LocalShell { params },
};
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
.or_cancel(&cancellation_token)
.await;
let router = Arc::new(ToolRouter::from_config(&turn_context.tools_config, None));
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
let runtime = ToolCallRuntime::new(
Arc::clone(&router),
Arc::clone(&session),
Arc::clone(&turn_context),
Arc::clone(&tracker),
);
match exec_result {
Err(CancelErr::Cancelled) => {
let aborted_message = "command aborted by user".to_string();
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(aborted_message.clone()),
aggregated_output: StreamOutput::new(aborted_message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
stdout: String::new(),
stderr: aborted_message.clone(),
aggregated_output: aborted_message.clone(),
exit_code: -1,
duration: Duration::ZERO,
formatted_output: aborted_message,
}),
)
.await;
}
Ok(Ok(output)) => {
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: call_id.clone(),
stdout: output.stdout.text.clone(),
stderr: output.stderr.text.clone(),
aggregated_output: output.aggregated_output.text.clone(),
exit_code: output.exit_code,
duration: output.duration,
formatted_output: format_exec_output_str(&output),
}),
)
.await;
let output_items = [user_shell_command_record_item(&raw_command, &output)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
}
Ok(Err(err)) => {
error!("user shell command failed: {err:?}");
let message = format!("execution error: {err:?}");
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(message.clone()),
aggregated_output: StreamOutput::new(message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
stdout: exec_output.stdout.text.clone(),
stderr: exec_output.stderr.text.clone(),
aggregated_output: exec_output.aggregated_output.text.clone(),
exit_code: exec_output.exit_code,
duration: exec_output.duration,
formatted_output: format_exec_output_str(&exec_output),
}),
)
.await;
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
}
if let Err(err) = runtime
.handle_tool_call(tool_call, cancellation_token)
.await
{
error!("user shell command failed: {err:?}");
}
None
}

View File

@@ -1,7 +1,8 @@
use std::path::PathBuf;
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use serde::Serialize;
use crate::function_tool::FunctionCallError;
use crate::protocol::EventMsg;
@@ -26,8 +27,6 @@ pub struct UnifiedExecHandler;
#[derive(Debug, Deserialize)]
struct ExecCommandArgs {
cmd: String,
#[serde(default)]
workdir: Option<String>,
#[serde(default = "default_shell")]
shell: String,
#[serde(default = "default_login")]
@@ -100,12 +99,6 @@ impl ToolHandler for UnifiedExecHandler {
"failed to parse exec_command arguments: {err:?}"
))
})?;
let workdir = args
.workdir
.as_deref()
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
let event_ctx = ToolEventCtx::new(
context.session.as_ref(),
@@ -113,7 +106,8 @@ impl ToolHandler for UnifiedExecHandler {
&context.call_id,
None,
);
let emitter = ToolEmitter::unified_exec(args.cmd.clone(), cwd.clone(), true);
let emitter =
ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true);
emitter.emit(event_ctx, ToolEventStage::Begin).await;
manager
@@ -124,7 +118,6 @@ impl ToolHandler for UnifiedExecHandler {
login: args.login,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
workdir,
},
&context,
)
@@ -170,7 +163,11 @@ impl ToolHandler for UnifiedExecHandler {
.await;
}
let content = format_response(&response);
let content = serialize_response(&response).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize unified exec output: {err:?}"
))
})?;
Ok(ToolOutput::Function {
content,
@@ -180,30 +177,32 @@ impl ToolHandler for UnifiedExecHandler {
}
}
fn format_response(response: &UnifiedExecResponse) -> String {
let mut sections = Vec::new();
if !response.chunk_id.is_empty() {
sections.push(format!("Chunk ID: {}", response.chunk_id));
}
let wall_time_seconds = response.wall_time.as_secs_f64();
sections.push(format!("Wall time: {wall_time_seconds:.4} seconds"));
if let Some(exit_code) = response.exit_code {
sections.push(format!("Process exited with code {exit_code}"));
}
if let Some(session_id) = response.session_id {
sections.push(format!("Process running with session ID {session_id}"));
}
if let Some(original_token_count) = response.original_token_count {
sections.push(format!("Original token count: {original_token_count}"));
}
sections.push("Output:".to_string());
sections.push(response.output.clone());
sections.join("\n")
#[derive(Serialize)]
struct SerializedUnifiedExecResponse<'a> {
chunk_id: &'a str,
wall_time_seconds: f64,
output: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
original_token_count: Option<usize>,
}
fn serialize_response(response: &UnifiedExecResponse) -> Result<String, serde_json::Error> {
let payload = SerializedUnifiedExecResponse {
chunk_id: &response.chunk_id,
wall_time_seconds: duration_to_seconds(response.wall_time),
output: &response.output,
session_id: response.session_id,
exit_code: response.exit_code,
original_token_count: response.original_token_count,
};
serde_json::to_string(&payload)
}
fn duration_to_seconds(duration: Duration) -> f64 {
duration.as_secs_f64()
}

View File

@@ -4,7 +4,8 @@ Runtime: shell
Executes shell requests under the orchestrator: asks for approval when needed,
builds a CommandSpec, and runs it under the current SandboxAttempt.
*/
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
use crate::command_safety::is_dangerous_command::command_might_be_dangerous;
use crate::command_safety::is_safe_command::is_known_safe_command;
use crate::exec::ExecToolCallOutput;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::execute_env;
@@ -120,12 +121,28 @@ impl Approvable<ShellRequest> for ShellRuntime {
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
requires_initial_appoval(
policy,
sandbox_policy,
&req.command,
req.with_escalated_permissions.unwrap_or(false),
)
if is_known_safe_command(&req.command) {
return false;
}
match policy {
AskForApproval::Never | AskForApproval::OnFailure => false,
AskForApproval::OnRequest => {
// In DangerFullAccess, only prompt if the command looks dangerous.
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
return command_might_be_dangerous(&req.command);
}
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
// nonescalated, nondangerous commands — let the sandbox enforce
// restrictions (e.g., block network/write) without a user prompt.
let wants_escalation = req.with_escalated_permissions.unwrap_or(false);
if wants_escalation {
return true;
}
command_might_be_dangerous(&req.command)
}
AskForApproval::UnlessTrusted => !is_known_safe_command(&req.command),
}
}
fn wants_escalated_first_attempt(&self, req: &ShellRequest) -> bool {

View File

@@ -1,4 +1,3 @@
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
/*
Runtime: unified exec
@@ -22,9 +21,7 @@ use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecSession;
use crate::unified_exec::UnifiedExecSessionManager;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -109,15 +106,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
.await
})
}
fn wants_initial_approval(
&self,
req: &UnifiedExecRequest,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
requires_initial_appoval(policy, sandbox_policy, &req.command, false)
}
}
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRuntime<'a> {

View File

@@ -15,11 +15,11 @@ use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Clone)]
pub enum ConfigShellToolType {
Default,
Local,
UnifiedExec,
Streamable,
}
#[derive(Debug, Clone)]
@@ -28,6 +28,7 @@ pub(crate) struct ToolsConfig {
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_request: bool,
pub include_view_image_tool: bool,
pub experimental_unified_exec_tool: bool,
pub experimental_supported_tools: Vec<String>,
}
@@ -42,14 +43,18 @@ impl ToolsConfig {
model_family,
features,
} = params;
let use_streamable_shell_tool = features.enabled(Feature::StreamableShell);
let experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_web_search_request = features.enabled(Feature::WebSearchRequest);
let include_view_image_tool = features.enabled(Feature::ViewImageTool);
let shell_type = if features.enabled(Feature::UnifiedExec) {
ConfigShellToolType::UnifiedExec
let shell_type = if use_streamable_shell_tool {
ConfigShellToolType::Streamable
} else if model_family.uses_local_shell_tool {
ConfigShellToolType::Local
} else {
model_family.shell_type.clone()
ConfigShellToolType::Default
};
let apply_patch_tool_type = match model_family.apply_patch_tool_type {
@@ -69,6 +74,7 @@ impl ToolsConfig {
apply_patch_tool_type,
web_search_request: include_web_search_request,
include_view_image_tool,
experimental_unified_exec_tool,
experimental_supported_tools: model_family.experimental_supported_tools.clone(),
}
}
@@ -138,15 +144,6 @@ fn create_exec_command_tool() -> ToolSpec {
description: Some("Shell command to execute.".to_string()),
},
);
properties.insert(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
);
properties.insert(
"shell".to_string(),
JsonSchema::String {
@@ -181,11 +178,8 @@ fn create_exec_command_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
description:
concat!(
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n",
"- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."
)
.to_string(),
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
@@ -277,12 +271,7 @@ fn create_shell_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "shell".to_string(),
description: concat!(
"Runs a shell command and returns its output.\n",
"- The value of `command` will be passed to execvp(). Most terminal commands should be prefixed with [`bash`, `-lc`].\n",
"- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.",
)
.to_string(),
description: "Runs a shell command and returns its output.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
@@ -897,6 +886,15 @@ pub(crate) fn build_specs(
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
let use_unified_exec = config.experimental_unified_exec_tool
|| matches!(config.shell_type, ConfigShellToolType::Streamable);
if use_unified_exec {
builder.push_spec(create_exec_command_tool());
builder.push_spec(create_write_stdin_tool());
builder.register_handler("exec_command", unified_exec_handler.clone());
builder.register_handler("write_stdin", unified_exec_handler);
}
match &config.shell_type {
ConfigShellToolType::Default => {
builder.push_spec(create_shell_tool());
@@ -904,11 +902,8 @@ pub(crate) fn build_specs(
ConfigShellToolType::Local => {
builder.push_spec(ToolSpec::LocalShell {});
}
ConfigShellToolType::UnifiedExec => {
builder.push_spec(create_exec_command_tool());
builder.push_spec(create_write_stdin_tool());
builder.register_handler("exec_command", unified_exec_handler.clone());
builder.register_handler("write_stdin", unified_exec_handler);
ConfigShellToolType::Streamable => {
// Already handled by use_unified_exec.
}
}
@@ -1050,7 +1045,7 @@ mod tests {
match config.shell_type {
ConfigShellToolType::Default => Some("shell"),
ConfigShellToolType::Local => Some("local_shell"),
ConfigShellToolType::UnifiedExec => None,
ConfigShellToolType::Streamable => None,
}
}
@@ -1100,7 +1095,7 @@ mod tests {
}
#[test]
fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
fn test_full_toolset_specs_for_gpt5_codex() {
let model_family = find_family_for_model("gpt-5-codex")
.expect("gpt-5-codex should be a valid model family");
let mut features = Features::with_defaults();
@@ -1134,6 +1129,7 @@ mod tests {
for spec in [
create_exec_command_tool(),
create_write_stdin_tool(),
create_shell_tool(),
create_list_mcp_resources_tool(),
create_list_mcp_resource_templates_tool(),
create_read_mcp_resource_tool(),
@@ -1160,106 +1156,32 @@ mod tests {
}
}
fn assert_model_tools(model_family: &str, features: &Features, expected_tools: &[&str]) {
let model_family = find_family_for_model(model_family)
.unwrap_or_else(|| panic!("{model_family} should be a valid model family"));
#[test]
fn test_build_specs_contains_expected_basics() {
let model_family = find_family_for_model("codex-mini-latest")
.expect("codex-mini-latest should be a valid model family");
let mut features = Features::with_defaults();
features.enable(Feature::WebSearchRequest);
features.enable(Feature::UnifiedExec);
let config = ToolsConfig::new(&ToolsConfigParams {
model_family: &model_family,
features,
features: &features,
});
let (tools, _) = build_specs(&config, Some(HashMap::new())).build();
let tool_names = tools.iter().map(|t| t.spec.name()).collect::<Vec<_>>();
assert_eq!(&tool_names, &expected_tools,);
}
#[test]
fn test_build_specs_gpt5_codex_default() {
assert_model_tools(
"gpt-5-codex",
&Features::with_defaults(),
&[
"shell",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"apply_patch",
"view_image",
],
);
}
#[test]
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
assert_model_tools(
"gpt-5-codex",
Features::with_defaults()
.enable(Feature::UnifiedExec)
.enable(Feature::WebSearchRequest),
assert_eq!(
&tool_names,
&[
"exec_command",
"write_stdin",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"apply_patch",
"web_search",
"view_image",
],
);
}
#[test]
fn test_codex_mini_defaults() {
assert_model_tools(
"codex-mini-latest",
&Features::with_defaults(),
&[
"local_shell",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"view_image",
],
);
}
#[test]
fn test_porcupine_defaults() {
assert_model_tools(
"porcupine",
&Features::with_defaults(),
&[
"exec_command",
"write_stdin",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"view_image",
],
);
}
#[test]
fn test_codex_mini_unified_exec_web_search() {
assert_model_tools(
"codex-mini-latest",
Features::with_defaults()
.enable(Feature::UnifiedExec)
.enable(Feature::WebSearchRequest),
&[
"exec_command",
"write_stdin",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"web_search",
"view_image",
],
]
);
}
@@ -1734,7 +1656,7 @@ mod tests {
};
assert_eq!(name, "shell");
let expected = "Runs a shell command and returns its output.\n- The value of `command` will be passed to execvp(). Most terminal commands should be prefixed with [`bash`, `-lc`].\n- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.";
let expected = "Runs a shell command and returns its output.";
assert_eq!(description, expected);
}

View File

@@ -70,7 +70,6 @@ pub(crate) struct ExecCommandRequest<'a> {
pub login: bool,
pub yield_time_ms: Option<u64>,
pub max_output_tokens: Option<usize>,
pub workdir: Option<PathBuf>,
}
#[derive(Debug)]
@@ -200,7 +199,6 @@ mod tests {
login: true,
yield_time_ms,
max_output_tokens: None,
workdir: None,
},
&context,
)

View File

@@ -1,4 +1,3 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
@@ -39,10 +38,6 @@ impl UnifiedExecSessionManager {
request: ExecCommandRequest<'_>,
context: &UnifiedExecContext,
) -> Result<UnifiedExecResponse, UnifiedExecError> {
let cwd = request
.workdir
.clone()
.unwrap_or_else(|| context.turn.cwd.clone());
let shell_flag = if request.login { "-lc" } else { "-c" };
let command = vec![
request.shell.to_string(),
@@ -50,9 +45,7 @@ impl UnifiedExecSessionManager {
request.command.to_string(),
];
let session = self
.open_session_with_sandbox(command, cwd.clone(), context)
.await?;
let session = self.open_session_with_sandbox(command, context).await?;
let max_tokens = resolve_max_tokens(request.max_output_tokens);
let yield_time_ms =
@@ -73,7 +66,7 @@ impl UnifiedExecSessionManager {
None
} else {
Some(
self.store_session(session, context, request.command, cwd.clone(), start)
self.store_session(session, context, request.command, start)
.await,
)
};
@@ -94,7 +87,6 @@ impl UnifiedExecSessionManager {
Self::emit_exec_end_from_context(
context,
request.command.to_string(),
cwd,
response.output.clone(),
exit,
response.wall_time,
@@ -219,7 +211,6 @@ impl UnifiedExecSessionManager {
session: UnifiedExecSession,
context: &UnifiedExecContext,
command: &str,
cwd: PathBuf,
started_at: Instant,
) -> i32 {
let session_id = self
@@ -231,7 +222,7 @@ impl UnifiedExecSessionManager {
turn_ref: Arc::clone(&context.turn),
call_id: context.call_id.clone(),
command: command.to_string(),
cwd,
cwd: context.turn.cwd.clone(),
started_at,
};
self.sessions.lock().await.insert(session_id, entry);
@@ -267,7 +258,6 @@ impl UnifiedExecSessionManager {
async fn emit_exec_end_from_context(
context: &UnifiedExecContext,
command: String,
cwd: PathBuf,
aggregated_output: String,
exit_code: i32,
duration: Duration,
@@ -286,7 +276,7 @@ impl UnifiedExecSessionManager {
&context.call_id,
None,
);
let emitter = ToolEmitter::unified_exec(command, cwd, true);
let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true);
emitter
.emit(event_ctx, ToolEventStage::Success(output))
.await;
@@ -310,14 +300,13 @@ impl UnifiedExecSessionManager {
pub(super) async fn open_session_with_sandbox(
&self,
command: Vec<String>,
cwd: PathBuf,
context: &UnifiedExecContext,
) -> Result<UnifiedExecSession, UnifiedExecError> {
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let req = UnifiedExecToolRequest::new(
command,
cwd,
context.turn.cwd.clone(),
create_env(&context.turn.shell_environment_policy),
);
let tool_ctx = ToolCtx {

View File

@@ -1,108 +0,0 @@
use std::time::Duration;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use crate::exec::ExecToolCallOutput;
use crate::tools::format_exec_output_str;
pub const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
pub const USER_SHELL_COMMAND_CLOSE: &str = "</user_shell_command>";
pub fn is_user_shell_command_text(text: &str) -> bool {
let trimmed = text.trim_start();
let lowered = trimmed.to_ascii_lowercase();
lowered.starts_with(USER_SHELL_COMMAND_OPEN)
}
fn format_duration_line(duration: Duration) -> String {
let duration_seconds = duration.as_secs_f64();
format!("Duration: {duration_seconds:.4} seconds")
}
fn format_user_shell_command_body(command: &str, exec_output: &ExecToolCallOutput) -> String {
let mut sections = Vec::new();
sections.push("<command>".to_string());
sections.push(command.to_string());
sections.push("</command>".to_string());
sections.push("<result>".to_string());
sections.push(format!("Exit code: {}", exec_output.exit_code));
sections.push(format_duration_line(exec_output.duration));
sections.push("Output:".to_string());
sections.push(format_exec_output_str(exec_output));
sections.push("</result>".to_string());
sections.join("\n")
}
pub fn format_user_shell_command_record(command: &str, exec_output: &ExecToolCallOutput) -> String {
let body = format_user_shell_command_body(command, exec_output);
format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}")
}
pub fn user_shell_command_record_item(
command: &str,
exec_output: &ExecToolCallOutput,
) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format_user_shell_command_record(command, exec_output),
}],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec::StreamOutput;
use pretty_assertions::assert_eq;
#[test]
fn detects_user_shell_command_text_variants() {
assert!(is_user_shell_command_text(
"<user_shell_command>\necho hi\n</user_shell_command>"
));
assert!(!is_user_shell_command_text("echo hi"));
}
#[test]
fn formats_basic_record() {
let exec_output = ExecToolCallOutput {
exit_code: 0,
stdout: StreamOutput::new("hi".to_string()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new("hi".to_string()),
duration: Duration::from_secs(1),
timed_out: false,
};
let item = user_shell_command_record_item("echo hi", &exec_output);
let ResponseItem::Message { content, .. } = item else {
panic!("expected message");
};
let [ContentItem::InputText { text }] = content.as_slice() else {
panic!("expected input text");
};
assert_eq!(
text,
"<user_shell_command>\n<command>\necho hi\n</command>\n<result>\nExit code: 0\nDuration: 1.0000 seconds\nOutput:\nhi\n</result>\n</user_shell_command>"
);
}
#[test]
fn uses_aggregated_output_over_streams() {
let exec_output = ExecToolCallOutput {
exit_code: 42,
stdout: StreamOutput::new("stdout-only".to_string()),
stderr: StreamOutput::new("stderr-only".to_string()),
aggregated_output: StreamOutput::new("combined output wins".to_string()),
duration: Duration::from_millis(120),
timed_out: false,
};
let record = format_user_shell_command_record("false", &exec_output);
assert_eq!(
record,
"<user_shell_command>\n<command>\nfalse\n</command>\n<result>\nExit code: 42\nDuration: 0.1200 seconds\nOutput:\ncombined output wins\n</result>\n</user_shell_command>"
);
}
}

View File

@@ -61,18 +61,6 @@ impl ResponsesRequest {
self.0.body_json().unwrap()
}
/// Returns all `input_text` spans from `message` inputs for the provided role.
pub fn message_input_texts(&self, role: &str) -> Vec<String> {
self.inputs_of_type("message")
.into_iter()
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array).cloned())
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
.collect()
}
pub fn input(&self) -> Vec<Value> {
self.0.body_json::<Value>().unwrap()["input"]
.as_array()

View File

@@ -13,7 +13,7 @@ use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use regex_lite::Regex;
use serde_json::json;
@@ -42,6 +42,8 @@ async fn interrupt_long_running_tool_emits_turn_aborted() {
let codex = test_codex().build(&server).await.unwrap().codex;
let wait_timeout = Duration::from_secs(5);
// Kick off a turn that triggers the function call.
codex
.submit(Op::UserInput {
@@ -53,12 +55,22 @@ async fn interrupt_long_running_tool_emits_turn_aborted() {
.unwrap();
// Wait until the exec begins to avoid a race, then interrupt.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecCommandBegin(_)),
wait_timeout,
)
.await;
codex.submit(Op::Interrupt).await.unwrap();
// Expect TurnAborted soon after.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TurnAborted(_)),
wait_timeout,
)
.await;
}
/// After an interrupt we expect the next request to the model to include both
@@ -95,6 +107,8 @@ async fn interrupt_tool_records_history_entries() {
let fixture = test_codex().build(&server).await.unwrap();
let codex = Arc::clone(&fixture.codex);
let wait_timeout = Duration::from_millis(100);
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
@@ -104,12 +118,22 @@ async fn interrupt_tool_records_history_entries() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandBegin(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecCommandBegin(_)),
wait_timeout,
)
.await;
tokio::time::sleep(Duration::from_secs_f32(0.1)).await;
codex.submit(Op::Interrupt).await.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TurnAborted(_)),
wait_timeout,
)
.await;
codex
.submit(Op::UserInput {
@@ -120,7 +144,12 @@ async fn interrupt_tool_records_history_entries() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
wait_timeout,
)
.await;
let requests = response_mock.requests();
assert!(

View File

@@ -1,7 +1,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Result;
use codex_core::features::Feature;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskForApproval;
@@ -24,13 +23,14 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -73,9 +73,6 @@ enum ActionKind {
RunCommand {
command: &'static [&'static str],
},
RunUnifiedExecCommand {
command: &'static str,
},
ApplyPatchFunction {
target: TargetPath,
content: &'static str,
@@ -139,17 +136,6 @@ impl ActionKind {
let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?;
Ok((event, Some(command)))
}
ActionKind::RunUnifiedExecCommand { command } => {
let event = exec_command_event(call_id, command, Some(1000))?;
Ok((
event,
Some(vec![
"/bin/bash".to_string(),
"-lc".to_string(),
command.to_string(),
]),
))
}
ActionKind::ApplyPatchFunction { target, content } => {
let (path, patch_path) = target.resolve_for_patch(test);
let _ = fs::remove_file(&path);
@@ -199,17 +185,6 @@ fn shell_event(
Ok(ev_function_call(call_id, "shell", &args_str))
}
fn exec_command_event(call_id: &str, cmd: &str, yield_time_ms: Option<u64>) -> Result<Value> {
let mut args = json!({
"cmd": cmd.to_string(),
});
if let Some(yield_time_ms) = yield_time_ms {
args["yield_time_ms"] = json!(yield_time_ms);
}
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
#[derive(Clone)]
enum Expectation {
FileCreated {
@@ -233,9 +208,6 @@ enum Expectation {
CommandSuccess {
stdout_contains: &'static str,
},
CommandFailure {
output_contains: &'static str,
},
}
impl Expectation {
@@ -367,19 +339,6 @@ impl Expectation {
result.stdout
);
}
Expectation::CommandFailure { output_contains } => {
assert_ne!(
result.exit_code,
Some(0),
"expected non-zero exit for command failure: {}",
result.stdout
);
assert!(
result.stdout.contains(output_contains),
"command failure stderr missing {output_contains:?}: {}",
result.stdout
);
}
}
Ok(())
}
@@ -405,7 +364,7 @@ struct ScenarioSpec {
sandbox_policy: SandboxPolicy,
action: ActionKind,
with_escalated_permissions: bool,
features: Vec<Feature>,
requires_apply_patch_tool: bool,
model_override: Option<&'static str>,
outcome: Outcome,
expectation: Expectation,
@@ -453,24 +412,10 @@ fn parse_result(item: &Value) -> CommandResult {
let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
CommandResult { exit_code, stdout }
}
Err(_) => {
let regex =
Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
// parse freeform output
if let Some(captures) = regex.captures(output_str) {
let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
let output = captures.get(2).unwrap().as_str();
CommandResult {
exit_code: Some(exit_code),
stdout: output.to_string(),
}
} else {
CommandResult {
exit_code: None,
stdout: output_str.to_string(),
}
}
}
Err(_) => CommandResult {
exit_code: None,
stdout: output_str.to_string(),
},
}
}
@@ -478,12 +423,16 @@ async fn expect_exec_approval(
test: &TestCodex,
expected_command: &[String],
) -> ExecApprovalRequestEvent {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_)
)
})
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_)
)
},
Duration::from_secs(5),
)
.await;
match event {
@@ -500,12 +449,16 @@ async fn expect_patch_approval(
test: &TestCodex,
expected_call_id: &str,
) -> ApplyPatchApprovalRequestEvent {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_)
)
})
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TaskComplete(_)
)
},
Duration::from_secs(5),
)
.await;
match event {
@@ -519,12 +472,16 @@ async fn expect_patch_approval(
}
async fn wait_for_completion_without_approval(test: &TestCodex) {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_)
)
})
let event = wait_for_event_with_timeout(
&test.codex,
|event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TaskComplete(_)
)
},
Duration::from_secs(5),
)
.await;
match event {
@@ -563,7 +520,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-on-request",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -580,7 +537,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "danger-network-ok",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccess {
@@ -595,7 +552,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
command: &["echo", "trusted-unless"],
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
@@ -611,7 +568,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-on-failure",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -628,7 +585,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -648,7 +605,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -665,7 +622,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-approval",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -684,7 +641,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
command: &["echo", "trusted-read-only"],
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
@@ -700,7 +657,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "should-not-see",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
@@ -714,7 +671,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "should-not-write",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Denied,
@@ -735,7 +692,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-on-failure",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -755,7 +712,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "read-only-network-ok",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -774,7 +731,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "shell-apply-patch",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: None,
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -794,7 +751,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-apply-patch",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::Auto,
expectation: Expectation::PatchApplied {
@@ -811,7 +768,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-danger",
},
with_escalated_permissions: false,
features: vec![Feature::ApplyPatchFreeform],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::Auto,
expectation: Expectation::PatchApplied {
@@ -828,7 +785,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-outside",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -848,7 +805,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-outside-denied",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Denied,
@@ -868,7 +825,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "shell-patch-outside",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: None,
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -888,7 +845,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -908,7 +865,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
@@ -927,7 +884,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -947,7 +904,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
@@ -967,7 +924,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
command: &["echo", "trusted-never"],
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
@@ -983,7 +940,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-on-request",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -1000,7 +957,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "workspace-network-blocked",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
@@ -1014,7 +971,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-on-request-outside",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -1034,7 +991,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "workspace-network-ok",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccess {
@@ -1051,7 +1008,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-on-failure",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -1071,7 +1028,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -1091,7 +1048,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
@@ -1103,39 +1060,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
},
},
},
ScenarioSpec {
name: "unified exec on request no approval for safe command",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunUnifiedExecCommand {
command: "echo \"hello unified exec\"",
},
with_escalated_permissions: false,
features: vec![Feature::UnifiedExec],
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
stdout_contains: "hello unified exec",
},
},
ScenarioSpec {
name: "unified exec on request requires approval unless trusted",
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunUnifiedExecCommand {
command: "git reset --hard",
},
with_escalated_permissions: false,
features: vec![Feature::UnifiedExec],
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Denied,
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
]
}
@@ -1155,7 +1079,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
let server = start_mock_server().await;
let approval_policy = scenario.approval_policy;
let sandbox_policy = scenario.sandbox_policy.clone();
let features = scenario.features.clone();
let requires_apply_patch_tool = scenario.requires_apply_patch_tool;
let model_override = scenario.model_override;
let mut builder = test_codex().with_config(move |config| {
@@ -1165,8 +1089,8 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
config.model = model.to_string();
config.model_family =
find_family_for_model(model).expect("model should map to a known family");
for feature in features {
config.features.enable(feature);
if requires_apply_patch_tool {
config.include_apply_patch_tool = true;
}
});
let test = builder.build(&server).await?;

View File

@@ -32,6 +32,7 @@ use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use futures::StreamExt;
use serde_json::json;
use std::io::Write;
@@ -1116,20 +1117,26 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res
})
.await?;
let token_event = wait_for_event(&codex, |event| {
matches!(
event,
EventMsg::TokenCount(payload)
if payload.info.as_ref().is_some_and(|info| {
info.model_context_window == Some(info.total_token_usage.total_tokens)
&& info.total_token_usage.total_tokens > 0
})
)
})
use std::time::Duration;
let token_event = wait_for_event_with_timeout(
&codex,
|event| {
matches!(
event,
EventMsg::TokenCount(payload)
if payload.info.as_ref().is_some_and(|info| {
info.model_context_window == Some(info.total_token_usage.total_tokens)
&& info.total_token_usage.total_tokens > 0
})
)
},
Duration::from_secs(5),
)
.await;
let EventMsg::TokenCount(token_payload) = token_event else {
unreachable!("wait_for_event returned unexpected event");
unreachable!("wait_for_event_with_timeout returned unexpected event");
};
let info = token_payload

View File

@@ -54,7 +54,7 @@ const COMPACT_PROMPT_MARKER: &str =
pub(super) const TEST_COMPACT_PROMPT: &str =
"You are performing a CONTEXT CHECKPOINT COMPACTION for a tool.\nTest-only compact prompt.";
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.";
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.";
fn auto_summary(summary: &str) -> String {
summary.to_string()

View File

@@ -18,11 +18,12 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::UnifiedExec);
config
.features
.record_legacy_usage_force("use_experimental_unified_exec_tool", Feature::UnifiedExec);
config.use_experimental_unified_exec_tool = true;
config.features.enable(Feature::StreamableShell);
config.features.record_legacy_usage_force(
"experimental_use_exec_command_tool",
Feature::StreamableShell,
);
config.use_experimental_streamable_shell_tool = true;
});
let TestCodex { codex, .. } = builder.build(&server).await?;
@@ -36,13 +37,13 @@ async fn emits_deprecation_notice_for_legacy_feature_flag() -> anyhow::Result<()
let DeprecationNoticeEvent { summary, details } = notice;
assert_eq!(
summary,
"`use_experimental_unified_exec_tool` is deprecated. Use `unified_exec` instead."
"`experimental_use_exec_command_tool` is deprecated. Use `streamable_shell` instead."
.to_string(),
);
assert_eq!(
details.as_deref(),
Some(
"Enable it with `--enable unified_exec` or `[features].unified_exec` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details."
"Enable it with `--enable streamable_shell` or `[features].streamable_shell` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details."
),
);

View File

@@ -26,7 +26,6 @@ mod model_overrides;
mod model_tools;
mod otel;
mod prompt_caching;
mod quota_exceeded;
mod read_file;
mod resume;
mod review;

View File

@@ -60,6 +60,7 @@ async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
config.features.disable(Feature::ApplyPatchFreeform);
config.features.disable(Feature::ViewImageTool);
config.features.disable(Feature::WebSearchRequest);
config.features.disable(Feature::StreamableShell);
config.features.disable(Feature::UnifiedExec);
let conversation_manager =

View File

@@ -14,7 +14,8 @@ use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use std::time::Duration;
use tracing_test::traced_test;
use core_test_support::responses::ev_local_shell_call;
@@ -37,7 +38,12 @@ async fn responses_api_emits_api_request_event() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -78,7 +84,12 @@ async fn process_sse_emits_tracing_for_output_item() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -117,7 +128,12 @@ async fn process_sse_emits_failed_event_on_parse_error() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -157,7 +173,12 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -209,7 +230,12 @@ async fn process_sse_failed_event_records_response_error_message() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -259,7 +285,12 @@ async fn process_sse_failed_event_logs_parse_error() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -304,7 +335,12 @@ async fn process_sse_failed_event_logs_missing_error() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -349,7 +385,12 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -399,7 +440,12 @@ async fn process_sse_emits_completed_telemetry() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
lines
@@ -454,7 +500,12 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
let line = lines
@@ -513,7 +564,12 @@ async fn handle_response_item_records_tool_result_for_function_call() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
let line = lines
@@ -582,7 +638,12 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
let line = lines
@@ -635,7 +696,12 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(|lines: &[&str]| {
let line = lines
@@ -728,7 +794,12 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"auto_config_call",
@@ -769,7 +840,12 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecApprovalRequest(_)),
Duration::from_secs(5),
)
.await;
codex
.submit(Op::ExecApproval {
@@ -779,7 +855,12 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"user_approved_call",
@@ -821,7 +902,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecApprovalRequest(_)),
Duration::from_secs(5),
)
.await;
codex
.submit(Op::ExecApproval {
@@ -831,7 +917,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"user_approved_session_call",
@@ -873,7 +964,12 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecApprovalRequest(_)),
Duration::from_secs(5),
)
.await;
codex
.submit(Op::ExecApproval {
@@ -883,7 +979,12 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"sandbox_retry_call",
@@ -925,7 +1026,12 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecApprovalRequest(_)),
Duration::from_secs(5),
)
.await;
codex
.submit(Op::ExecApproval {
@@ -935,7 +1041,12 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"user_denied_call",
@@ -977,7 +1088,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecApprovalRequest(_)),
Duration::from_secs(5),
)
.await;
codex
.submit(Op::ExecApproval {
@@ -987,7 +1103,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"sandbox_session_call",
@@ -1029,7 +1150,12 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecApprovalRequest(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::ExecApprovalRequest(_)),
Duration::from_secs(5),
)
.await;
codex
.submit(Op::ExecApproval {
@@ -1039,7 +1165,12 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TokenCount(_)),
Duration::from_secs(5),
)
.await;
logs_assert(tool_decision_assertion(
"sandbox_deny_call",

View File

@@ -1,72 +0,0 @@
use anyhow::Result;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn quota_exceeded_emits_single_error_event() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex();
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
json!({
"type": "response.failed",
"response": {
"id": "resp-1",
"error": {
"code": "insufficient_quota",
"message": "You exceeded your current quota, please check your plan and billing details."
}
}
}),
]),
)
.await;
let test = builder.build(&server).await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "quota?".into(),
}],
})
.await
.unwrap();
let mut error_events = 0;
loop {
let event = wait_for_event(&test.codex, |_| true).await;
match event {
EventMsg::Error(err) => {
error_events += 1;
assert_eq!(
err.message,
"Quota exceeded. Check your plan and billing details."
);
}
EventMsg::TaskComplete(_) => break,
_ => {}
}
}
assert_eq!(error_events, 1, "expected exactly one Codex:Error event");
Ok(())
}

View File

@@ -23,6 +23,7 @@ use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id_from_str;
use core_test_support::skip_if_no_network;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::sync::Arc;
@@ -245,31 +246,37 @@ async fn review_filters_agent_message_related_events() {
let mut saw_exited = false;
// Drain until TaskComplete; assert filtered events never surface.
wait_for_event(&codex, |event| match event {
EventMsg::TaskComplete(_) => true,
EventMsg::EnteredReviewMode(_) => {
saw_entered = true;
false
}
EventMsg::ExitedReviewMode(_) => {
saw_exited = true;
false
}
// The following must be filtered by review flow
EventMsg::AgentMessageContentDelta(_) => {
panic!("unexpected AgentMessageContentDelta surfaced during review")
}
EventMsg::AgentMessageDelta(_) => {
panic!("unexpected AgentMessageDelta surfaced during review")
}
EventMsg::ItemCompleted(ev) => match &ev.item {
codex_protocol::items::TurnItem::AgentMessage(_) => {
panic!("unexpected ItemCompleted for TurnItem::AgentMessage surfaced during review")
wait_for_event_with_timeout(
&codex,
|event| match event {
EventMsg::TaskComplete(_) => true,
EventMsg::EnteredReviewMode(_) => {
saw_entered = true;
false
}
EventMsg::ExitedReviewMode(_) => {
saw_exited = true;
false
}
// The following must be filtered by review flow
EventMsg::AgentMessageContentDelta(_) => {
panic!("unexpected AgentMessageContentDelta surfaced during review")
}
EventMsg::AgentMessageDelta(_) => {
panic!("unexpected AgentMessageDelta surfaced during review")
}
EventMsg::ItemCompleted(ev) => match &ev.item {
codex_protocol::items::TurnItem::AgentMessage(_) => {
panic!(
"unexpected ItemCompleted for TurnItem::AgentMessage surfaced during review"
)
}
_ => false,
},
_ => false,
},
_ => false,
})
tokio::time::Duration::from_secs(5),
)
.await;
assert!(saw_entered && saw_exited, "missing review lifecycle events");
@@ -328,21 +335,25 @@ async fn review_does_not_emit_agent_message_on_structured_output() {
// Drain events until TaskComplete; ensure none are AgentMessage.
let mut saw_entered = false;
let mut saw_exited = false;
wait_for_event(&codex, |event| match event {
EventMsg::TaskComplete(_) => true,
EventMsg::AgentMessage(_) => {
panic!("unexpected AgentMessage during review with structured output")
}
EventMsg::EnteredReviewMode(_) => {
saw_entered = true;
false
}
EventMsg::ExitedReviewMode(_) => {
saw_exited = true;
false
}
_ => false,
})
wait_for_event_with_timeout(
&codex,
|event| match event {
EventMsg::TaskComplete(_) => true,
EventMsg::AgentMessage(_) => {
panic!("unexpected AgentMessage during review with structured output")
}
EventMsg::EnteredReviewMode(_) => {
saw_entered = true;
false
}
EventMsg::ExitedReviewMode(_) => {
saw_exited = true;
false
}
_ => false,
},
tokio::time::Duration::from_secs(5),
)
.await;
assert!(saw_entered && saw_exited, "missing review lifecycle events");

View File

@@ -25,6 +25,7 @@ use core_test_support::responses::mount_sse_once_match;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use escargot::CargoBuild;
use mcp_types::ContentBlock;
use serde_json::Value;
@@ -124,9 +125,11 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
})
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
let begin_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpToolCallBegin(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
@@ -265,9 +268,11 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
.await?;
// Wait for tool begin/end and final completion.
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
let begin_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpToolCallBegin(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("begin");
@@ -460,9 +465,11 @@ async fn stdio_image_completions_round_trip() -> anyhow::Result<()> {
})
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
let begin_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpToolCallBegin(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
unreachable!("begin");
@@ -602,9 +609,11 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
})
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
let begin_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpToolCallBegin(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
@@ -753,9 +762,11 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
})
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
let begin_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpToolCallBegin(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {
@@ -936,9 +947,11 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> {
})
.await?;
let begin_event = wait_for_event(&fixture.codex, |ev| {
matches!(ev, EventMsg::McpToolCallBegin(_))
})
let begin_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpToolCallBegin(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpToolCallBegin(begin) = begin_event else {

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
use codex_core::protocol::EventMsg;
@@ -7,7 +9,7 @@ use core_test_support::load_sse_fixture_with_id;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -94,9 +96,19 @@ async fn continue_after_stream_error() {
.unwrap();
// Expect an Error followed by TaskComplete so the session is released.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::Error(_)),
Duration::from_secs(5),
)
.await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
// 2) Second turn: now send another prompt that should succeed using the
// mock server SSE stream. If the agent failed to clear the running task on
@@ -110,5 +122,10 @@ async fn continue_after_stream_error() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
Duration::from_secs(5),
)
.await;
}

View File

@@ -1,6 +1,8 @@
//! Verifies that the agent retries when the SSE stream terminates before
//! delivering a `response.completed` event.
use std::time::Duration;
use codex_core::ModelProviderInfo;
use codex_core::WireApi;
use codex_core::protocol::EventMsg;
@@ -11,7 +13,7 @@ use core_test_support::load_sse_fixture_with_id;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::Request;
@@ -101,5 +103,10 @@ async fn retries_on_early_close() {
.unwrap();
// Wait until TaskComplete (should succeed after retry).
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
wait_for_event_with_timeout(
&codex,
|event| matches!(event, EventMsg::TaskComplete(_)),
Duration::from_secs(10),
)
.await;
}

View File

@@ -1,8 +1,7 @@
#![cfg(not(target_os = "windows"))]
use std::collections::HashMap;
use std::sync::OnceLock;
use anyhow::Context;
use std::collections::HashMap;
use anyhow::Result;
use codex_core::features::Feature;
use codex_core::protocol::AskForApproval;
@@ -11,7 +10,6 @@ use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::user_input::UserInput;
use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
@@ -25,7 +23,7 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use regex_lite::Regex;
use core_test_support::wait_for_event_with_timeout;
use serde_json::Value;
use serde_json::json;
@@ -37,95 +35,7 @@ fn extract_output_text(item: &Value) -> Option<&str> {
})
}
#[derive(Debug)]
struct ParsedUnifiedExecOutput {
chunk_id: Option<String>,
wall_time_seconds: f64,
session_id: Option<i32>,
exit_code: Option<i32>,
original_token_count: Option<usize>,
output: String,
}
#[allow(clippy::expect_used)]
fn parse_unified_exec_output(raw: &str) -> Result<ParsedUnifiedExecOutput> {
static OUTPUT_REGEX: OnceLock<Regex> = OnceLock::new();
let regex = OUTPUT_REGEX.get_or_init(|| {
Regex::new(concat!(
r#"(?s)^(?:Total output lines: \d+\n\n)?"#,
r#"(?:Chunk ID: (?P<chunk_id>[^\n]+)\n)?"#,
r#"Wall time: (?P<wall_time>-?\d+(?:\.\d+)?) seconds\n"#,
r#"(?:Process exited with code (?P<exit_code>-?\d+)\n)?"#,
r#"(?:Process running with session ID (?P<session_id>-?\d+)\n)?"#,
r#"(?:Original token count: (?P<original_token_count>\d+)\n)?"#,
r#"Output:\n?(?P<output>.*)$"#,
))
.expect("valid unified exec output regex")
});
let cleaned = raw.trim_matches('\r');
let captures = regex
.captures(cleaned)
.ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output"))?;
let chunk_id = captures
.name("chunk_id")
.map(|value| value.as_str().to_string());
let wall_time_seconds = captures
.name("wall_time")
.expect("wall_time group present")
.as_str()
.parse::<f64>()
.context("failed to parse wall time seconds")?;
let exit_code = captures
.name("exit_code")
.map(|value| {
value
.as_str()
.parse::<i32>()
.context("failed to parse exit code from unified exec output")
})
.transpose()?;
let session_id = captures
.name("session_id")
.map(|value| {
value
.as_str()
.parse::<i32>()
.context("failed to parse session id from unified exec output")
})
.transpose()?;
let original_token_count = captures
.name("original_token_count")
.map(|value| {
value
.as_str()
.parse::<usize>()
.context("failed to parse original token count from unified exec output")
})
.transpose()?;
let output = captures
.name("output")
.expect("output group present")
.as_str()
.to_string();
Ok(ParsedUnifiedExecOutput {
chunk_id,
wall_time_seconds,
session_id,
exit_code,
original_token_count,
output,
})
}
fn collect_tool_outputs(bodies: &[Value]) -> Result<HashMap<String, ParsedUnifiedExecOutput>> {
fn collect_tool_outputs(bodies: &[Value]) -> Result<HashMap<String, Value>> {
let mut outputs = HashMap::new();
for body in bodies {
if let Some(items) = body.get("input").and_then(Value::as_array) {
@@ -140,8 +50,8 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result<HashMap<String, ParsedUnifie
if trimmed.is_empty() {
continue;
}
let parsed = parse_unified_exec_output(content).with_context(|| {
format!("failed to parse unified exec output for {call_id}")
let parsed: Value = serde_json::from_str(trimmed).map_err(|err| {
anyhow::anyhow!("failed to parse tool output content {trimmed:?}: {err}")
})?;
outputs.insert(call_id.to_string(), parsed);
}
@@ -223,90 +133,6 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_respects_workdir_override() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config.features.enable(Feature::UnifiedExec);
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let workdir = cwd.path().join("uexec_workdir_test");
std::fs::create_dir_all(&workdir)?;
let call_id = "uexec-workdir";
let args = json!({
"cmd": "pwd",
"yield_time_ms": 250,
"workdir": workdir.to_string_lossy().to_string(),
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "finished"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "run workdir test".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = requests
.iter()
.map(|req| req.body_json::<Value>().expect("request json"))
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs
.get(call_id)
.expect("missing exec_command workdir output");
let output_text = output.output.trim();
let output_canonical = std::fs::canonicalize(output_text)?;
let expected_canonical = std::fs::canonicalize(&workdir)?;
assert_eq!(
output_canonical, expected_canonical,
"pwd should reflect the requested workdir override"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -565,6 +391,8 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> {
use tokio::time::Duration;
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -640,7 +468,7 @@ async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> {
let mut begin_events = Vec::new();
loop {
let event_msg = wait_for_event(&codex, |_| true).await;
let event_msg = wait_for_event_with_timeout(&codex, |_| true, Duration::from_secs(2)).await;
match event_msg {
EventMsg::ExecCommandBegin(event) => begin_events.push(event),
EventMsg::TaskComplete(_) => break,
@@ -728,38 +556,51 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
.get(call_id)
.expect("missing exec_command metadata output");
let chunk_id = metadata.chunk_id.as_ref().expect("missing chunk_id");
let chunk_id = metadata
.get("chunk_id")
.and_then(Value::as_str)
.expect("missing chunk_id");
assert_eq!(chunk_id.len(), 6, "chunk id should be 6 hex characters");
assert!(
chunk_id.chars().all(|c| c.is_ascii_hexdigit()),
"chunk id should be hexadecimal: {chunk_id}"
);
let wall_time = metadata.wall_time_seconds;
let wall_time = metadata
.get("wall_time_seconds")
.and_then(Value::as_f64)
.unwrap_or_default();
assert!(
wall_time >= 0.0,
"wall_time_seconds should be non-negative, got {wall_time}"
);
assert!(
metadata.session_id.is_none(),
metadata.get("session_id").is_none(),
"exec_command for a completed process should not include session_id"
);
let exit_code = metadata.exit_code.expect("expected exit_code");
let exit_code = metadata
.get("exit_code")
.and_then(Value::as_i64)
.expect("expected exit_code");
assert_eq!(exit_code, 0, "expected successful exit");
let output_text = &metadata.output;
let output_text = metadata
.get("output")
.and_then(Value::as_str)
.expect("missing output text");
assert!(
output_text.contains("tokens truncated"),
"expected truncation notice in output: {output_text:?}"
);
let original_tokens = metadata
.original_token_count
.expect("missing original_token_count") as usize;
.get("original_token_count")
.and_then(Value::as_u64)
.expect("missing original_token_count");
assert!(
original_tokens > 6,
original_tokens as usize > 6,
"original token count should exceed max_output_tokens"
);
@@ -870,34 +711,39 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
.get(start_call_id)
.expect("missing start output for exec_command");
let session_id = start_output
.session_id
.get("session_id")
.and_then(Value::as_i64)
.expect("expected session id from exec_command");
assert!(
session_id >= 0,
"session_id should be non-negative, got {session_id}"
);
assert!(
start_output.exit_code.is_none(),
start_output.get("exit_code").is_none(),
"initial exec_command should not include exit_code while session is running"
);
let send_output = outputs
.get(send_call_id)
.expect("missing write_stdin echo output");
let echoed = send_output.output.as_str();
let echoed = send_output
.get("output")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
echoed.contains("hello unified exec"),
"expected echoed output from cat, got {echoed:?}"
);
let echoed_session = send_output
.session_id
.get("session_id")
.and_then(Value::as_i64)
.expect("write_stdin should return session id while process is running");
assert_eq!(
echoed_session, session_id,
"write_stdin should reuse existing session id"
);
assert!(
send_output.exit_code.is_none(),
send_output.get("exit_code").is_none(),
"write_stdin should not include exit_code while process is running"
);
@@ -905,17 +751,18 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
.get(exit_call_id)
.expect("missing exit metadata output");
assert!(
exit_output.session_id.is_none(),
exit_output.get("session_id").is_none(),
"session_id should be omitted once the process exits"
);
let exit_code = exit_output
.exit_code
.get("exit_code")
.and_then(Value::as_i64)
.expect("expected exit_code after sending EOF");
assert_eq!(exit_code, 0, "cat should exit cleanly after EOF");
let exit_chunk = exit_output
.chunk_id
.as_ref()
.get("chunk_id")
.and_then(Value::as_str)
.expect("missing chunk id for exit output");
assert!(
exit_chunk.chars().all(|c| c.is_ascii_hexdigit()),
@@ -1117,18 +964,26 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
let start_output = outputs
.get(first_call_id)
.expect("missing first unified_exec output");
let session_id = start_output.session_id.unwrap_or_default();
let session_id = start_output["session_id"].as_i64().unwrap_or_default();
assert!(
session_id >= 0,
"expected session id in first unified_exec response"
);
assert!(start_output.output.is_empty());
assert!(
start_output["output"]
.as_str()
.unwrap_or_default()
.is_empty()
);
let reuse_output = outputs
.get(second_call_id)
.expect("missing reused unified_exec output");
assert_eq!(reuse_output.session_id.unwrap_or_default(), session_id);
let echoed = reuse_output.output.as_str();
assert_eq!(
reuse_output["session_id"].as_i64().unwrap_or_default(),
session_id
);
let echoed = reuse_output["output"].as_str().unwrap_or_default();
assert!(
echoed.contains("hello unified exec"),
"expected echoed output, got {echoed:?}"
@@ -1245,7 +1100,7 @@ PY
let start_output = outputs
.get(first_call_id)
.expect("missing initial unified_exec output");
let session_id = start_output.session_id.unwrap_or_default();
let session_id = start_output["session_id"].as_i64().unwrap_or_default();
assert!(
session_id >= 0,
"expected session id from initial unified_exec response"
@@ -1254,7 +1109,7 @@ PY
let poll_output = outputs
.get(second_call_id)
.expect("missing poll unified_exec output");
let poll_text = poll_output.output.as_str();
let poll_text = poll_output["output"].as_str().unwrap_or_default();
assert!(
poll_text.contains("TAIL-MARKER"),
"expected poll output to contain tail marker, got {poll_text:?}"
@@ -1354,11 +1209,16 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
let outputs = collect_tool_outputs(&bodies)?;
let first_output = outputs.get(first_call_id).expect("missing timeout output");
assert_eq!(first_output.session_id, Some(0));
assert!(first_output.output.is_empty());
assert_eq!(first_output["session_id"], 0);
assert!(
first_output["output"]
.as_str()
.unwrap_or_default()
.is_empty()
);
let poll_output = outputs.get(second_call_id).expect("missing poll output");
let output_text = poll_output.output.as_str();
let output_text = poll_output["output"].as_str().unwrap_or_default();
assert!(
output_text.contains("ready"),
"expected ready output, got {output_text:?}"
@@ -1366,88 +1226,3 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_formats_large_output_summary() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.features.enable(Feature::UnifiedExec);
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let script = r#"python3 - <<'PY'
for i in range(300):
print(f"line-{i}")
PY
"#;
let call_id = "uexec-large-output";
let args = serde_json::json!({
"cmd": script,
"yield_time_ms": 500,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "summarize large output".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = requests
.iter()
.map(|req| req.body_json::<Value>().expect("request json"))
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let large_output = outputs.get(call_id).expect("missing large output summary");
assert_regex_match(
concat!(
r"(?s)",
r"line-0.*?",
r"\[\.{3} omitted \d+ of \d+ lines \.{3}\].*?",
r"line-299",
),
&large_output.output,
);
Ok(())
}

View File

@@ -2,20 +2,35 @@ use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecOutputStream;
use codex_core::protocol::Op;
use codex_core::protocol::TurnAbortReason;
use core_test_support::assert_regex_match;
use core_test_support::load_default_config_for_test;
use core_test_support::responses;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use regex_lite::escape;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use tempfile::TempDir;
fn detect_python_executable() -> Option<String> {
let candidates = ["python3", "python"];
candidates.iter().find_map(|candidate| {
Command::new(candidate)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()
.and_then(|status| status.success().then(|| (*candidate).to_string()))
})
}
#[tokio::test]
async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
let Some(python) = detect_python_executable() else {
eprintln!("skipping test: python3 not found in PATH");
return;
};
// Create a temporary working directory with a known file.
let cwd = TempDir::new().unwrap();
let file_name = "hello.txt";
@@ -40,8 +55,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
.await
.expect("create new conversation");
// 1) shell command should list the file
let list_cmd = "ls".to_string();
// 1) python should list the file
let list_cmd = format!(
"{python} -c \"import pathlib; print('\\n'.join(sorted(p.name for p in pathlib.Path('.').iterdir())))\""
);
codex
.submit(Op::RunUserShellCommand { command: list_cmd })
.await
@@ -59,8 +76,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
"ls output should include {file_name}, got: {stdout:?}"
);
// 2) shell command should print the file contents verbatim
let cat_cmd = format!("cat {file_name}");
// 2) python should print the file contents verbatim
let cat_cmd = format!(
"{python} -c \"import pathlib; print(pathlib.Path('{file_name}').read_text(), end='')\""
);
codex
.submit(Op::RunUserShellCommand { command: cat_cmd })
.await
@@ -76,7 +95,7 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
};
assert_eq!(exit_code, 0);
if cfg!(windows) {
// Windows shells emit CRLF line endings; normalize so the assertion remains portable.
// Windows' Python writes CRLF line endings; normalize so the assertion remains portable.
stdout = stdout.replace("\r\n", "\n");
}
assert_eq!(stdout, contents);
@@ -84,6 +103,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
#[tokio::test]
async fn user_shell_cmd_can_be_interrupted() {
let Some(python) = detect_python_executable() else {
eprintln!("skipping test: python3 not found in PATH");
return;
};
// Set up isolated config and conversation.
let codex_home = TempDir::new().unwrap();
let config = load_default_config_for_test(&codex_home);
@@ -98,7 +121,7 @@ async fn user_shell_cmd_can_be_interrupted() {
.expect("create new conversation");
// Start a long-running command and then interrupt it.
let sleep_cmd = "sleep 5".to_string();
let sleep_cmd = format!("{python} -c \"import time; time.sleep(5)\"");
codex
.submit(Op::RunUserShellCommand { command: sleep_cmd })
.await
@@ -115,137 +138,3 @@ async fn user_shell_cmd_can_be_interrupted() {
};
assert_eq!(ev.reason, TurnAbortReason::Interrupted);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let mut builder = core_test_support::test_codex::test_codex();
let test = builder.build(&server).await?;
#[cfg(windows)]
let command = r#"$val = $env:CODEX_SANDBOX; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string();
#[cfg(not(windows))]
let command = r#"sh -c "printf '%s' \"${CODEX_SANDBOX:-not-set}\"""#.to_string();
test.codex
.submit(Op::RunUserShellCommand {
command: command.clone(),
})
.await?;
let begin_event = wait_for_event_match(&test.codex, |ev| match ev {
EventMsg::ExecCommandBegin(event) => Some(event.clone()),
_ => None,
})
.await;
assert!(begin_event.is_user_shell_command);
let matches_last_arg = begin_event.command.last() == Some(&command);
let matches_split = shlex::split(&command).is_some_and(|split| split == begin_event.command);
assert!(
matches_last_arg || matches_split,
"user command begin event should include the original command; got: {:?}",
begin_event.command
);
let delta_event = wait_for_event_match(&test.codex, |ev| match ev {
EventMsg::ExecCommandOutputDelta(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(delta_event.stream, ExecOutputStream::Stdout);
let chunk_text =
String::from_utf8(delta_event.chunk.clone()).expect("user command chunk is valid utf-8");
assert_eq!(chunk_text.trim(), "not-set");
let end_event = wait_for_event_match(&test.codex, |ev| match ev {
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(end_event.exit_code, 0);
assert_eq!(end_event.stdout.trim(), "not-set");
let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
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 mock = responses::mount_sse_sequence(&server, responses).await;
test.submit_turn("follow-up after shell command").await?;
let request = mock.single_request();
let command_message = request
.message_input_texts("user")
.into_iter()
.find(|text| text.contains("<user_shell_command>"))
.expect("command message recorded in request");
let command_message = command_message.replace("\r\n", "\n");
let escaped_command = escape(&command);
let expected_pattern = format!(
r"(?m)\A<user_shell_command>\n<command>\n{escaped_command}\n</command>\n<result>\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nnot-set\n</result>\n</user_shell_command>\z"
);
assert_regex_match(&expected_pattern, &command_message);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> {
let server = responses::start_mock_server().await;
let mut builder = core_test_support::test_codex::test_codex();
let test = builder.build(&server).await?;
#[cfg(windows)]
let command = r#"for ($i=1; $i -le 400; $i++) { Write-Output $i }"#.to_string();
#[cfg(not(windows))]
let command = "seq 1 400".to_string();
test.codex
.submit(Op::RunUserShellCommand {
command: command.clone(),
})
.await?;
let end_event = wait_for_event_match(&test.codex, |ev| match ev {
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
_ => None,
})
.await;
assert_eq!(end_event.exit_code, 0);
let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
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 mock = responses::mount_sse_sequence(&server, responses).await;
test.submit_turn("follow-up after shell command").await?;
let request = mock.single_request();
let command_message = request
.message_input_texts("user")
.into_iter()
.find(|text| text.contains("<user_shell_command>"))
.expect("command message recorded in request");
let command_message = command_message.replace("\r\n", "\n");
let head = (1..=128).map(|i| format!("{i}\n")).collect::<String>();
let tail = (273..=400).map(|i| format!("{i}\n")).collect::<String>();
let truncated_body =
format!("Total output lines: 400\n\n{head}\n[... omitted 144 of 400 lines ...]\n\n{tail}");
let escaped_command = escape(&command);
let escaped_truncated_body = escape(&truncated_body);
let expected_pattern = format!(
r"(?m)\A<user_shell_command>\n<command>\n{escaped_command}\n</command>\n<result>\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{escaped_truncated_body}\n</result>\n</user_shell_command>\z"
);
assert_regex_match(&expected_pattern, &command_message);
Ok(())
}

View File

@@ -21,8 +21,7 @@ At a glance:
- `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo`
- `model/list` → enumerate available models and reasoning options
- Auth
- `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read`
- notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated`
- `loginApiKey`, `loginChatGpt`, `cancelLoginChatGpt`, `logoutChatGpt`, `getAuthStatus`
- Utilities
- `gitDiffToRemote`, `execOneOffCommand`
- Approvals (server → client requests)
@@ -114,7 +113,11 @@ The client must reply with `{ decision: "allow" | "deny" }` for each request.
## Auth helpers
For the complete request/response shapes and flow examples, see the [“Auth endpoints (v2)” section in the appserver README](../app-server/README.md#auth-endpoints-v2).
For ChatGPT or APIkey based auth flows, the server exposes helpers:
- `loginApiKey { apiKey }`
- `loginChatGpt` → returns `{ loginId, authUrl }`; browser completes flow; then `loginChatGptComplete` notification follows
- `cancelLoginChatGpt { loginId }`, `logoutChatGpt`, `getAuthStatus { includeToken?, refreshToken? }`
## Example: start and send a message

View File

@@ -548,7 +548,7 @@ fn warning_event_produces_error_item() {
let out = ep.collect_thread_events(&event(
"e1",
EventMsg::Warning(WarningEvent {
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(),
}),
));
assert_eq!(
@@ -557,7 +557,7 @@ fn warning_event_produces_error_item() {
item: ThreadItem {
id: "item_0".to_string(),
details: ThreadItemDetails::Error(ErrorItem {
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(),
}),
},
})]

View File

@@ -0,0 +1,21 @@
[package]
edition = "2024"
name = "codex-protocol-ts"
version = { workspace = true }
[lints]
workspace = true
[lib]
name = "codex_protocol_ts"
path = "src/lib.rs"
[[bin]]
name = "codex-protocol-ts"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
ts-rs = { workspace = true }

View File

@@ -0,0 +1,10 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")"/..
tmpdir=$(mktemp -d)
just codex generate-ts --prettier ../node_modules/.bin/prettier --out "$tmpdir"
echo "wrote output to $tmpdir"

View File

@@ -0,0 +1,133 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::export_client_responses;
use codex_app_server_protocol::export_server_responses;
use std::ffi::OsStr;
use std::fs;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use ts_rs::TS;
const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
ensure_dir(out_dir)?;
// Generate the TS bindings client -> server messages.
ClientRequest::export_all_to(out_dir)?;
export_client_responses(out_dir)?;
ClientNotification::export_all_to(out_dir)?;
// Generate the TS bindings server -> client messages.
ServerRequest::export_all_to(out_dir)?;
export_server_responses(out_dir)?;
ServerNotification::export_all_to(out_dir)?;
// Generate index.ts that re-exports all types.
generate_index_ts(out_dir)?;
// Prepend header to each generated .ts file
let ts_files = ts_files_in(out_dir)?;
for file in &ts_files {
prepend_header_if_missing(file)?;
}
// Format with Prettier by passing individual files (no shell globbing)
if let Some(prettier_bin) = prettier
&& !ts_files.is_empty()
{
let status = Command::new(prettier_bin)
.arg("--write")
.args(ts_files.iter().map(|p| p.as_os_str()))
.status()
.with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?;
if !status.success() {
return Err(anyhow!("Prettier failed with status {status}"));
}
}
Ok(())
}
fn ensure_dir(dir: &Path) -> Result<()> {
fs::create_dir_all(dir)
.with_context(|| format!("Failed to create output directory {}", dir.display()))
}
fn prepend_header_if_missing(path: &Path) -> Result<()> {
let mut content = String::new();
{
let mut f = fs::File::open(path)
.with_context(|| format!("Failed to open {} for reading", path.display()))?;
f.read_to_string(&mut content)
.with_context(|| format!("Failed to read {}", path.display()))?;
}
if content.starts_with(HEADER) {
return Ok(());
}
let mut f = fs::File::create(path)
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
f.write_all(HEADER.as_bytes())
.with_context(|| format!("Failed to write header to {}", path.display()))?;
f.write_all(content.as_bytes())
.with_context(|| format!("Failed to write content to {}", path.display()))?;
Ok(())
}
fn ts_files_in(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in
fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension() == Some(OsStr::new("ts")) {
files.push(path);
}
}
files.sort();
Ok(files)
}
/// Generate an index.ts file that re-exports all generated types.
/// This allows consumers to import all types from a single file.
fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
let mut entries: Vec<String> = Vec::new();
let mut stems: Vec<String> = ts_files_in(out_dir)?
.into_iter()
.filter_map(|p| {
let stem = p.file_stem()?.to_string_lossy().into_owned();
if stem == "index" { None } else { Some(stem) }
})
.collect();
stems.sort();
stems.dedup();
for name in stems {
entries.push(format!("export type {{ {name} }} from \"./{name}\";\n"));
}
let mut content =
String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::<usize>());
content.push_str(HEADER);
for line in &entries {
content.push_str(line);
}
let index_path = out_dir.join("index.ts");
let mut f = fs::File::create(&index_path)
.with_context(|| format!("Failed to create {}", index_path.display()))?;
f.write_all(content.as_bytes())
.with_context(|| format!("Failed to write {}", index_path.display()))?;
Ok(index_path)
}

View File

@@ -0,0 +1,20 @@
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(about = "Generate TypeScript bindings for the Codex protocol")]
struct Args {
/// Output directory where .ts files will be written
#[arg(short = 'o', long = "out", value_name = "DIR")]
out_dir: PathBuf,
/// Optional path to the Prettier executable to format generated files
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
prettier: Option<PathBuf>,
}
fn main() -> Result<()> {
let args = Args::parse();
codex_protocol_ts::generate_ts(&args.out_dir, args.prettier.as_deref())
}

View File

@@ -21,11 +21,6 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Print the version that would be used and exit before making changes.",
)
parser.add_argument(
"--promote-alpha",
metavar="VERSION",
help="Promote an existing alpha tag (e.g., 0.56.0-alpha.5) by using its merge-base with main as the base commit.",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
@@ -48,43 +43,26 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
args.publish_alpha
or args.publish_release
or args.emergency_version_override
or args.promote_alpha
):
parser.error(
"Must specify --publish-alpha, --publish-release, --promote-alpha, or --emergency-version-override."
"Must specify --publish-alpha, --publish-release, or --emergency-version-override."
)
return args
def main(argv: list[str]) -> int:
args = parse_args(argv)
# Strip the leading "v" if present.
promote_alpha = args.promote_alpha
if promote_alpha and promote_alpha.startswith("v"):
promote_alpha = promote_alpha[1:]
try:
if promote_alpha:
version = derive_release_version_from_alpha(promote_alpha)
elif args.emergency_version_override:
if args.emergency_version_override:
version = args.emergency_version_override
else:
version = determine_version(args)
print(f"Publishing version {version}")
if promote_alpha:
base_commit = get_promote_alpha_base_commit(promote_alpha)
if args.dry_run:
print(
f"Would publish version {version} using base commit {base_commit} derived from rust-v{promote_alpha}."
)
return 0
elif args.dry_run:
if args.dry_run:
return 0
if not promote_alpha:
print("Fetching branch head...")
base_commit = get_branch_head()
print("Fetching branch head...")
base_commit = get_branch_head()
print(f"Base commit: {base_commit}")
print("Fetching commit tree...")
base_tree = get_commit_tree(base_commit)
@@ -152,39 +130,6 @@ def get_branch_head() -> str:
raise ReleaseError("Unable to determine branch head.") from error
def get_promote_alpha_base_commit(alpha_version: str) -> str:
tag_name = f"rust-v{alpha_version}"
tag_commit_sha = get_tag_commit_sha(tag_name)
return get_merge_base_with_main(tag_commit_sha)
def get_tag_commit_sha(tag_name: str) -> str:
response = run_gh_api(f"/repos/{REPO}/git/refs/tags/{tag_name}")
try:
sha = response["object"]["sha"]
obj_type = response["object"]["type"]
except KeyError as error:
raise ReleaseError(f"Unable to resolve tag {tag_name}.") from error
while obj_type == "tag":
tag_response = run_gh_api(f"/repos/{REPO}/git/tags/{sha}")
try:
sha = tag_response["object"]["sha"]
obj_type = tag_response["object"]["type"]
except KeyError as error:
raise ReleaseError(f"Unable to resolve annotated tag {tag_name}.") from error
if obj_type != "commit":
raise ReleaseError(f"Tag {tag_name} does not reference a commit.")
return sha
def get_merge_base_with_main(commit_sha: str) -> str:
response = run_gh_api(f"/repos/{REPO}/compare/main...{commit_sha}")
try:
return response["merge_base_commit"]["sha"]
except KeyError as error:
raise ReleaseError("Unable to determine merge base with main.") from error
def get_commit_tree(commit_sha: str) -> str:
response = run_gh_api(f"/repos/{REPO}/git/commits/{commit_sha}")
try:
@@ -364,12 +309,5 @@ def format_version(major: int, minor: int, patch: int) -> str:
return f"{major}.{minor}.{patch}"
def derive_release_version_from_alpha(alpha_version: str) -> str:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$", alpha_version)
if match is None:
raise ReleaseError(f"Unexpected alpha version format: {alpha_version}")
return f"{match.group(1)}.{match.group(2)}.{match.group(3)}"
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@@ -27,7 +27,6 @@ base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-ansi-escape = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = [
"cli",
@@ -35,14 +34,17 @@ codex-common = { workspace = true, features = [
"sandbox_summary",
] }
codex-core = { workspace = true }
codex-feedback = { workspace = true }
codex-file-search = { workspace = true }
codex-login = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-feedback = { workspace = true }
color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
derive_more = { workspace = true, features = ["is_variant"] }
crossterm = { workspace = true, features = [
"bracketed-paste",
"event-stream",
] }
diffy = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
@@ -50,7 +52,6 @@ image = { workspace = true, features = ["jpeg", "png"] }
itertools = { workspace = true }
lazy_static = { workspace = true }
mcp-types = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
pathdiff = { workspace = true }
pulldown-cmark = { workspace = true }
rand = { workspace = true }
@@ -70,6 +71,8 @@ strum_macros = { workspace = true }
supports-color = { workspace = true }
tempfile = { workspace = true }
textwrap = { workspace = true }
tree-sitter-highlight = { workspace = true }
tree-sitter-bash = { workspace = true }
tokio = { workspace = true, features = [
"io-std",
"macros",
@@ -82,14 +85,11 @@ toml = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
tree-sitter-bash = { workspace = true }
tree-sitter-highlight = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
codex-windows-sandbox = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
@@ -105,5 +105,5 @@ chrono = { workspace = true, features = ["serde"] }
insta = { workspace = true }
pretty_assertions = { workspace = true }
rand = { workspace = true }
serial_test = { workspace = true }
vt100 = { workspace = true }
serial_test = { workspace = true }

View File

@@ -13,7 +13,7 @@ use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::updates::UpdateAction;
use codex_ansi_escape::ansi_escape_line;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -79,9 +79,6 @@ pub(crate) struct App {
pub(crate) feedback: codex_feedback::CodexFeedback,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
// One-shot suppression of the next world-writable scan after user confirmation.
skip_world_writable_scan_once: bool,
}
impl App {
@@ -171,39 +168,15 @@ impl App {
backtrack: BacktrackState::default(),
feedback: feedback.clone(),
pending_update_action: None,
skip_world_writable_scan_once: false,
};
// On startup, if Auto mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
let should_check = codex_core::get_platform_sandbox().is_some()
&& matches!(
app.config.sandbox_policy,
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_core::protocol::SandboxPolicy::ReadOnly
)
&& !app
.config
.notices
.hide_world_writable_warning
.unwrap_or(false);
if should_check {
let cwd = app.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let tx = app.app_event_tx.clone();
let logs_base_dir = app.config.codex_home.clone();
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
}
}
#[cfg(not(debug_assertions))]
if let Some(latest_version) = upgrade_version {
app.handle_event(
tui,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
crate::updates::get_update_action(),
))),
)
.await?;
@@ -387,19 +360,6 @@ impl App {
AppEvent::OpenFullAccessConfirmation { preset } => {
self.chat_widget.open_full_access_confirmation(preset);
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
sample_paths,
extra_count,
failed_scan,
} => {
self.chat_widget.open_world_writable_warning_confirmation(
preset,
sample_paths,
extra_count,
failed_scan,
);
}
AppEvent::OpenFeedbackNote {
category,
include_logs,
@@ -458,50 +418,11 @@ impl App {
self.chat_widget.set_approval_policy(policy);
}
AppEvent::UpdateSandboxPolicy(policy) => {
#[cfg(target_os = "windows")]
let policy_is_workspace_write_or_ro = matches!(
policy,
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_core::protocol::SandboxPolicy::ReadOnly
);
self.chat_widget.set_sandbox_policy(policy);
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
#[cfg(target_os = "windows")]
{
// One-shot suppression if the user just confirmed continue.
if self.skip_world_writable_scan_once {
self.skip_world_writable_scan_once = false;
return Ok(true);
}
let should_check = codex_core::get_platform_sandbox().is_some()
&& policy_is_workspace_write_or_ro
&& !self.chat_widget.world_writable_warning_hidden();
if should_check {
let cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let tx = self.app_event_tx.clone();
let logs_base_dir = self.config.codex_home.clone();
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
}
}
}
AppEvent::SkipNextWorldWritableScan => {
self.skip_world_writable_scan_once = true;
}
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
self.chat_widget.set_full_access_warning_acknowledged(ack);
}
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
self.chat_widget
.set_world_writable_warning_acknowledged(ack);
}
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_full_access_warning(true)
@@ -517,36 +438,6 @@ impl App {
));
}
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_world_writable_warning(true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist world-writable warning acknowledgement"
);
self.chat_widget.add_error_message(format!(
"Failed to save Auto mode warning preference: {err}"
));
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_rate_limit_model_nudge(true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist rate limit switch prompt preference"
);
self.chat_widget.add_error_message(format!(
"Failed to save rate limit reminder preference: {err}"
));
}
}
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
@@ -650,58 +541,6 @@ impl App {
}
};
}
#[cfg(target_os = "windows")]
fn spawn_world_writable_scan(
cwd: PathBuf,
env_map: std::collections::HashMap<String, String>,
logs_base_dir: PathBuf,
tx: AppEventSender,
) {
#[inline]
fn normalize_windows_path_for_display(p: &std::path::Path) -> String {
let canon = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon.display().to_string().replace('/', "\\")
}
tokio::task::spawn_blocking(move || {
let result = codex_windows_sandbox::preflight_audit_everyone_writable(
&cwd,
&env_map,
Some(logs_base_dir.as_path()),
);
if let Ok(ref paths) = result
&& !paths.is_empty()
{
let as_strings: Vec<String> = paths
.iter()
.map(|p| normalize_windows_path_for_display(p))
.collect();
let sample_paths: Vec<String> = as_strings.iter().take(3).cloned().collect();
let extra_count = if as_strings.len() > sample_paths.len() {
as_strings.len() - sample_paths.len()
} else {
0
};
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
sample_paths,
extra_count,
failed_scan: false,
});
} else if result.is_err() {
// Scan failed: still warn, but with no examples and mark as failed.
let sample_paths: Vec<String> = Vec::new();
let extra_count = 0usize;
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
sample_paths,
extra_count,
failed_scan: true,
});
}
});
}
}
#[cfg(test)]
@@ -753,7 +592,6 @@ mod tests {
backtrack: BacktrackState::default(),
feedback: codex_feedback::CodexFeedback::new(),
pending_update_action: None,
skip_world_writable_scan_once: false,
}
}

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::app::App;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::Overlay;
use crate::tui;
@@ -394,13 +394,13 @@ fn nth_user_position(
fn user_positions_iter(
cells: &[Arc<dyn crate::history_cell::HistoryCell>],
) -> impl Iterator<Item = usize> + '_ {
let session_start_type = TypeId::of::<SessionInfoCell>();
let header_type = TypeId::of::<CompositeHistoryCell>();
let user_type = TypeId::of::<UserHistoryCell>();
let type_of = |cell: &Arc<dyn crate::history_cell::HistoryCell>| cell.as_any().type_id();
let start = cells
.iter()
.rposition(|cell| type_of(cell) == session_start_type)
.rposition(|cell| type_of(cell) == header_type)
.map_or(0, |idx| idx + 1);
cells

View File

@@ -72,23 +72,7 @@ pub(crate) enum AppEvent {
preset: ApprovalPreset,
},
/// Open the Windows world-writable directories warning.
/// If `preset` is `Some`, the confirmation will apply the provided
/// approval/sandbox configuration on Continue; if `None`, it performs no
/// policy change and only acknowledges/dismisses the warning.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWorldWritableWarningConfirmation {
preset: Option<ApprovalPreset>,
/// Up to 3 sample world-writable directories to display in the warning.
sample_paths: Vec<String>,
/// If there are more than `sample_paths`, this carries the remaining count.
extra_count: usize,
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
failed_scan: bool,
},
/// Show Windows Subsystem for Linux setup instructions for auto mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
ShowWindowsAutoModeInstructions,
/// Update the current approval policy in the running app and widget.
@@ -100,27 +84,9 @@ pub(crate) enum AppEvent {
/// Update whether the full access warning prompt has been acknowledged.
UpdateFullAccessWarningAcknowledged(bool),
/// Update whether the world-writable directories warning has been acknowledged.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
UpdateWorldWritableWarningAcknowledged(bool),
/// Update whether the rate limit switch prompt has been acknowledged for the session.
UpdateRateLimitSwitchPromptHidden(bool),
/// Persist the acknowledgement flag for the full access warning prompt.
PersistFullAccessWarningAcknowledged,
/// Persist the acknowledgement flag for the world-writable directories warning.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
PersistWorldWritableWarningAcknowledged,
/// Persist the acknowledgement flag for the rate limit switch prompt.
PersistRateLimitSwitchPromptHidden,
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
SkipNextWorldWritableScan,
/// Re-open the approval presets popup.
OpenApprovalsPopup,

View File

@@ -117,9 +117,7 @@ impl ApprovalOverlay {
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
display_shortcut: opt
.display_shortcut
.or_else(|| opt.additional_shortcuts.first().copied()),
display_shortcut: opt.display_shortcut,
dismiss_on_select: false,
..Default::default()
})

View File

@@ -410,11 +410,6 @@ impl ChatComposer {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
@@ -422,11 +417,6 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
@@ -594,11 +584,6 @@ impl ChatComposer {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_up();
(InputResult::None, true)
@@ -606,11 +591,6 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
popup.move_down();
(InputResult::None, true)
@@ -890,11 +870,6 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('p') | KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => {
if self
.history
@@ -903,8 +878,6 @@ impl ChatComposer {
let replace_text = match key_event.code {
KeyCode::Up => self.history.navigate_up(&self.app_event_tx),
KeyCode::Down => self.history.navigate_down(&self.app_event_tx),
KeyCode::Char('p') => self.history.navigate_up(&self.app_event_tx),
KeyCode::Char('n') => self.history.navigate_down(&self.app_event_tx),
_ => unreachable!(),
};
if let Some(text) = replace_text {

View File

@@ -3,7 +3,6 @@ use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it.
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -97,9 +96,8 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
let this_name_width = Line::from(name_spans.clone()).width();
let mut full_spans: Vec<Span> = name_spans;
if let Some(display_shortcut) = row.display_shortcut {
full_spans.push(" (".into());
full_spans.push(" ".into());
full_spans.push(display_shortcut.into());
full_spans.push(")".into());
}
if let Some(desc) = row.description.as_ref() {
let gap = desc_col.saturating_sub(this_name_width);
@@ -181,9 +179,8 @@ pub(crate) fn render_rows(
);
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
// Reset the style first to avoid inheriting dim from keyboard shortcuts.
full_line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
span.style = span.style.fg(Color::Cyan).bold();
});
}

View File

@@ -522,7 +522,6 @@ impl ChatWidget {
.unwrap_or(false);
if high_usage
&& !self.rate_limit_switch_prompt_hidden()
&& self.config.model != NUDGE_MODEL_SLUG
&& !matches!(
self.rate_limit_switch_prompt,
@@ -1711,18 +1710,7 @@ impl ChatWidget {
.find(|preset| preset.model == NUDGE_MODEL_SLUG)
}
fn rate_limit_switch_prompt_hidden(&self) -> bool {
self.config
.notices
.hide_rate_limit_model_nudge
.unwrap_or(false)
}
fn maybe_show_pending_rate_limit_prompt(&mut self) {
if self.rate_limit_switch_prompt_hidden() {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
return;
}
if !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
@@ -1756,10 +1744,6 @@ impl ChatWidget {
})];
let keep_actions: Vec<SelectionAction> = Vec::new();
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
})];
let description = if preset.description.is_empty() {
Some("Uses fewer credits for upcoming turns.".to_string())
} else {
@@ -1785,17 +1769,6 @@ impl ChatWidget {
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep current model (never show again)".to_string(),
description: Some(
"Hide future rate limit reminders about switching models.".to_string(),
),
selected_description: None,
is_current: false,
actions: never_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
@@ -2057,73 +2030,13 @@ impl ChatWidget {
preset: preset_clone.clone(),
});
})]
} else if preset.id == "auto" {
#[cfg(target_os = "windows")]
{
if codex_core::get_platform_sandbox().is_none() {
vec![Box::new(|tx| {
tx.send(AppEvent::ShowWindowsAutoModeInstructions);
})]
} else if !self
.config
.notices
.hide_world_writable_warning
.unwrap_or(false)
&& self.windows_world_writable_flagged()
{
let preset_clone = preset.clone();
// Compute sample paths for the warning popup.
let mut env_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (k, v) in std::env::vars() {
env_map.insert(k, v);
}
let (sample_paths, extra_count, failed_scan) =
match codex_windows_sandbox::preflight_audit_everyone_writable(
&self.config.cwd,
&env_map,
Some(self.config.codex_home.as_path()),
) {
Ok(paths) if !paths.is_empty() => {
fn normalize_windows_path_for_display(
p: &std::path::Path,
) -> String {
let canon = dunce::canonicalize(p)
.unwrap_or_else(|_| p.to_path_buf());
canon.display().to_string().replace('/', "\\")
}
let as_strings: Vec<String> = paths
.iter()
.map(|p| normalize_windows_path_for_display(p))
.collect();
let samples: Vec<String> =
as_strings.iter().take(3).cloned().collect();
let extra = if as_strings.len() > samples.len() {
as_strings.len() - samples.len()
} else {
0
};
(samples, extra, false)
}
Err(_) => (Vec::new(), 0, true),
_ => (Vec::new(), 0, false),
};
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset_clone.clone()),
sample_paths: sample_paths.clone(),
extra_count,
failed_scan,
});
})]
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
}
}
#[cfg(not(target_os = "windows"))]
{
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
}
} else if cfg!(target_os = "windows")
&& preset.id == "auto"
&& codex_core::get_platform_sandbox().is_none()
{
vec![Box::new(|tx| {
tx.send(AppEvent::ShowWindowsAutoModeInstructions);
})]
} else {
Self::approval_preset_actions(preset.approval, preset.sandbox.clone())
};
@@ -2165,23 +2078,6 @@ impl ChatWidget {
})]
}
#[cfg(target_os = "windows")]
fn windows_world_writable_flagged(&self) -> bool {
use std::collections::HashMap;
let mut env_map: HashMap<String, String> = HashMap::new();
for (k, v) in std::env::vars() {
env_map.insert(k, v);
}
match codex_windows_sandbox::preflight_audit_everyone_writable(
&self.config.cwd,
&env_map,
Some(self.config.codex_home.as_path()),
) {
Ok(paths) => !paths.is_empty(),
Err(_) => true,
}
}
pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) {
let approval = preset.approval;
let sandbox = preset.sandbox;
@@ -2246,118 +2142,6 @@ impl ChatWidget {
});
}
#[cfg(target_os = "windows")]
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
preset: Option<ApprovalPreset>,
sample_paths: Vec<String>,
extra_count: usize,
failed_scan: bool,
) {
let (approval, sandbox) = match &preset {
Some(p) => (Some(p.approval), Some(p.sandbox.clone())),
None => (None, None),
};
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let mode_label = match self.config.sandbox_policy {
SandboxPolicy::WorkspaceWrite { .. } => "Auto mode",
SandboxPolicy::ReadOnly => "Read-Only mode",
_ => "Auto mode",
};
let title_line = Line::from("Unprotected directories found").bold();
let info_line = if failed_scan {
Line::from(vec![
"We couldn't complete the world-writable scan, so protections cannot be verified. "
.into(),
format!("The Windows sandbox cannot guarantee protection in {mode_label}.")
.fg(Color::Red),
])
} else {
Line::from(vec![
"Some important directories on this system are world-writable. ".into(),
format!(
"The Windows sandbox cannot protect writes to these locations in {mode_label}."
)
.fg(Color::Red),
])
};
header_children.push(Box::new(title_line));
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
if !sample_paths.is_empty() {
// Show up to three examples and optionally an "and X more" line.
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from("Examples:").bold());
for p in &sample_paths {
lines.push(Line::from(format!(" - {p}")));
}
if extra_count > 0 {
lines.push(Line::from(format!("and {extra_count} more")));
}
header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
}
let header = ColumnRenderable::with(header_children);
// Build actions ensuring acknowledgement happens before applying the new sandbox policy,
// so downstream policy-change hooks don't re-trigger the warning.
let mut accept_actions: Vec<SelectionAction> = Vec::new();
// Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals),
// to avoid duplicate warnings from the ensuing policy change.
if preset.is_some() {
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::SkipNextWorldWritableScan);
}));
}
if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) {
accept_actions.extend(Self::approval_preset_actions(approval, sandbox));
}
let mut accept_and_remember_actions: Vec<SelectionAction> = Vec::new();
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
}));
if let (Some(approval), Some(sandbox)) = (approval, sandbox) {
accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox));
}
let items = vec![
SelectionItem {
name: "Continue".to_string(),
description: Some(format!("Apply {mode_label} for this session")),
actions: accept_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Continue and don't warn again".to_string(),
description: Some(format!("Enable {mode_label} and remember this choice")),
actions: accept_and_remember_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items,
header: Box::new(header),
..Default::default()
});
}
#[cfg(not(target_os = "windows"))]
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
_preset: Option<ApprovalPreset>,
_sample_paths: Vec<String>,
_extra_count: usize,
_failed_scan: bool,
) {
}
#[cfg(target_os = "windows")]
pub(crate) fn open_windows_auto_mode_instructions(&mut self) {
use ratatui_macros::line;
@@ -2409,25 +2193,6 @@ impl ChatWidget {
self.config.notices.hide_full_access_warning = Some(acknowledged);
}
pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) {
self.config.notices.hide_world_writable_warning = Some(acknowledged);
}
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
if hidden {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn world_writable_warning_hidden(&self) -> bool {
self.config
.notices
.hide_world_writable_warning
.unwrap_or(false)
}
/// Set the reasoning effort in the widget's config copy.
pub(crate) fn set_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
self.config.model_reasoning_effort = effort;

View File

@@ -9,8 +9,8 @@ expression: terminal.backend().vt100().screen().contents()
$ echo hello world
1. Yes, proceed (y)
2. Yes, and don't ask again for this command (a)
3. No, and tell Codex what to do differently (esc)
1. Yes, proceed
2. Yes, and don't ask again for this command
3. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -6,8 +6,8 @@ expression: terminal.backend().vt100().screen().contents()
$ echo hello world
1. Yes, proceed (y)
2. Yes, and don't ask again for this command (a)
3. No, and tell Codex what to do differently (esc)
1. Yes, proceed
2. Yes, and don't ask again for this command
3. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -11,7 +11,7 @@ expression: terminal.backend().vt100().screen().contents()
1 +hello
2 +world
1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
1. Yes, proceed
2. No, and tell Codex what to do differently esc
Press enter to confirm or esc to cancel

View File

@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 409
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -14,9 +15,9 @@ Buffer {
" ",
" $ echo hello world ",
" ",
" 1. Yes, proceed (y) ",
" 2. Yes, and don't ask again for this command (a) ",
" 3. No, and tell Codex what to do differently (esc) ",
" 1. Yes, proceed ",
" 2. Yes, and don't ask again for this command ",
" 3. No, and tell Codex what to do differently esc ",
" ",
" Press enter to confirm or esc to cancel ",
],
@@ -29,11 +30,9 @@ Buffer {
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 17, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}

View File

@@ -1,15 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 500
expression: popup
---
Approaching rate limits
Switch to gpt-5-codex-mini for lower credit usage?
1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper,
faster, but less capable.
1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, but
less capable.
2. Keep current model
3. Keep current model (never show again) Hide future rate limit reminders
about switching models.
Press enter to confirm or esc to go back

View File

@@ -12,8 +12,8 @@ expression: terminal.backend()
" "
" $ echo 'hello world' "
" "
" 1. Yes, proceed (y) "
" 2. Yes, and don't ask again for this command (a) "
" 3. No, and tell Codex what to do differently (esc) "
" 1. Yes, proceed "
" 2. Yes, and don't ask again for this command "
" 3. No, and tell Codex what to do differently esc "
" "
" Press enter to confirm or esc to cancel "

Some files were not shown because too many files have changed in this diff Show More