Compare commits

..

29 Commits

Author SHA1 Message Date
Ahmed Ibrahim
da2e1d2ba3 tests 2025-11-20 20:52:17 -08:00
Ahmed Ibrahim
528e7fde9d merge 2025-11-20 19:37:47 -08:00
Dylan Hurd
3f73e2c892 fix(app-server) remove www warning (#7046)
### Summary
After #7022, we no longer need this warning. We should also clean up the
schema for the notification, but this is a quick fix to just stop the
behavior in the VSCE

## Testing
- [x] Ran locally
2025-11-20 19:18:39 -08:00
Dylan Hurd
1822ffe870 feat(tui): default reasoning selection to medium (#7040)
## Summary
- allow selection popups to request an initial highlighted row
- begin the /models reasoning selector focused on the default effort

## Testing
- just fmt
- just fix -p codex-tui
- cargo test -p codex-tui



https://github.com/user-attachments/assets/b322aeb1-e8f3-4578-92f7-5c2fa5ee4c98



------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_691f75e8fc188322a910fbe2138666ef)
2025-11-20 17:06:04 -08:00
Celia Chen
7e2165f394 [app-server] update doc with codex error info (#6941)
Document new codex error info. Also fixed the name from
`codex_error_code` to `codex_error_info`.
2025-11-21 01:02:37 +00:00
Michael Bolin
8e5f38c0f0 feat: waiting for an elicitation should not count against a shell tool timeout (#6973)
Previously, we were running into an issue where we would run the `shell`
tool call with a timeout of 10s, but it fired an elicitation asking for
user approval, the time the user took to respond to the elicitation was
counted agains the 10s timeout, so the `shell` tool call would fail with
a timeout error unless the user is very fast!

This PR addresses this issue by introducing a "stopwatch" abstraction
that is used to manage the timeout. The idea is:

- `Stopwatch::new()` is called with the _real_ timeout of the `shell`
tool call.
- `process_exec_tool_call()` is called with the `Cancellation` variant
of `ExecExpiration` because it should not manage its own timeout in this
case
- the `Stopwatch` expiration is wired up to the `cancel_rx` passed to
`process_exec_tool_call()`
- when an elicitation for the `shell` tool call is received, the
`Stopwatch` pauses
- because it is possible for multiple elicitations to arrive
concurrently, it keeps track of the number of "active pauses" and does
not resume until that counter goes down to zero

I verified that I can test the MCP server using
`@modelcontextprotocol/inspector` and specify `git status` as the
`command` with a timeout of 500ms and that the elicitation pops up and I
have all the time in the world to respond whereas previous to this PR,
that would not have been possible.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/6973).
* #7005
* __->__ #6973
* #6972
2025-11-20 16:45:38 -08:00
Ahmed Ibrahim
1388e99674 fix flaky tool_call_output_exceeds_limit_truncated_chars_limit (#7043)
I am suspecting this is flaky because of the wall time can become 0,
0.1, or 1.
2025-11-20 16:36:29 -08:00
Michael Bolin
f56d1dc8fc feat: update process_exec_tool_call() to take a cancellation token (#6972)
This updates `ExecParams` so that instead of taking `timeout_ms:
Option<u64>`, it now takes a more general cancellation mechanism,
`ExecExpiration`, which is an enum that includes a
`Cancellation(tokio_util::sync::CancellationToken)` variant.

If the cancellation token is fired, then `process_exec_tool_call()`
returns in the same way as if a timeout was exceeded.

This is necessary so that in #6973, we can manage the timeout logic
external to the `process_exec_tool_call()` because we want to "suspend"
the timeout when an elicitation from a human user is pending.








---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/6972).
* #7005
* #6973
* __->__ #6972
2025-11-20 16:29:57 -08:00
Ahmed Ibrahim
9be310041b migrate collect_tool_identifiers_for_model to test_codex (#7041)
Maybe it solved flakiness
2025-11-20 16:02:50 -08:00
Xiao-Yong Jin
0fbcdd77c8 core: make shell behavior portable on FreeBSD (#7039)
- Use /bin/sh instead of /bin/bash on FreeBSD/OpenBSD in the process
group timeout test to avoid command-not-found failures.

- Accept /usr/local/bin/bash as a valid SHELL path to match common
FreeBSD installations.

- Switch the shell serialization duration test to /bin/sh for improved
portability across Unix platforms.

With this change, `cargo test -p codex-core --lib` runs and passes on
FreeBSD.
2025-11-20 16:01:35 -08:00
Celia Chen
9bce050385 [app-server & core] introduce new codex error code and v2 app-server error events (#6938)
This PR does two things:
1. populate a new `codex_error_code` protocol in error events sent from
core to client;
2. old v1 core events `codex/event/stream_error` and `codex/event/error`
will now both become `error`. We also show codex error code for
turncompleted -> error status.

new events in app server test:
```
< {
<   "method": "codex/event/stream_error",
<   "params": {
<     "conversationId": "019aa34c-0c14-70e0-9706-98520a760d67",
<     "id": "0",
<     "msg": {
<       "codex_error_code": {
<         "response_stream_disconnected": {
<           "http_status_code": 401
<         }
<       },
<       "message": "Reconnecting... 2/5",
<       "type": "stream_error"
<     }
<   }
< }

 {
<   "method": "error",
<   "params": {
<     "error": {
<       "codexErrorCode": {
<         "responseStreamDisconnected": {
<           "httpStatusCode": 401
<         }
<       },
<       "message": "Reconnecting... 2/5"
<     }
<   }
< }

< {
<   "method": "turn/completed",
<   "params": {
<     "turn": {
<       "error": {
<         "codexErrorCode": {
<           "responseTooManyFailedAttempts": {
<             "httpStatusCode": 401
<           }
<         },
<         "message": "exceeded retry limit, last status: 401 Unauthorized, request id: 9a1b495a1a97ed3e-SJC"
<       },
<       "id": "0",
<       "items": [],
<       "status": "failed"
<     }
<   }
< }
```
2025-11-20 23:06:55 +00:00
iceweasel-oai
3f92ad4190 add deny ACEs for world writable dirs (#7022)
Our Restricted Token contains 3 SIDs (Logon, Everyone, {WorkspaceWrite
Capability || ReadOnly Capability})

because it must include Everyone, that left us vulnerable to directories
that allow writes to Everyone. Even though those directories do not have
ACEs that enable our capability SIDs to write to them, they could still
be written to even in ReadOnly mode, or even in WorkspaceWrite mode if
they are outside of a writable root.

A solution to this is to explicitly add *Deny* ACEs to these
directories, always for the ReadOnly Capability SID, and for the
WorkspaceWrite SID if the directory is outside of a workspace root.

Under a restricted token, Windows always checks Deny ACEs before Allow
ACEs so even though our restricted token would allow a write to these
directories due to the Everyone SID, it fails first because of the Deny
ACE on the capability SID
2025-11-20 14:50:33 -08:00
Ahmed Ibrahim
54ee302a06 Attempt to fix unified_exec_formats_large_output_summary flakiness (#7029)
second attempt to fix this test after
https://github.com/openai/codex/pull/6884. I think this flakiness is
happening because yield_time is too small for a 10,000 step loop in
python.
2025-11-20 14:38:04 -08:00
Ahmed Ibrahim
44fa06ae36 fix flaky test: approval_matrix_covers_all_modes (#7028)
looks like it sometimes flake around 30. let's give it more time.
2025-11-20 14:37:42 -08:00
pakrym-oai
856f97f449 Delete shell_command feature (#7024) 2025-11-20 14:14:56 -08:00
zhao-oai
fe7a3f0c2b execpolicycheck command in codex cli (#7012)
adding execpolicycheck tool onto codex cli

this is useful for validating policies (can be multiple) against
commands.

it will also surface errors in policy syntax:
<img width="1150" height="281" alt="Screenshot 2025-11-19 at 12 46
21 PM"
src="https://github.com/user-attachments/assets/8f99b403-564c-4172-acc9-6574a8d13dc3"
/>

this PR also changes output format when there's no match in the CLI.
instead of returning the raw string `noMatch`, we return
`{"noMatch":{}}`

this PR is a rewrite of: https://github.com/openai/codex/pull/6932 (due
to the numerous merge conflicts present in the original PR)

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
2025-11-20 16:44:31 -05:00
zhao-oai
c30ca0d5b6 increasing user shell timeout to 1 hour (#7025)
setting user shell timeout to an unreasonably high value since there
isn't an easy way to have a command run without timeouts

currently, user shell commands timeout is 10 seconds
2025-11-20 13:39:16 -08:00
Weiller Carvalho
a8a6cbdd1c fix: route feedback issue links by category (#6840)
## Summary
- TUI feedback note now only links to the bug-report template when the
category is bug/bad result.
- Good result/other feedback shows a thank-you+thread ID instead of
funneling people to file a bug.
- Added a helper + unit test so future changes keep the behavior
consistent.

## Testing
  - just fmt
  - just fix -p codex-tui
  - cargo test -p codex-tui

  Fixes #6839
2025-11-20 13:20:03 -08:00
Dmitri Khokhlov
e4257f432e codex-exec: allow resume --last to read prompt #6717 (#6719)
### Description

- codex exec --json resume --last "<prompt>" bailed out because clap
treated the prompt as SESSION_ID. I removed the conflicts_with flag and
reinterpret that positional as a prompt when
--last is set, so the flow now keeps working in JSON mode.
(codex-rs/exec/src/cli.rs:84-104, codex-rs/exec/src/lib.rs:75-130)
- Added a regression test that exercises resume --last in JSON mode to
ensure the prompt is accepted and the rollout file is updated.
(codex-rs/exec/tests/suite/resume.rs:126-178)

### Testing

  - just fmt
  - cargo test -p codex-exec
  - just fix -p codex-exec
  - cargo test -p codex-exec

#6717

Signed-off-by: Dmitri Khokhlov <dkhokhlov@cribl.io>
2025-11-20 13:10:49 -08:00
Jeremy Rose
2c793083f4 tui: centralize markdown styling and make inline code cyan (#7023)
<img width="762" height="271" alt="Screenshot 2025-11-20 at 12 54 06 PM"
src="https://github.com/user-attachments/assets/10021d63-27eb-407b-8fcc-43740e3bfb0f"
/>
2025-11-20 21:06:22 +00:00
Lionel Cheng
e150798baf Bumped number of fuzzy search results from 8 to 20 (#7013)
I just noticed that in the VSCode / Codex extension when you type @ the
number of results is around 70:

- small video of searching for `mod.rs` inside `codex` repository:
https://github.com/user-attachments/assets/46e53d31-adff-465e-b32b-051c4c1c298c

- while in the CLI the number of results is currently of 8 which is
quite small:
<img width="615" height="439" alt="Screenshot 2025-11-20 at 09 42 04"
src="https://github.com/user-attachments/assets/1c6d12cb-3b1f-4d5b-9ad3-6b12975eaaec"
/>

I bumped it to 20. I had several cases where I wanted a file and did not
find it because the number of results was too small

Signed-off-by: lionel-oai <lionel@openai.com>
Co-authored-by: lionel-oai <lionel@openai.com>
2025-11-20 12:33:12 -08:00
Kyuheon Kim
33a6cc66ab fix(cli): correct mcp add usage order (#6827)
## Summary
- add an explicit `override_usage` string to `AddArgs` so clap prints
`<NAME>` before the command/url choice, matching the actual parser and
docs

### Before

Usage: codex mcp add [OPTIONS] <COMMAND|--url <URL>> <NAME>


### After

Usage: codex mcp add [OPTIONS] <NAME> [--url <URL> | -- <COMMAND>...]

---------

Signed-off-by: kyuheon-kr <kyuheon.kr@gmail.com>
2025-11-20 12:32:12 -08:00
pakrym-oai
52d0ec4cd8 Delete tiktoken-rs (#7018) 2025-11-20 11:15:04 -08:00
LIHUA
397279d46e Fix: Improve text encoding for shell output in VSCode preview (#6178) (#6182)
## 🐛 Problem

Users running commands with non-ASCII characters (like Russian text
"пример") in Windows/WSL environments experience garbled text in
VSCode's shell preview window, with Unicode replacement characters (�)
appearing instead of the actual text.

**Issue**: https://github.com/openai/codex/issues/6178

## 🔧 Root Cause

The issue was in `StreamOutput<Vec<u8>>::from_utf8_lossy()` method in
`codex-rs/core/src/exec.rs`, which used `String::from_utf8_lossy()` to
convert shell output bytes to strings. This function immediately
replaces any invalid UTF-8 byte sequences with replacement characters,
without attempting to decode using other common encodings.

In Windows/WSL environments, shell output often uses encodings like:

- Windows-1252 (common Windows encoding)
- Latin-1/ISO-8859-1 (extended ASCII)

## 🛠️ Solution

Replaced the simple `String::from_utf8_lossy()` call with intelligent
encoding detection via a new `bytes_to_string_smart()` function that
tries multiple encoding strategies:

1. **UTF-8** (fast path for valid UTF-8)
2. **Windows-1252** (handles Windows-specific characters in 0x80-0x9F
range)
3. **Latin-1** (fallback for extended ASCII)
4. **Lossy UTF-8** (final fallback, same as before)

## 📁 Changes

### New Files

- `codex-rs/core/src/text_encoding.rs` - Smart encoding detection module
- `codex-rs/core/tests/suite/text_encoding_fix.rs` - Integration tests

### Modified Files

- `codex-rs/core/src/lib.rs` - Added text_encoding module
- `codex-rs/core/src/exec.rs` - Updated StreamOutput::from_utf8_lossy()
- `codex-rs/core/tests/suite/mod.rs` - Registered new test module

##  Testing

- **5 unit tests** covering UTF-8, Windows-1252, Latin-1, and fallback
scenarios
- **2 integration tests** simulating the exact Issue #6178 scenario
- **Demonstrates improvement** over the previous
`String::from_utf8_lossy()` approach

All tests pass:

```bash
cargo test -p codex-core text_encoding
cargo test -p codex-core test_shell_output_encoding_issue_6178
```

## 🎯 Impact

-  **Eliminates garbled text** in VSCode shell preview for non-ASCII
content
-  **Supports Windows/WSL environments** with proper encoding detection
-  **Zero performance impact** for UTF-8 text (fast path)
-  **Backward compatible** - UTF-8 content works exactly as before
-  **Handles edge cases** with robust fallback mechanism

## 🧪 Test Scenarios

The fix has been tested with:

- Russian text ("пример")
- Windows-1252 quotation marks (""test")
- Latin-1 accented characters ("café")
- Mixed encoding content
- Invalid byte sequences (graceful fallback)

## 📋 Checklist

- [X] Addresses the reported issue
- [X] Includes comprehensive tests
- [X] Maintains backward compatibility
- [X] Follows project coding conventions
- [X] No breaking changes

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
2025-11-20 11:04:11 -08:00
pakrym-oai
30ca89424c Always fallback to real shell (#6953)
Either cmd.exe or `/bin/sh`.
2025-11-20 10:58:46 -08:00
Eric Traut
d909048a85 Added feature switch to disable animations in TUI (#6870)
This PR adds support for a new feature flag `tui.animations`. By
default, the TUI uses animations in its welcome screen, "working"
spinners, and "shimmer" effects. This animations can interfere with
screen readers, so it's good to provide a way to disable them.

This change is inspired by [a
PR](https://github.com/openai/codex/pull/4014) contributed by @Orinks.
That PR has faltered a bit, but I think the core idea is sound. This
version incorporates feedback from @aibrahim-oai. In particular:
1. It uses a feature flag (`tui.animations`) rather than the unqualified
CLI key `no-animations`. Feature flags are the preferred way to expose
boolean switches. They are also exposed via CLI command switches.
2. It includes more complete documentation.
3. It disables a few animations that the other PR omitted.
2025-11-20 10:40:08 -08:00
jif-oai
888c6dd9e7 fix: command formatting for user commands (#7002) 2025-11-20 17:29:15 +01:00
hanson-openai
b5dd189067 Allow unified_exec to early exit (if the process terminates before yield_time_ms) (#6867)
Thread through an `exit_notify` tokio `Notify` through to the
`UnifiedExecSession` so that we can return early if the command
terminates before `yield_time_ms`.

As Codex review correctly pointed out below 🙌 we also need a
`exit_signaled` flag so that commands which finish before we start
waiting can also exit early.

Since the default `yield_time_ms` is now 10s, this means that we don't
have to wait 10s for trivial commands like ls, sed, etc (which are the
majority of agent commands 😅)

---------

Co-authored-by: jif-oai <jif@openai.com>
2025-11-20 13:34:41 +01:00
Ahmed Ibrahim
2e44082a30 shell 2025-11-19 16:44:45 -08:00
114 changed files with 3111 additions and 1493 deletions

View File

@@ -92,15 +92,15 @@ prefix_rule(
In this example rule, if Codex wants to run commands with the prefix `git push` or `git fetch`, it will first ask for user approval.
Use [`execpolicy2` CLI](./codex-rs/execpolicy2/README.md) to preview decisions for policy files:
Use the `codex execpolicy check` subcommand to preview decisions before you save a rule (see the [`codex-execpolicy` README](./codex-rs/execpolicy/README.md) for syntax details):
```shell
cargo run -p codex-execpolicy2 -- check --policy ~/.codex/policy/default.codexpolicy git push origin main
codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push origin main
```
Pass multiple `--policy` flags to test how several files combine. See the [`codex-rs/execpolicy2` README](./codex-rs/execpolicy2/README.md) for a more detailed walkthrough of the available syntax.
Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](./codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax.
---
## Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future.
### Docs & FAQ

View File

@@ -7,3 +7,7 @@ slow-timeout = { period = "15s", terminate-after = 2 }
# Do not add new tests here
filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)'
slow-timeout = { period = "1m", terminate-after = 4 }
[[profile.default.overrides]]
filter = 'test(approval_matrix_covers_all_modes)'
slow-timeout = { period = "30s", terminate-after = 2 }

69
codex-rs/Cargo.lock generated
View File

@@ -260,7 +260,7 @@ dependencies = [
"memchr",
"proc-macro2",
"quote",
"rustc-hash 2.1.1",
"rustc-hash",
"serde",
"serde_derive",
"syn 2.0.104",
@@ -726,6 +726,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chardetng"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "chrono"
version = "0.4.42"
@@ -849,7 +860,6 @@ dependencies = [
"codex-login",
"codex-protocol",
"codex-utils-json-to-toml",
"codex-windows-sandbox",
"core_test_support",
"mcp-types",
"opentelemetry-appender-tracing",
@@ -880,6 +890,7 @@ dependencies = [
"serde",
"serde_json",
"strum_macros 0.27.2",
"thiserror 2.0.17",
"ts-rs",
"uuid",
]
@@ -990,6 +1001,7 @@ dependencies = [
"codex-common",
"codex-core",
"codex-exec",
"codex-execpolicy",
"codex-login",
"codex-mcp-server",
"codex-process-hardening",
@@ -1081,6 +1093,7 @@ dependencies = [
"async-trait",
"base64",
"bytes",
"chardetng",
"chrono",
"codex-app-server-protocol",
"codex-apply-patch",
@@ -1096,13 +1109,13 @@ dependencies = [
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-string",
"codex-utils-tokenizer",
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"ctor 0.5.0",
"dirs",
"dunce",
"encoding_rs",
"env-flags",
"escargot",
"eventsource-stream",
@@ -1202,6 +1215,7 @@ dependencies = [
"socket2 0.6.0",
"tempfile",
"tokio",
"tokio-util",
"tracing",
"tracing-subscriber",
]
@@ -1218,7 +1232,6 @@ dependencies = [
"serde_json",
"shlex",
"starlark",
"tempfile",
"thiserror 2.0.17",
]
@@ -1616,18 +1629,6 @@ dependencies = [
name = "codex-utils-string"
version = "0.0.0"
[[package]]
name = "codex-utils-tokenizer"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-utils-cache",
"pretty_assertions",
"thiserror 2.0.17",
"tiktoken-rs",
"tokio",
]
[[package]]
name = "codex-windows-sandbox"
version = "0.1.0"
@@ -2449,17 +2450,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "fancy-regex"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2"
dependencies = [
"bit-set",
"regex-automata",
"regex-syntax 0.8.5",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -4784,7 +4774,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.1",
"rustc-hash",
"rustls",
"socket2 0.6.0",
"thiserror 2.0.17",
@@ -4804,7 +4794,7 @@ dependencies = [
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash 2.1.1",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
@@ -5149,12 +5139,6 @@ version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -6375,21 +6359,6 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "tiktoken-rs"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d"
dependencies = [
"anyhow",
"base64",
"bstr",
"fancy-regex",
"lazy_static",
"regex",
"rustc-hash 1.1.0",
]
[[package]]
name = "time"
version = "0.3.44"

View File

@@ -41,7 +41,6 @@ members = [
"utils/pty",
"utils/readiness",
"utils/string",
"utils/tokenizer",
]
resolver = "2"
@@ -90,7 +89,6 @@ codex-utils-json-to-toml = { path = "utils/json-to-toml" }
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" }
core_test_support = { path = "core/tests/common" }
mcp-types = { path = "mcp-types" }
@@ -111,6 +109,7 @@ axum = { version = "0.8", default-features = false }
base64 = "0.22.1"
bytes = "1.10.1"
chrono = "0.4.42"
chardetng = "0.1.17"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
@@ -123,6 +122,7 @@ dotenvy = "0.15.7"
dunce = "1.0.4"
env-flags = "0.1.1"
env_logger = "0.11.5"
encoding_rs = "0.8.35"
escargot = "0.5"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
@@ -169,7 +169,6 @@ reqwest = "0.12"
rmcp = { version = "0.8.5", default-features = false }
schemars = "0.8.22"
seccompiler = "0.5.0"
sentry = "0.34.0"
serde = "1"
serde_json = "1"
serde_with = "3.14"
@@ -188,7 +187,6 @@ tempfile = "3.23.0"
test-log = "0.2.18"
textwrap = "0.16.2"
thiserror = "2.0.17"
tiktoken-rs = "0.9"
time = "0.3"
tiny_http = "0.12"
tokio = "1"
@@ -266,7 +264,6 @@ ignored = [
"icu_provider",
"openssl-sys",
"codex-utils-readiness",
"codex-utils-tokenizer",
]
[profile.release]

View File

@@ -19,6 +19,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }

View File

@@ -378,7 +378,7 @@ macro_rules! server_notification_definitions {
impl TryFrom<JSONRPCNotification> for ServerNotification {
type Error = serde_json::Error;
fn try_from(value: JSONRPCNotification) -> Result<Self, Self::Error> {
fn try_from(value: JSONRPCNotification) -> Result<Self, serde_json::Error> {
serde_json::from_value(serde_json::to_value(value)?)
}
}
@@ -487,6 +487,7 @@ pub struct FuzzyFileSearchResponse {
server_notification_definitions! {
/// NEW NOTIFICATIONS
Error => "error" (v2::ErrorNotification),
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
TurnStarted => "turn/started" (v2::TurnStartedNotification),
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),

View File

@@ -11,6 +11,7 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
@@ -20,6 +21,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use thiserror::Error;
use ts_rs::TS;
// Macro to declare a camelCased API v2 enum mirroring a core enum which
@@ -47,6 +49,72 @@ macro_rules! v2_enum_from_core {
};
}
/// This translation layer make sure that we expose codex error code in camel case.
///
/// When an upstream HTTP status is available (for example, from the Responses API or a provider),
/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
HttpConnectionFailed {
#[serde(rename = "httpStatusCode")]
#[ts(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
/// Failed to connect to the response SSE stream.
ResponseStreamConnectionFailed {
#[serde(rename = "httpStatusCode")]
#[ts(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
InternalServerError,
Unauthorized,
BadRequest,
SandboxError,
/// The response SSE stream disconnected in the middle of a turn before completion.
ResponseStreamDisconnected {
#[serde(rename = "httpStatusCode")]
#[ts(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
/// Reached the retry limit for responses.
ResponseTooManyFailedAttempts {
#[serde(rename = "httpStatusCode")]
#[ts(rename = "httpStatusCode")]
http_status_code: Option<u16>,
},
Other,
}
impl From<CoreCodexErrorInfo> for CodexErrorInfo {
fn from(value: CoreCodexErrorInfo) -> Self {
match value {
CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded,
CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => {
CodexErrorInfo::HttpConnectionFailed { http_status_code }
}
CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => {
CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code }
}
CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError,
CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized,
CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest,
CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError,
CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => {
CodexErrorInfo::ResponseStreamDisconnected { http_status_code }
}
CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => {
CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code }
}
CoreCodexErrorInfo::Other => CodexErrorInfo::Other,
}
}
}
v2_enum_from_core!(
pub enum AskForApproval from codex_protocol::protocol::AskForApproval {
UnlessTrusted, OnFailure, OnRequest, Never
@@ -544,11 +612,20 @@ pub struct Turn {
pub status: TurnStatus,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
#[error("{message}")]
pub struct TurnError {
pub message: String,
pub codex_error_info: Option<CodexErrorInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnError {
pub message: String,
pub struct ErrorNotification {
pub error: TurnError,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1091,6 +1168,7 @@ mod tests {
use codex_protocol::items::WebSearchItem;
use codex_protocol::user_input::UserInput as CoreUserInput;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
#[test]
@@ -1176,4 +1254,20 @@ mod tests {
}
);
}
#[test]
fn codex_error_info_serializes_http_status_code_in_camel_case() {
let value = CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: Some(401),
};
assert_eq!(
serde_json::to_value(value).unwrap(),
json!({
"responseTooManyFailedAttempts": {
"httpStatusCode": 401
}
})
);
}
}

View File

@@ -40,7 +40,6 @@ tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
opentelemetry-appender-tracing = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
codex-windows-sandbox.workspace = true
[dev-dependencies]
app_test_support = { workspace = true }

View File

@@ -339,6 +339,29 @@ Event notifications are the server-initiated event stream for thread lifecycles,
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` plus token `usage`), and clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
- `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`.
- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`.
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
#### Errors
`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification.
`codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values:
- `ContextWindowExceeded`
- `UsageLimitExceeded`
- `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx
- `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream
- `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion
- `ResponseTooManyFailedAttempts { httpStatusCode? }`
- `BadRequest`
- `Unauthorized`
- `SandboxError`
- `InternalServerError`
- `Other`: all unclassified errors
When an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.
#### Thread items
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:

View File

@@ -8,11 +8,13 @@ use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::ApplyPatchApprovalParams;
use codex_app_server_protocol::ApplyPatchApprovalResponse;
use codex_app_server_protocol::ApprovalDecision;
use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo;
use codex_app_server_protocol::CommandAction as V2ParsedCommand;
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ExecCommandApprovalParams;
use codex_app_server_protocol::ExecCommandApprovalResponse;
use codex_app_server_protocol::FileChangeRequestApprovalParams;
@@ -153,7 +155,6 @@ pub(crate) async fn apply_bespoke_event_handling(
cwd,
reason,
risk,
allow_prefix: _allow_prefix,
parsed_cmd,
}) => match api_version {
ApiVersion::V1 => {
@@ -261,7 +262,29 @@ pub(crate) async fn apply_bespoke_event_handling(
}
}
EventMsg::Error(ev) => {
handle_error(conversation_id, ev.message, &turn_summary_store).await;
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
};
handle_error(conversation_id, turn_error.clone(), &turn_summary_store).await;
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error: turn_error,
}))
.await;
}
EventMsg::StreamError(ev) => {
// We don't need to update the turn summary store for stream errors as they are intermediate error states for retries,
// but we notify the client.
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
};
outgoing
.send_server_notification(ServerNotification::Error(ErrorNotification {
error: turn_error,
}))
.await;
}
EventMsg::EnteredReviewMode(review_request) => {
let notification = ItemStartedNotification {
@@ -509,10 +532,8 @@ async fn handle_turn_complete(
) {
let turn_summary = find_and_remove_turn_summary(conversation_id, turn_summary_store).await;
let status = if let Some(message) = turn_summary.last_error_message {
TurnStatus::Failed {
error: TurnError { message },
}
let status = if let Some(error) = turn_summary.last_error {
TurnStatus::Failed { error }
} else {
TurnStatus::Completed
};
@@ -533,11 +554,11 @@ async fn handle_turn_interrupted(
async fn handle_error(
conversation_id: ConversationId,
message: String,
error: TurnError,
turn_summary_store: &TurnSummaryStore,
) {
let mut map = turn_summary_store.lock().await;
map.entry(conversation_id).or_default().last_error_message = Some(message);
map.entry(conversation_id).or_default().last_error = Some(error);
}
async fn on_patch_approval_response(
@@ -611,7 +632,6 @@ async fn on_exec_approval_response(
.submit(Op::ExecApproval {
id: event_id,
decision: response.decision,
allow_prefix: None,
})
.await
{
@@ -785,7 +805,6 @@ async fn on_command_execution_request_approval_response(
.submit(Op::ExecApproval {
id: event_id,
decision,
allow_prefix: None,
})
.await
{
@@ -876,10 +895,24 @@ mod tests {
let conversation_id = ConversationId::new();
let turn_summary_store = new_turn_summary_store();
handle_error(conversation_id, "boom".to_string(), &turn_summary_store).await;
handle_error(
conversation_id,
TurnError {
message: "boom".to_string(),
codex_error_info: Some(V2CodexErrorInfo::InternalServerError),
},
&turn_summary_store,
)
.await;
let turn_summary = find_and_remove_turn_summary(conversation_id, &turn_summary_store).await;
assert_eq!(turn_summary.last_error_message, Some("boom".to_string()));
assert_eq!(
turn_summary.last_error,
Some(TurnError {
message: "boom".to_string(),
codex_error_info: Some(V2CodexErrorInfo::InternalServerError),
})
);
Ok(())
}
@@ -919,7 +952,15 @@ mod tests {
let conversation_id = ConversationId::new();
let event_id = "interrupt1".to_string();
let turn_summary_store = new_turn_summary_store();
handle_error(conversation_id, "oops".to_string(), &turn_summary_store).await;
handle_error(
conversation_id,
TurnError {
message: "oops".to_string(),
codex_error_info: None,
},
&turn_summary_store,
)
.await;
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = Arc::new(OutgoingMessageSender::new(tx));
@@ -951,7 +992,15 @@ mod tests {
let conversation_id = ConversationId::new();
let event_id = "complete_err1".to_string();
let turn_summary_store = new_turn_summary_store();
handle_error(conversation_id, "bad".to_string(), &turn_summary_store).await;
handle_error(
conversation_id,
TurnError {
message: "bad".to_string(),
codex_error_info: Some(V2CodexErrorInfo::Other),
},
&turn_summary_store,
)
.await;
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = Arc::new(OutgoingMessageSender::new(tx));
@@ -975,6 +1024,7 @@ mod tests {
TurnStatus::Failed {
error: TurnError {
message: "bad".to_string(),
codex_error_info: Some(V2CodexErrorInfo::Other),
}
}
);
@@ -1025,7 +1075,15 @@ mod tests {
// Turn 1 on conversation A
let a_turn1 = "a_turn1".to_string();
handle_error(conversation_a, "a1".to_string(), &turn_summary_store).await;
handle_error(
conversation_a,
TurnError {
message: "a1".to_string(),
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
},
&turn_summary_store,
)
.await;
handle_turn_complete(
conversation_a,
a_turn1.clone(),
@@ -1036,7 +1094,15 @@ mod tests {
// Turn 1 on conversation B
let b_turn1 = "b_turn1".to_string();
handle_error(conversation_b, "b1".to_string(), &turn_summary_store).await;
handle_error(
conversation_b,
TurnError {
message: "b1".to_string(),
codex_error_info: None,
},
&turn_summary_store,
)
.await;
handle_turn_complete(
conversation_b,
b_turn1.clone(),
@@ -1068,6 +1134,7 @@ mod tests {
TurnStatus::Failed {
error: TurnError {
message: "a1".to_string(),
codex_error_info: Some(V2CodexErrorInfo::BadRequest),
}
}
);
@@ -1088,6 +1155,7 @@ mod tests {
TurnStatus::Failed {
error: TurnError {
message: "b1".to_string(),
codex_error_info: None,
}
}
);

View File

@@ -83,6 +83,7 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
@@ -91,7 +92,6 @@ use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInfoResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::UserSavedConfig;
use codex_app_server_protocol::WindowsWorldWritableWarningNotification;
use codex_app_server_protocol::build_turns_from_event_msgs;
use codex_backend_client::Client as BackendClient;
use codex_core::AuthManager;
@@ -162,8 +162,8 @@ pub(crate) type PendingInterrupts = Arc<Mutex<HashMap<ConversationId, PendingInt
/// Per-conversation accumulation of the latest states e.g. error message while a turn runs.
#[derive(Default, Clone)]
pub(crate) struct TurnSummary {
pub(crate) last_error_message: Option<String>,
pub(crate) file_change_started: HashSet<String>,
pub(crate) last_error: Option<TurnError>,
}
pub(crate) type TurnSummaryStore = Arc<Mutex<HashMap<ConversationId, TurnSummary>>>;
@@ -1170,11 +1170,13 @@ impl CodexMessageProcessor {
let exec_params = ExecParams {
command: params.command,
cwd,
timeout_ms,
expiration: timeout_ms.into(),
env,
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let effective_policy = params
@@ -1276,10 +1278,6 @@ impl CodexMessageProcessor {
return;
}
};
if cfg!(windows) && config.features.enabled(Feature::WindowsSandbox) {
self.handle_windows_world_writable_warning(config.cwd.clone())
.await;
}
match self.conversation_manager.new_conversation(config).await {
Ok(conversation_id) => {
@@ -1999,10 +1997,6 @@ impl CodexMessageProcessor {
return;
}
};
if cfg!(windows) && config.features.enabled(Feature::WindowsSandbox) {
self.handle_windows_world_writable_warning(config.cwd.clone())
.await;
}
let conversation_history = if let Some(path) = path {
match RolloutRecorder::get_rollout_history(&path).await {
@@ -2861,53 +2855,6 @@ impl CodexMessageProcessor {
Err(_) => None,
}
}
/// On Windows, when using the experimental sandbox, we need to warn the user about world-writable directories.
async fn handle_windows_world_writable_warning(&self, cwd: PathBuf) {
if !cfg!(windows) {
return;
}
if !self.config.features.enabled(Feature::WindowsSandbox) {
return;
}
if !matches!(
self.config.sandbox_policy,
codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_protocol::protocol::SandboxPolicy::ReadOnly
) {
return;
}
if self
.config
.notices
.hide_world_writable_warning
.unwrap_or(false)
{
return;
}
// This function is stubbed out to return None on non-Windows platforms
if let Some((sample_paths, extra_count, failed_scan)) =
codex_windows_sandbox::world_writable_warning_details(
self.config.codex_home.as_path(),
cwd,
)
{
tracing::warn!("world writable warning: {sample_paths:?} {extra_count} {failed_scan}");
self.outgoing
.send_server_notification(ServerNotification::WindowsWorldWritableWarning(
WindowsWorldWritableWarningNotification {
sample_paths,
extra_count,
failed_scan,
},
))
.await;
}
}
}
async fn derive_config_from_params(

View File

@@ -26,6 +26,7 @@ codex-cloud-tasks = { path = "../cloud-tasks" }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-exec = { workspace = true }
codex-execpolicy = { workspace = true }
codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-process-hardening = { workspace = true }

View File

@@ -18,6 +18,7 @@ use codex_cli::login::run_logout;
use codex_cloud_tasks::Cli as CloudTasksCli;
use codex_common::CliConfigOverrides;
use codex_exec::Cli as ExecCli;
use codex_execpolicy::ExecPolicyCheckCommand;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
@@ -93,6 +94,10 @@ enum Subcommand {
#[clap(visible_alias = "debug")]
Sandbox(SandboxArgs),
/// Execpolicy tooling.
#[clap(hide = true)]
Execpolicy(ExecpolicyCommand),
/// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree.
#[clap(visible_alias = "a")]
Apply(ApplyCommand),
@@ -162,6 +167,19 @@ enum SandboxCommand {
Windows(WindowsCommand),
}
#[derive(Debug, Parser)]
struct ExecpolicyCommand {
#[command(subcommand)]
sub: ExecpolicySubcommand,
}
#[derive(Debug, clap::Subcommand)]
enum ExecpolicySubcommand {
/// Check execpolicy files against a command.
#[clap(name = "check")]
Check(ExecPolicyCheckCommand),
}
#[derive(Debug, Parser)]
struct LoginCommand {
#[clap(skip)]
@@ -327,6 +345,10 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
Ok(())
}
fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> {
cmd.run()
}
#[derive(Debug, Default, Parser, Clone)]
struct FeatureToggles {
/// Enable a feature (repeatable). Equivalent to `-c features.<name>=true`.
@@ -549,6 +571,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
.await?;
}
},
Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
ExecpolicySubcommand::Check(cmd) => run_execpolicycheck(cmd)?,
},
Some(Subcommand::Apply(mut apply_cli)) => {
prepend_config_flags(
&mut apply_cli.config_overrides,

View File

@@ -79,6 +79,7 @@ pub struct GetArgs {
}
#[derive(Debug, clap::Parser)]
#[command(override_usage = "codex mcp add [OPTIONS] <NAME> (--url <URL> | -- <COMMAND>...)")]
pub struct AddArgs {
/// Name for the MCP server configuration.
pub name: String,

View File

@@ -0,0 +1,58 @@
use std::fs;
use assert_cmd::Command;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn execpolicy_check_matches_expected_json() -> Result<(), Box<dyn std::error::Error>> {
let codex_home = TempDir::new()?;
let policy_path = codex_home.path().join("policy.codexpolicy");
fs::write(
&policy_path,
r#"
prefix_rule(
pattern = ["git", "push"],
decision = "forbidden",
)
"#,
)?;
let output = Command::cargo_bin("codex")?
.env("CODEX_HOME", codex_home.path())
.args([
"execpolicy",
"check",
"--policy",
policy_path
.to_str()
.expect("policy path should be valid UTF-8"),
"git",
"push",
"origin",
"main",
])
.output()?;
assert!(output.status.success());
let result: serde_json::Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(
result,
json!({
"match": {
"decision": "forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "forbidden"
}
}
]
}
})
);
Ok(())
}

View File

@@ -19,6 +19,7 @@ async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
chardetng = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
codex-async-utils = { workspace = true }
@@ -32,11 +33,11 @@ codex-rmcp-client = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-utils-string = { workspace = true }
codex-utils-tokenizer = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
encoding_rs = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }

View File

@@ -100,7 +100,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
if !matches!(flag.as_str(), "-lc" | "-c")
|| !matches!(
detect_shell_type(&PathBuf::from(shell)),
Some(ShellType::Zsh) | Some(ShellType::Bash)
Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh)
)
{
return None;

View File

@@ -68,7 +68,6 @@ use crate::error::CodexErr;
use crate::error::Result as CodexResult;
#[cfg(test)]
use crate::exec::StreamOutput;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_family::find_family_for_model;
@@ -80,7 +79,6 @@ use crate::protocol::ApplyPatchApprovalRequestEvent;
use crate::protocol::AskForApproval;
use crate::protocol::BackgroundEventEvent;
use crate::protocol::DeprecationNoticeEvent;
use crate::protocol::ErrorEvent;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::ExecApprovalRequestEvent;
@@ -130,11 +128,11 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness;
use codex_utils_readiness::ReadinessFlag;
use codex_utils_tokenizer::warm_model_cache;
/// The high-level interface to the Codex system.
/// It operates as a queue pair where you send submissions and receive events.
@@ -494,7 +492,7 @@ impl Session {
// - load history metadata
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
let default_shell_fut = shell::default_user_shell();
let default_shell = shell::default_user_shell();
let history_meta_fut = crate::message_history::history_metadata(&config);
let auth_statuses_fut = compute_auth_statuses(
config.mcp_servers.iter(),
@@ -502,12 +500,8 @@ impl Session {
);
// Join all independent futures.
let (rollout_recorder, default_shell, (history_log_id, history_entry_count), auth_statuses) = tokio::join!(
rollout_fut,
default_shell_fut,
history_meta_fut,
auth_statuses_fut
);
let (rollout_recorder, (history_log_id, history_entry_count), auth_statuses) =
tokio::join!(rollout_fut, history_meta_fut, auth_statuses_fut);
let rollout_recorder = rollout_recorder.map_err(|e| {
error!("failed to initialize rollout recorder: {e:#}");
@@ -560,9 +554,6 @@ impl Session {
// Create the mutable state for the Session.
let state = SessionState::new(session_configuration.clone());
// Warm the tokenizer cache for the session model without blocking startup.
warm_model_cache(&session_configuration.model);
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: CancellationToken::new(),
@@ -846,43 +837,11 @@ impl Session {
.await
}
pub(crate) async fn persist_command_allow_prefix(
&self,
prefix: &[String],
) -> Result<(), ExecPolicyUpdateError> {
let (features, codex_home) = {
let state = self.state.lock().await;
(
state.session_configuration.features.clone(),
state
.session_configuration
.original_config_do_not_use
.codex_home
.clone(),
)
};
let policy =
crate::exec_policy::append_allow_prefix_rule_and_reload(&features, &codex_home, prefix)
.await?;
let mut state = self.state.lock().await;
state.session_configuration.exec_policy = policy;
Ok(())
}
pub(crate) async fn current_exec_policy(&self) -> Arc<ExecPolicy> {
let state = self.state.lock().await;
state.session_configuration.exec_policy.clone()
}
/// Emit an exec approval request event and await the user's decision.
///
/// The request is keyed by `sub_id`/`call_id` so matching responses are delivered
/// to the correct in-flight turn. If the task is aborted, this returns the
/// default `ReviewDecision` (`Denied`).
#[allow(clippy::too_many_arguments)]
pub async fn request_command_approval(
&self,
turn_context: &TurnContext,
@@ -891,7 +850,6 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
) -> ReviewDecision {
let sub_id = turn_context.sub_id.clone();
// Add the tx_approve callback to the map before sending the request.
@@ -919,7 +877,6 @@ impl Session {
cwd,
reason,
risk,
allow_prefix,
parsed_cmd,
});
self.send_event(turn_context, event).await;
@@ -1092,7 +1049,7 @@ impl Session {
Some(turn_context.cwd.clone()),
Some(turn_context.approval_policy),
Some(turn_context.sandbox_policy.clone()),
Some(self.user_shell().clone()),
self.user_shell().clone(),
)));
items
}
@@ -1232,9 +1189,14 @@ impl Session {
&self,
turn_context: &TurnContext,
message: impl Into<String>,
codex_error: CodexErr,
) {
let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected {
http_status_code: codex_error.http_status_code_value(),
};
let event = EventMsg::StreamError(StreamErrorEvent {
message: message.into(),
codex_error_info: Some(codex_error_info),
});
self.send_event(turn_context, event).await;
}
@@ -1418,12 +1380,8 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op, &mut previous_context)
.await;
}
Op::ExecApproval {
id,
decision,
allow_prefix,
} => {
handlers::exec_approval(&sess, id, decision, allow_prefix).await;
Op::ExecApproval { id, decision } => {
handlers::exec_approval(&sess, id, decision).await;
}
Op::PatchApproval { id, decision } => {
handlers::patch_approval(&sess, id, decision).await;
@@ -1484,6 +1442,7 @@ mod handlers {
use crate::tasks::UndoTask;
use crate::tasks::UserShellCommandTask;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -1492,7 +1451,6 @@ mod handlers {
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use std::sync::Arc;
@@ -1578,28 +1536,7 @@ mod handlers {
*previous_context = Some(turn_context);
}
pub async fn exec_approval(
sess: &Arc<Session>,
id: String,
decision: ReviewDecision,
allow_prefix: Option<Vec<String>>,
) {
if let Some(prefix) = allow_prefix
&& matches!(
decision,
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
)
&& let Err(err) = sess.persist_command_allow_prefix(&prefix).await
{
let message = format!("Failed to update execpolicy allow list: {err}");
tracing::warn!("{message}");
let warning = EventMsg::Warning(WarningEvent { message });
sess.send_event_raw(Event {
id: id.clone(),
msg: warning,
})
.await;
}
pub async fn exec_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
match decision {
ReviewDecision::Abort => {
sess.interrupt_task().await;
@@ -1752,6 +1689,7 @@ mod handlers {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: "Failed to shutdown rollout recorder".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
};
sess.send_event_raw(event).await;
@@ -2006,9 +1944,7 @@ pub(crate) async fn run_task(
}
Err(e) => {
info!("Turn error: {e:#}");
let event = EventMsg::Error(ErrorEvent {
message: e.to_string(),
});
let event = EventMsg::Error(e.to_error_event(None));
sess.send_event(&turn_context, event).await;
// let the user continue the conversation
break;
@@ -2133,6 +2069,7 @@ async fn run_turn(
sess.notify_stream_error(
&turn_context,
format!("Reconnecting... {retries}/{max_retries}"),
e,
)
.await;
@@ -2451,6 +2388,7 @@ mod tests {
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::exec::ExecToolCallOutput;
use crate::shell::default_user_shell;
use crate::tools::format_exec_output_str;
use crate::protocol::CompactedItem;
@@ -2690,7 +2628,7 @@ mod tests {
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: UserNotifier::new(None),
rollout: Mutex::new(None),
user_shell: shell::Shell::Unknown,
user_shell: default_user_shell(),
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
auth_manager: Arc::clone(&auth_manager),
otel_event_manager: otel_event_manager.clone(),
@@ -2768,7 +2706,7 @@ mod tests {
unified_exec_manager: UnifiedExecSessionManager::default(),
notifier: UserNotifier::new(None),
rollout: Mutex::new(None),
user_shell: shell::Shell::Unknown,
user_shell: default_user_shell(),
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
auth_manager: Arc::clone(&auth_manager),
otel_event_manager: otel_event_manager.clone(),
@@ -3113,6 +3051,7 @@ mod tests {
let session = Arc::new(session);
let mut turn_context = Arc::new(turn_context_raw);
let timeout_ms = 1000;
let params = ExecParams {
command: if cfg!(windows) {
vec![
@@ -3128,16 +3067,25 @@ mod tests {
]
},
cwd: turn_context.cwd.clone(),
timeout_ms: Some(1000),
expiration: timeout_ms.into(),
env: HashMap::new(),
with_escalated_permissions: Some(true),
justification: Some("test".to_string()),
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let params2 = ExecParams {
with_escalated_permissions: Some(false),
..params.clone()
command: params.command.clone(),
cwd: params.cwd.clone(),
expiration: timeout_ms.into(),
env: HashMap::new(),
justification: params.justification.clone(),
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
@@ -3157,7 +3105,7 @@ mod tests {
arguments: serde_json::json!({
"command": params.command.clone(),
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
"timeout_ms": params.timeout_ms,
"timeout_ms": params.expiration.timeout_ms(),
"with_escalated_permissions": params.with_escalated_permissions,
"justification": params.justification.clone(),
})
@@ -3194,7 +3142,7 @@ mod tests {
arguments: serde_json::json!({
"command": params2.command.clone(),
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
"timeout_ms": params2.timeout_ms,
"timeout_ms": params2.expiration.timeout_ms(),
"with_escalated_permissions": params2.with_escalated_permissions,
"justification": params2.justification.clone(),
})

View File

@@ -235,7 +235,6 @@ async fn handle_exec_approval(
event.cwd,
event.reason,
event.risk,
event.allow_prefix,
);
let decision = await_approval_with_cancel(
approval_fut,
@@ -245,13 +244,7 @@ async fn handle_exec_approval(
)
.await;
let _ = codex
.submit(Op::ExecApproval {
id,
decision,
allow_prefix: None,
})
.await;
let _ = codex.submit(Op::ExecApproval { id, decision }).await;
}
/// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying.

View File

@@ -10,7 +10,6 @@ use crate::error::Result as CodexResult;
use crate::features::Feature;
use crate::protocol::AgentMessageEvent;
use crate::protocol::CompactedItem;
use crate::protocol::ErrorEvent;
use crate::protocol::EventMsg;
use crate::protocol::TaskStartedEvent;
use crate::protocol::TurnContextItem;
@@ -128,9 +127,7 @@ async fn run_compact_task_inner(
continue;
}
sess.set_total_tokens_full(turn_context.as_ref()).await;
let event = EventMsg::Error(ErrorEvent {
message: e.to_string(),
});
let event = EventMsg::Error(e.to_error_event(None));
sess.send_event(&turn_context, event).await;
return;
}
@@ -141,14 +138,13 @@ async fn run_compact_task_inner(
sess.notify_stream_error(
turn_context.as_ref(),
format!("Reconnecting... {retries}/{max_retries}"),
e,
)
.await;
tokio::time::sleep(delay).await;
continue;
} else {
let event = EventMsg::Error(ErrorEvent {
message: e.to_string(),
});
let event = EventMsg::Error(e.to_error_event(None));
sess.send_event(&turn_context, event).await;
return;
}

View File

@@ -6,7 +6,6 @@ use crate::codex::TurnContext;
use crate::error::Result as CodexResult;
use crate::protocol::AgentMessageEvent;
use crate::protocol::CompactedItem;
use crate::protocol::ErrorEvent;
use crate::protocol::EventMsg;
use crate::protocol::RolloutItem;
use crate::protocol::TaskStartedEvent;
@@ -30,9 +29,9 @@ pub(crate) async fn run_remote_compact_task(sess: Arc<Session>, turn_context: Ar
async fn run_remote_compact_task_inner(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await {
let event = EventMsg::Error(ErrorEvent {
message: format!("Error running remote compact task: {err}"),
});
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
);
sess.send_event(turn_context, event).await;
}
}

View File

@@ -4,7 +4,6 @@ use crate::config::types::Notice;
use anyhow::Context;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::TrustLevel;
use codex_utils_tokenizer::warm_model_cache;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
@@ -231,9 +230,6 @@ impl ConfigDocument {
fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result<bool> {
match edit {
ConfigEdit::SetModel { model, effort } => Ok({
if let Some(model) = &model {
warm_model_cache(model)
}
let mut mutated = false;
mutated |= self.write_profile_value(
&["model"],

View File

@@ -160,6 +160,9 @@ pub struct Config {
/// and turn completions when not focused.
pub tui_notifications: Notifications,
/// Enable ASCII animations and shimmer effects in the TUI.
pub animations: bool,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@@ -1253,6 +1256,7 @@ impl Config {
.as_ref()
.map(|t| t.notifications.clone())
.unwrap_or_default(),
animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -3003,6 +3007,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -3075,6 +3080,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
};
@@ -3162,6 +3168,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
};
@@ -3235,6 +3242,7 @@ model_verbosity = "high"
notices: Default::default(),
disable_paste_burst: false,
tui_notifications: Default::default(),
animations: true,
otel: OtelConfig::default(),
};

View File

@@ -363,6 +363,15 @@ pub struct Tui {
/// Defaults to `true`.
#[serde(default)]
pub notifications: Notifications,
/// Enable animations (welcome screen, shimmer effects, spinners).
/// Defaults to `true`.
#[serde(default = "default_true")]
pub animations: bool,
}
const fn default_true() -> bool {
true
}
/// Settings for notices we display to users via the tui and app-server clients

View File

@@ -1,13 +1,14 @@
use crate::codex::TurnContext;
use crate::context_manager::normalize;
use crate::truncate::TruncationPolicy;
use crate::truncate::approx_token_count;
use crate::truncate::truncate_function_output_items_with_policy;
use crate::truncate::truncate_text;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_utils_tokenizer::Tokenizer;
use std::ops::Deref;
/// Transcript of conversation history
@@ -74,26 +75,21 @@ impl ContextManager {
history
}
// Estimate the number of tokens in the history. Return None if no tokenizer
// is available. This does not consider the reasoning traces.
// /!\ The value is a lower bound estimate and does not represent the exact
// context length.
// Estimate token usage using byte-based heuristics from the truncation helpers.
// This is a coarse lower bound, not a tokenizer-accurate count.
pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option<i64> {
let model = turn_context.client.get_model();
let tokenizer = Tokenizer::for_model(model.as_str()).ok()?;
let model_family = turn_context.client.get_model_family();
let base_tokens =
i64::try_from(approx_token_count(model_family.base_instructions.as_str()))
.unwrap_or(i64::MAX);
Some(
self.items
.iter()
.map(|item| {
serde_json::to_string(&item)
.map(|item| tokenizer.count(&item))
.unwrap_or_default()
})
.sum::<i64>()
+ tokenizer.count(model_family.base_instructions.as_str()),
)
let items_tokens = self.items.iter().fold(0i64, |acc, item| {
let serialized = serde_json::to_string(item).unwrap_or_default();
let item_tokens = i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX);
acc.saturating_add(item_tokens)
});
Some(base_tokens.saturating_add(items_tokens))
}
pub(crate) fn remove_first_item(&mut self) {
@@ -135,6 +131,47 @@ impl ContextManager {
normalize::remove_orphan_outputs(&mut self.items);
}
fn get_shell_truncation_policy(&self, call_id: &str) -> Option<TruncationPolicy> {
let call = self.get_call_for_call_id(call_id)?;
match call {
ResponseItem::FunctionCall { arguments, .. } => {
let shell_tool_call_params =
serde_json::from_str::<ShellToolCallParams>(&arguments).ok()?;
Self::create_truncation_policy(
shell_tool_call_params.max_output_tokens,
shell_tool_call_params.max_output_chars,
)
}
_ => None,
}
}
fn create_truncation_policy(
max_output_tokens: Option<usize>,
max_output_chars: Option<usize>,
) -> Option<TruncationPolicy> {
if let Some(max_output_tokens) = max_output_tokens {
Some(TruncationPolicy::Tokens(max_output_tokens))
} else {
max_output_chars.map(TruncationPolicy::Bytes)
}
}
fn get_call_for_call_id(&self, call_id: &str) -> Option<ResponseItem> {
self.items.iter().find_map(|item| match item {
ResponseItem::FunctionCall {
call_id: existing, ..
} => {
if existing == call_id {
Some(item.clone())
} else {
None
}
}
_ => None,
})
}
/// Returns a clone of the contents in the transcript.
fn contents(&self) -> Vec<ResponseItem> {
self.items.clone()
@@ -148,13 +185,12 @@ impl ContextManager {
let policy_with_serialization_budget = policy.mul(1.2);
match item {
ResponseItem::FunctionCallOutput { call_id, output } => {
let truncated =
truncate_text(output.content.as_str(), policy_with_serialization_budget);
let truncation_policy_override = self.get_shell_truncation_policy(call_id);
let truncation_policy =
truncation_policy_override.unwrap_or(policy_with_serialization_budget);
let truncated = truncate_text(output.content.as_str(), truncation_policy);
let truncated_items = output.content_items.as_ref().map(|items| {
truncate_function_output_items_with_policy(
items,
policy_with_serialization_budget,
)
truncate_function_output_items_with_policy(items, truncation_policy)
});
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),

View File

@@ -6,6 +6,7 @@ use crate::codex::TurnContext;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::shell::Shell;
use crate::shell::default_user_shell;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -28,7 +29,7 @@ pub(crate) struct EnvironmentContext {
pub sandbox_mode: Option<SandboxMode>,
pub network_access: Option<NetworkAccess>,
pub writable_roots: Option<Vec<PathBuf>>,
pub shell: Option<Shell>,
pub shell: Shell,
}
impl EnvironmentContext {
@@ -36,7 +37,7 @@ impl EnvironmentContext {
cwd: Option<PathBuf>,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
shell: Option<Shell>,
shell: Shell,
) -> Self {
Self {
cwd,
@@ -110,7 +111,7 @@ impl EnvironmentContext {
} else {
None
};
EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None)
EnvironmentContext::new(cwd, approval_policy, sandbox_policy, default_user_shell())
}
}
@@ -121,7 +122,7 @@ impl From<&TurnContext> for EnvironmentContext {
Some(turn_context.approval_policy),
Some(turn_context.sandbox_policy.clone()),
// Shell is not configurable from turn to turn
None,
default_user_shell(),
)
}
}
@@ -169,11 +170,9 @@ impl EnvironmentContext {
}
lines.push(" </writable_roots>".to_string());
}
if let Some(shell) = self.shell
&& let Some(shell_name) = shell.name()
{
lines.push(format!(" <shell>{shell_name}</shell>"));
}
let shell_name = self.shell.name();
lines.push(format!(" <shell>{shell_name}</shell>"));
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
lines.join("\n")
}
@@ -193,12 +192,18 @@ impl From<EnvironmentContext> for ResponseItem {
#[cfg(test)]
mod tests {
use crate::shell::BashShell;
use crate::shell::ZshShell;
use crate::shell::ShellType;
use super::*;
use pretty_assertions::assert_eq;
fn fake_shell() -> Shell {
Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
}
}
fn workspace_write_policy(writable_roots: Vec<&str>, network_access: bool) -> SandboxPolicy {
SandboxPolicy::WorkspaceWrite {
writable_roots: writable_roots.into_iter().map(PathBuf::from).collect(),
@@ -214,7 +219,7 @@ mod tests {
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], false)),
None,
fake_shell(),
);
let expected = r#"<environment_context>
@@ -226,6 +231,7 @@ mod tests {
<root>/repo</root>
<root>/tmp</root>
</writable_roots>
<shell>bash</shell>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -237,13 +243,14 @@ mod tests {
None,
Some(AskForApproval::Never),
Some(SandboxPolicy::ReadOnly),
None,
fake_shell(),
);
let expected = r#"<environment_context>
<approval_policy>never</approval_policy>
<sandbox_mode>read-only</sandbox_mode>
<network_access>restricted</network_access>
<shell>bash</shell>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -255,13 +262,14 @@ mod tests {
None,
Some(AskForApproval::OnFailure),
Some(SandboxPolicy::DangerFullAccess),
None,
fake_shell(),
);
let expected = r#"<environment_context>
<approval_policy>on-failure</approval_policy>
<sandbox_mode>danger-full-access</sandbox_mode>
<network_access>enabled</network_access>
<shell>bash</shell>
</environment_context>"#;
assert_eq!(context.serialize_to_xml(), expected);
@@ -274,13 +282,13 @@ mod tests {
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
None,
fake_shell(),
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::Never),
Some(workspace_write_policy(vec!["/repo"], true)),
None,
fake_shell(),
);
assert!(!context1.equals_except_shell(&context2));
}
@@ -291,13 +299,13 @@ mod tests {
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_read_only_policy()),
None,
fake_shell(),
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(SandboxPolicy::new_workspace_write_policy()),
None,
fake_shell(),
);
assert!(!context1.equals_except_shell(&context2));
@@ -309,13 +317,13 @@ mod tests {
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp", "/var"], false)),
None,
fake_shell(),
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo", "/tmp"], true)),
None,
fake_shell(),
);
assert!(!context1.equals_except_shell(&context2));
@@ -327,17 +335,19 @@ mod tests {
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
Some(Shell::Bash(BashShell {
Shell {
shell_type: ShellType::Bash,
shell_path: "/bin/bash".into(),
})),
},
);
let context2 = EnvironmentContext::new(
Some(PathBuf::from("/repo")),
Some(AskForApproval::OnRequest),
Some(workspace_write_policy(vec!["/repo"], false)),
Some(Shell::Zsh(ZshShell {
Shell {
shell_type: ShellType::Zsh,
shell_path: "/bin/zsh".into(),
})),
},
);
assert!(context1.equals_except_shell(&context2));

View File

@@ -10,6 +10,8 @@ use chrono::Local;
use chrono::Utc;
use codex_async_utils::CancelErr;
use codex_protocol::ConversationId;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use reqwest::StatusCode;
use serde_json;
@@ -430,6 +432,57 @@ impl CodexErr {
pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> {
(self as &dyn std::any::Any).downcast_ref::<T>()
}
/// Translate core error to client-facing protocol error.
pub fn to_codex_protocol_error(&self) -> CodexErrorInfo {
match self {
CodexErr::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
CodexErr::UsageLimitReached(_)
| CodexErr::QuotaExceeded
| CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: self.http_status_code_value(),
},
CodexErr::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed {
http_status_code: self.http_status_code_value(),
},
CodexErr::ResponseStreamFailed(_) => CodexErrorInfo::ResponseStreamConnectionFailed {
http_status_code: self.http_status_code_value(),
},
CodexErr::RefreshTokenFailed(_) => CodexErrorInfo::Unauthorized,
CodexErr::SessionConfiguredNotFirstEvent
| CodexErr::InternalServerError
| CodexErr::InternalAgentDied => CodexErrorInfo::InternalServerError,
CodexErr::UnsupportedOperation(_) | CodexErr::ConversationNotFound(_) => {
CodexErrorInfo::BadRequest
}
CodexErr::Sandbox(_) => CodexErrorInfo::SandboxError,
_ => CodexErrorInfo::Other,
}
}
pub fn to_error_event(&self, message_prefix: Option<String>) -> ErrorEvent {
let error_message = self.to_string();
let message: String = match message_prefix {
Some(prefix) => format!("{prefix}: {error_message}"),
None => error_message,
};
ErrorEvent {
message,
codex_error_info: Some(self.to_codex_protocol_error()),
}
}
pub fn http_status_code_value(&self) -> Option<u16> {
let http_status_code = match self {
CodexErr::RetryLimit(err) => Some(err.status),
CodexErr::UnexpectedStatus(err) => Some(err.status),
CodexErr::ConnectionFailed(err) => err.source.status(),
CodexErr::ResponseStreamFailed(err) => err.source.status(),
_ => None,
};
http_status_code.as_ref().map(StatusCode::as_u16)
}
}
pub fn get_error_message_ui(e: &CodexErr) -> String {
@@ -478,6 +531,10 @@ mod tests {
use chrono::Utc;
use codex_protocol::protocol::RateLimitWindow;
use pretty_assertions::assert_eq;
use reqwest::Response;
use reqwest::ResponseBuilderExt;
use reqwest::StatusCode;
use reqwest::Url;
fn rate_limit_snapshot() -> RateLimitSnapshot {
let primary_reset_at = Utc
@@ -573,6 +630,33 @@ mod tests {
assert_eq!(get_error_message_ui(&err), "stdout only");
}
#[test]
fn to_error_event_handles_response_stream_failed() {
let response = http::Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.url(Url::parse("http://example.com").unwrap())
.body("")
.unwrap();
let source = Response::from(response).error_for_status_ref().unwrap_err();
let err = CodexErr::ResponseStreamFailed(ResponseStreamFailed {
source,
request_id: Some("req-123".to_string()),
});
let event = err.to_error_event(Some("prefix".to_string()));
assert_eq!(
event.message,
"prefix: Error while reading the server response: HTTP status client error (429 Too Many Requests) for url (http://example.com/), request id: req-123"
);
assert_eq!(
event.codex_error_info,
Some(CodexErrorInfo::ResponseStreamConnectionFailed {
http_status_code: Some(429)
})
);
}
#[test]
fn sandbox_denied_reports_exit_code_when_no_output_available() {
let output = ExecToolCallOutput {

View File

@@ -14,6 +14,7 @@ use tokio::io::AsyncRead;
use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio_util::sync::CancellationToken;
use crate::error::CodexErr;
use crate::error::Result;
@@ -28,8 +29,9 @@ use crate::sandboxing::ExecEnv;
use crate::sandboxing::SandboxManager;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use crate::text_encoding::bytes_to_string_smart;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000;
// Hardcode these since it does not seem worth including the libc crate just
// for these.
@@ -46,20 +48,61 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB
/// Aggregation still collects full output; only the live event stream is capped.
pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000;
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct ExecParams {
pub command: Vec<String>,
pub cwd: PathBuf,
pub timeout_ms: Option<u64>,
pub expiration: ExecExpiration,
pub env: HashMap<String, String>,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub arg0: Option<String>,
pub max_output_tokens: Option<usize>,
pub max_output_chars: Option<usize>,
}
impl ExecParams {
pub fn timeout_duration(&self) -> Duration {
Duration::from_millis(self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
/// Mechanism to terminate an exec invocation before it finishes naturally.
#[derive(Debug)]
pub enum ExecExpiration {
Timeout(Duration),
DefaultTimeout,
Cancellation(CancellationToken),
}
impl From<Option<u64>> for ExecExpiration {
fn from(timeout_ms: Option<u64>) -> Self {
timeout_ms.map_or(ExecExpiration::DefaultTimeout, |timeout_ms| {
ExecExpiration::Timeout(Duration::from_millis(timeout_ms))
})
}
}
impl From<u64> for ExecExpiration {
fn from(timeout_ms: u64) -> Self {
ExecExpiration::Timeout(Duration::from_millis(timeout_ms))
}
}
impl ExecExpiration {
async fn wait(self) {
match self {
ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await,
ExecExpiration::DefaultTimeout => {
tokio::time::sleep(Duration::from_millis(DEFAULT_EXEC_COMMAND_TIMEOUT_MS)).await
}
ExecExpiration::Cancellation(cancel) => {
cancel.cancelled().await;
}
}
}
/// If ExecExpiration is a timeout, returns the timeout in milliseconds.
pub(crate) fn timeout_ms(&self) -> Option<u64> {
match self {
ExecExpiration::Timeout(duration) => Some(duration.as_millis() as u64),
ExecExpiration::DefaultTimeout => Some(DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
ExecExpiration::Cancellation(_) => None,
}
}
}
@@ -95,11 +138,13 @@ pub async fn process_exec_tool_call(
let ExecParams {
command,
cwd,
timeout_ms,
expiration,
env,
with_escalated_permissions,
justification,
arg0: _,
max_output_tokens,
max_output_chars,
} = params;
let (program, args) = command.split_first().ok_or_else(|| {
@@ -114,15 +159,17 @@ pub async fn process_exec_tool_call(
args: args.to_vec(),
cwd,
env,
timeout_ms,
expiration,
with_escalated_permissions,
justification,
max_output_tokens,
max_output_chars,
};
let manager = SandboxManager::new();
let exec_env = manager
.transform(
&spec,
spec,
sandbox_policy,
sandbox_type,
sandbox_cwd,
@@ -131,7 +178,7 @@ pub async fn process_exec_tool_call(
.map_err(CodexErr::from)?;
// Route through the sandboxing module for a single, unified execution path.
crate::sandboxing::execute_env(&exec_env, sandbox_policy, stdout_stream).await
crate::sandboxing::execute_env(exec_env, sandbox_policy, stdout_stream).await
}
pub(crate) async fn execute_exec_env(
@@ -143,21 +190,25 @@ pub(crate) async fn execute_exec_env(
command,
cwd,
env,
timeout_ms,
expiration,
sandbox,
with_escalated_permissions,
justification,
arg0,
max_output_tokens,
max_output_chars,
} = env;
let params = ExecParams {
command,
cwd,
timeout_ms,
expiration,
env,
with_escalated_permissions,
justification,
arg0,
max_output_tokens,
max_output_chars,
};
let start = Instant::now();
@@ -178,9 +229,12 @@ async fn exec_windows_sandbox(
command,
cwd,
env,
timeout_ms,
expiration,
..
} = params;
// TODO(iceweasel-oai): run_windows_sandbox_capture should support all
// variants of ExecExpiration, not just timeout.
let timeout_ms = expiration.timeout_ms();
let policy_str = serde_json::to_string(sandbox_policy).map_err(|err| {
CodexErr::Io(io::Error::other(format!(
@@ -414,7 +468,7 @@ impl StreamOutput<String> {
impl StreamOutput<Vec<u8>> {
pub fn from_utf8_lossy(&self) -> StreamOutput<String> {
StreamOutput {
text: String::from_utf8_lossy(&self.text).to_string(),
text: bytes_to_string_smart(&self.text),
truncated_after_lines: self.truncated_after_lines,
}
}
@@ -448,12 +502,12 @@ async fn exec(
{
return exec_windows_sandbox(params, sandbox_policy).await;
}
let timeout = params.timeout_duration();
let ExecParams {
command,
cwd,
env,
arg0,
expiration,
..
} = params;
@@ -474,14 +528,14 @@ async fn exec(
env,
)
.await?;
consume_truncated_output(child, timeout, stdout_stream).await
consume_truncated_output(child, expiration, stdout_stream).await
}
/// Consumes the output of a child process, truncating it so it is suitable for
/// use as the output of a `shell` tool call. Also enforces specified timeout.
async fn consume_truncated_output(
mut child: Child,
timeout: Duration,
expiration: ExecExpiration,
stdout_stream: Option<StdoutStream>,
) -> Result<RawExecToolCallOutput> {
// Both stdout and stderr were configured with `Stdio::piped()`
@@ -515,20 +569,14 @@ async fn consume_truncated_output(
));
let (exit_status, timed_out) = tokio::select! {
result = tokio::time::timeout(timeout, child.wait()) => {
match result {
Ok(status_result) => {
let exit_status = status_result?;
(exit_status, false)
}
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)
}
}
status_result = child.wait() => {
let exit_status = status_result?;
(exit_status, false)
}
_ = expiration.wait() => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
(synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true)
}
_ = tokio::signal::ctrl_c() => {
kill_child_process_group(&mut child)?;
@@ -780,6 +828,15 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> {
// On Linux/macOS, /bin/bash is typically present; on FreeBSD/OpenBSD,
// prefer /bin/sh to avoid NotFound errors.
#[cfg(any(target_os = "freebsd", target_os = "openbsd"))]
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"sleep 60 & echo $!; sleep 60".to_string(),
];
#[cfg(all(unix, not(any(target_os = "freebsd", target_os = "openbsd"))))]
let command = vec![
"/bin/bash".to_string(),
"-c".to_string(),
@@ -789,11 +846,13 @@ mod tests {
let params = ExecParams {
command,
cwd: std::env::current_dir()?,
timeout_ms: Some(500),
expiration: 500.into(),
env,
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?;
@@ -823,4 +882,64 @@ mod tests {
assert!(killed, "grandchild process with pid {pid} is still alive");
Ok(())
}
#[tokio::test]
async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
let command = long_running_command();
let cwd = std::env::current_dir()?;
let env: HashMap<String, String> = std::env::vars().collect();
let cancel_token = CancellationToken::new();
let cancel_tx = cancel_token.clone();
let params = ExecParams {
command,
cwd: cwd.clone(),
expiration: ExecExpiration::Cancellation(cancel_token),
env,
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(1_000)).await;
cancel_tx.cancel();
});
let result = process_exec_tool_call(
params,
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
cwd.as_path(),
&None,
None,
)
.await;
let output = match result {
Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output,
other => panic!("expected timeout error, got {other:?}"),
};
assert!(output.timed_out);
assert_eq!(output.exit_code, EXEC_TIMEOUT_EXIT_CODE);
Ok(())
}
#[cfg(unix)]
fn long_running_command() -> Vec<String> {
vec![
"/bin/sh".to_string(),
"-c".to_string(),
"sleep 30".to_string(),
]
}
#[cfg(windows)]
fn long_running_command() -> Vec<String> {
vec![
"powershell.exe".to_string(),
"-NonInteractive".to_string(),
"-NoLogo".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 30".to_string(),
]
}
}

View File

@@ -109,7 +109,7 @@ fn evaluate_with_policy(
}
Decision::Allow => Some(ApprovalRequirement::Skip),
},
Evaluation::NoMatch => None,
Evaluation::NoMatch { .. } => None,
}
}
@@ -206,7 +206,7 @@ mod tests {
let commands = [vec!["rm".to_string()]];
assert!(matches!(
policy.check_multiple(commands.iter()),
Evaluation::NoMatch
Evaluation::NoMatch { .. }
));
assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists());
}
@@ -259,7 +259,7 @@ mod tests {
let command = [vec!["ls".to_string()]];
assert!(matches!(
policy.check_multiple(command.iter()),
Evaluation::NoMatch
Evaluation::NoMatch { .. }
));
}

View File

@@ -31,9 +31,6 @@ pub enum Feature {
GhostCommit,
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Use the shell command tool that takes `command` as a single string of
/// shell instead of an array of args passed to `execvp(3)`.
ShellCommandTool,
/// Enable experimental RMCP features such as OAuth login.
RmcpClient,
/// Include the freeform apply_patch tool.
@@ -275,12 +272,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ShellCommandTool,
key: "shell_command_tool",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::RmcpClient,
key: "rmcp_client",

View File

@@ -39,6 +39,7 @@ pub mod parse_command;
pub mod powershell;
mod response_processing;
pub mod sandboxing;
mod text_encoding;
pub mod token_data;
mod truncate;
mod unified_exec;

View File

@@ -76,6 +76,7 @@ macro_rules! model_family {
(
$slug:expr, $family:expr $(, $key:ident : $value:expr )* $(,)?
) => {{
let truncation_policy = TruncationPolicy::Bytes(10_000);
// defaults
#[allow(unused_mut)]
let mut mf = ModelFamily {
@@ -90,10 +91,10 @@ macro_rules! model_family {
experimental_supported_tools: Vec::new(),
effective_context_window_percent: 95,
support_verbosity: false,
shell_type: ConfigShellToolType::Default,
shell_type: ConfigShellToolType::Default(truncation_policy),
default_verbosity: None,
default_reasoning_effort: None,
truncation_policy: TruncationPolicy::Bytes(10_000),
truncation_policy,
};
// apply overrides
@@ -138,6 +139,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
} 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("test-gpt-5") {
let truncation_policy = TruncationPolicy::Tokens(10_000);
model_family!(
slug, slug,
supports_reasoning_summaries: true,
@@ -150,13 +152,13 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
"test_sync_tool".to_string(),
],
supports_parallel_tool_calls: true,
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::ShellCommand(truncation_policy),
support_verbosity: true,
truncation_policy: TruncationPolicy::Tokens(10_000),
)
// Internal models.
} else if slug.starts_with("codex-exp-") {
let truncation_policy = TruncationPolicy::Tokens(10_000);
model_family!(
slug, slug,
supports_reasoning_summaries: true,
@@ -168,41 +170,44 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
"list_dir".to_string(),
"read_file".to_string(),
],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::ShellCommand(truncation_policy),
supports_parallel_tool_calls: true,
support_verbosity: true,
truncation_policy: TruncationPolicy::Tokens(10_000),
truncation_policy: truncation_policy,
)
// Production models.
} else if slug.starts_with("gpt-5.1-codex-max") {
let truncation_policy = TruncationPolicy::Tokens(10_000);
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
base_instructions: GPT_5_1_CODEX_MAX_INSTRUCTIONS.to_string(),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::ShellCommand(truncation_policy),
supports_parallel_tool_calls: true,
support_verbosity: false,
truncation_policy: TruncationPolicy::Tokens(10_000),
truncation_policy: truncation_policy,
)
} else if slug.starts_with("gpt-5-codex")
|| slug.starts_with("gpt-5.1-codex")
|| slug.starts_with("codex-")
{
let truncation_policy = TruncationPolicy::Tokens(10_000);
model_family!(
slug, slug,
supports_reasoning_summaries: true,
reasoning_summary_format: ReasoningSummaryFormat::Experimental,
base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::ShellCommand(truncation_policy),
supports_parallel_tool_calls: true,
support_verbosity: false,
truncation_policy: TruncationPolicy::Tokens(10_000),
truncation_policy: truncation_policy,
)
} else if slug.starts_with("gpt-5.1") {
let truncation_policy = TruncationPolicy::Tokens(10_000);
model_family!(
slug, "gpt-5.1",
supports_reasoning_summaries: true,
@@ -212,7 +217,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
base_instructions: GPT_5_1_INSTRUCTIONS.to_string(),
default_reasoning_effort: Some(ReasoningEffort::Medium),
truncation_policy: TruncationPolicy::Bytes(10_000),
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::ShellCommand(truncation_policy),
supports_parallel_tool_calls: true,
)
} else if slug.starts_with("gpt-5") {
@@ -220,7 +225,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
slug, "gpt-5",
supports_reasoning_summaries: true,
needs_special_apply_patch_instructions: true,
shell_type: ConfigShellToolType::Default,
shell_type: ConfigShellToolType::Default(TruncationPolicy::Bytes(10_000)),
support_verbosity: true,
truncation_policy: TruncationPolicy::Bytes(10_000),
)
@@ -230,6 +235,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
}
pub fn derive_default_model_family(model: &str) -> ModelFamily {
let truncation_policy = TruncationPolicy::Bytes(10_000);
ModelFamily {
slug: model.to_string(),
family: model.to_string(),
@@ -242,9 +248,9 @@ pub fn derive_default_model_family(model: &str) -> ModelFamily {
experimental_supported_tools: Vec::new(),
effective_context_window_percent: 95,
support_verbosity: false,
shell_type: ConfigShellToolType::Default,
shell_type: ConfigShellToolType::Default(truncation_policy),
default_verbosity: None,
default_reasoning_effort: None,
truncation_policy: TruncationPolicy::Bytes(10_000),
truncation_policy,
}
}

View File

@@ -8,6 +8,7 @@ readytospawn environment.
pub mod assessment;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
@@ -48,27 +49,31 @@ impl From<bool> for SandboxPermissions {
}
}
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct CommandSpec {
pub program: String,
pub args: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub timeout_ms: Option<u64>,
pub expiration: ExecExpiration,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub max_output_tokens: Option<usize>,
pub max_output_chars: Option<usize>,
}
#[derive(Clone, Debug)]
#[derive(Debug)]
pub struct ExecEnv {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub timeout_ms: Option<u64>,
pub expiration: ExecExpiration,
pub sandbox: SandboxType,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub arg0: Option<String>,
pub max_output_tokens: Option<usize>,
pub max_output_chars: Option<usize>,
}
pub enum SandboxPreference {
@@ -115,13 +120,13 @@ impl SandboxManager {
pub(crate) fn transform(
&self,
spec: &CommandSpec,
mut spec: CommandSpec,
policy: &SandboxPolicy,
sandbox: SandboxType,
sandbox_policy_cwd: &Path,
codex_linux_sandbox_exe: Option<&PathBuf>,
) -> Result<ExecEnv, SandboxTransformError> {
let mut env = spec.env.clone();
let mut env = spec.env;
if !policy.has_full_network_access() {
env.insert(
CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(),
@@ -130,8 +135,8 @@ impl SandboxManager {
}
let mut command = Vec::with_capacity(1 + spec.args.len());
command.push(spec.program.clone());
command.extend(spec.args.iter().cloned());
command.push(spec.program);
command.append(&mut spec.args);
let (command, sandbox_env, arg0_override) = match sandbox {
SandboxType::None => (command, HashMap::new(), None),
@@ -176,13 +181,15 @@ impl SandboxManager {
Ok(ExecEnv {
command,
cwd: spec.cwd.clone(),
cwd: spec.cwd,
env,
timeout_ms: spec.timeout_ms,
expiration: spec.expiration,
sandbox,
with_escalated_permissions: spec.with_escalated_permissions,
justification: spec.justification.clone(),
justification: spec.justification,
arg0: arg0_override,
max_output_tokens: spec.max_output_tokens,
max_output_chars: spec.max_output_chars,
})
}
@@ -192,9 +199,9 @@ impl SandboxManager {
}
pub async fn execute_env(
env: &ExecEnv,
env: ExecEnv,
policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
) -> crate::error::Result<ExecToolCallOutput> {
execute_exec_env(env.clone(), policy, stdout_stream).await
execute_exec_env(env, policy, stdout_stream).await
}

View File

@@ -7,61 +7,41 @@ pub enum ShellType {
Zsh,
Bash,
PowerShell,
Sh,
Cmd,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct ZshShell {
pub struct Shell {
pub(crate) shell_type: ShellType,
pub(crate) shell_path: PathBuf,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct BashShell {
pub(crate) shell_path: PathBuf,
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct PowerShellConfig {
pub(crate) shell_path: PathBuf, // Executable name or path, e.g. "pwsh" or "powershell.exe".
}
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub enum Shell {
Zsh(ZshShell),
Bash(BashShell),
PowerShell(PowerShellConfig),
Unknown,
}
impl Shell {
pub fn name(&self) -> Option<String> {
match self {
Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => {
std::path::Path::new(shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
}
Shell::PowerShell(ps) => ps
.shell_path
.file_stem()
.map(|s| s.to_string_lossy().to_string()),
Shell::Unknown => None,
pub fn name(&self) -> &'static str {
match self.shell_type {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
ShellType::PowerShell => "powershell",
ShellType::Sh => "sh",
ShellType::Cmd => "cmd",
}
}
/// Takes a string of shell and returns the full list of command args to
/// use with `exec()` to run the shell command.
pub fn derive_exec_args(&self, command: &str, use_login_shell: bool) -> Vec<String> {
match self {
Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => {
match self.shell_type {
ShellType::Zsh | ShellType::Bash | ShellType::Sh => {
let arg = if use_login_shell { "-lc" } else { "-c" };
vec![
shell_path.to_string_lossy().to_string(),
self.shell_path.to_string_lossy().to_string(),
arg.to_string(),
command.to_string(),
]
}
Shell::PowerShell(ps) => {
let mut args = vec![ps.shell_path.to_string_lossy().to_string()];
ShellType::PowerShell => {
let mut args = vec![self.shell_path.to_string_lossy().to_string()];
if !use_login_shell {
args.push("-NoProfile".to_string());
}
@@ -70,7 +50,12 @@ impl Shell {
args.push(command.to_string());
args
}
Shell::Unknown => shlex::split(command).unwrap_or_else(|| vec![command.to_string()]),
ShellType::Cmd => {
let mut args = vec![self.shell_path.to_string_lossy().to_string()];
args.push("/c".to_string());
args.push(command.to_string());
args
}
}
}
}
@@ -143,19 +128,34 @@ fn get_shell_path(
None
}
fn get_zsh_shell(path: Option<&PathBuf>) -> Option<ZshShell> {
fn get_zsh_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", vec!["/bin/zsh"]);
shell_path.map(|shell_path| ZshShell { shell_path })
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Zsh,
shell_path,
})
}
fn get_bash_shell(path: Option<&PathBuf>) -> Option<BashShell> {
fn get_bash_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Bash, path, "bash", vec!["/bin/bash"]);
shell_path.map(|shell_path| BashShell { shell_path })
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Bash,
shell_path,
})
}
fn get_powershell_shell(path: Option<&PathBuf>) -> Option<PowerShellConfig> {
fn get_sh_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Sh, path, "sh", vec!["/bin/sh"]);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Sh,
shell_path,
})
}
fn get_powershell_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(
ShellType::PowerShell,
path,
@@ -164,26 +164,56 @@ fn get_powershell_shell(path: Option<&PathBuf>) -> Option<PowerShellConfig> {
)
.or_else(|| get_shell_path(ShellType::PowerShell, path, "powershell", vec![]));
shell_path.map(|shell_path| PowerShellConfig { shell_path })
shell_path.map(|shell_path| Shell {
shell_type: ShellType::PowerShell,
shell_path,
})
}
fn get_cmd_shell(path: Option<&PathBuf>) -> Option<Shell> {
let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", vec![]);
shell_path.map(|shell_path| Shell {
shell_type: ShellType::Cmd,
shell_path,
})
}
fn ultimate_fallback_shell() -> Shell {
if cfg!(windows) {
Shell {
shell_type: ShellType::Cmd,
shell_path: PathBuf::from("cmd.exe"),
}
} else {
Shell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
}
}
}
pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell {
detect_shell_type(shell_path)
.and_then(|shell_type| get_shell(shell_type, Some(shell_path)))
.unwrap_or(Shell::Unknown)
.unwrap_or(ultimate_fallback_shell())
}
pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option<Shell> {
match shell_type {
ShellType::Zsh => get_zsh_shell(path).map(Shell::Zsh),
ShellType::Bash => get_bash_shell(path).map(Shell::Bash),
ShellType::PowerShell => get_powershell_shell(path).map(Shell::PowerShell),
ShellType::Zsh => get_zsh_shell(path),
ShellType::Bash => get_bash_shell(path),
ShellType::PowerShell => get_powershell_shell(path),
ShellType::Sh => get_sh_shell(path),
ShellType::Cmd => get_cmd_shell(path),
}
}
pub fn detect_shell_type(shell_path: &PathBuf) -> Option<ShellType> {
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("sh") => Some(ShellType::Sh),
Some("cmd") => Some(ShellType::Cmd),
Some("bash") => Some(ShellType::Bash),
Some("pwsh") => Some(ShellType::PowerShell),
Some("powershell") => Some(ShellType::PowerShell),
@@ -200,11 +230,15 @@ pub fn detect_shell_type(shell_path: &PathBuf) -> Option<ShellType> {
}
}
pub async fn default_user_shell() -> Shell {
pub fn default_user_shell() -> Shell {
default_user_shell_from_path(get_user_shell_path())
}
fn default_user_shell_from_path(user_shell_path: Option<PathBuf>) -> Shell {
if cfg!(windows) {
get_shell(ShellType::PowerShell, None).unwrap_or(Shell::Unknown)
get_shell(ShellType::PowerShell, None).unwrap_or(ultimate_fallback_shell())
} else {
let user_default_shell = get_user_shell_path()
let user_default_shell = user_shell_path
.and_then(|shell| detect_shell_type(&shell))
.and_then(|shell_type| get_shell(shell_type, None));
@@ -218,7 +252,7 @@ pub async fn default_user_shell() -> Shell {
.or_else(|| get_shell(ShellType::Zsh, None))
};
shell_with_fallback.unwrap_or(Shell::Unknown)
shell_with_fallback.unwrap_or(ultimate_fallback_shell())
}
}
@@ -274,6 +308,19 @@ mod detect_shell_type_tests {
detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")),
Some(ShellType::PowerShell)
);
assert_eq!(
detect_shell_type(&PathBuf::from("/bin/sh")),
Some(ShellType::Sh)
);
assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh));
assert_eq!(
detect_shell_type(&PathBuf::from("cmd")),
Some(ShellType::Cmd)
);
assert_eq!(
detect_shell_type(&PathBuf::from("cmd.exe")),
Some(ShellType::Cmd)
);
}
}
@@ -289,10 +336,17 @@ mod tests {
fn detects_zsh() {
let zsh_shell = get_shell(ShellType::Zsh, None).unwrap();
let ZshShell { shell_path } = match zsh_shell {
Shell::Zsh(zsh_shell) => zsh_shell,
_ => panic!("expected zsh shell"),
};
let shell_path = zsh_shell.shell_path;
assert_eq!(shell_path, PathBuf::from("/bin/zsh"));
}
#[test]
#[cfg(target_os = "macos")]
fn fish_fallback_to_zsh() {
let zsh_shell = default_user_shell_from_path(Some(PathBuf::from("/bin/fish")));
let shell_path = zsh_shell.shell_path;
assert_eq!(shell_path, PathBuf::from("/bin/zsh"));
}
@@ -300,18 +354,60 @@ mod tests {
#[test]
fn detects_bash() {
let bash_shell = get_shell(ShellType::Bash, None).unwrap();
let BashShell { shell_path } = match bash_shell {
Shell::Bash(bash_shell) => bash_shell,
_ => panic!("expected bash shell"),
};
let shell_path = bash_shell.shell_path;
assert!(
shell_path == PathBuf::from("/bin/bash")
|| shell_path == PathBuf::from("/usr/bin/bash"),
|| shell_path == PathBuf::from("/usr/bin/bash")
|| shell_path == PathBuf::from("/usr/local/bin/bash"),
"shell path: {shell_path:?}",
);
}
#[test]
fn detects_sh() {
let sh_shell = get_shell(ShellType::Sh, None).unwrap();
let shell_path = sh_shell.shell_path;
assert!(
shell_path == PathBuf::from("/bin/sh") || shell_path == PathBuf::from("/usr/bin/sh"),
"shell path: {shell_path:?}",
);
}
#[test]
fn can_run_on_shell_test() {
let cmd = "echo \"Works\"";
if cfg!(windows) {
assert!(shell_works(
get_shell(ShellType::PowerShell, None),
"Out-String 'Works'",
true,
));
assert!(shell_works(get_shell(ShellType::Cmd, None), cmd, true,));
assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true));
} else {
assert!(shell_works(Some(ultimate_fallback_shell()), cmd, true));
assert!(shell_works(get_shell(ShellType::Zsh, None), cmd, false));
assert!(shell_works(get_shell(ShellType::Bash, None), cmd, true));
assert!(shell_works(get_shell(ShellType::Sh, None), cmd, true));
}
}
fn shell_works(shell: Option<Shell>, command: &str, required: bool) -> bool {
if let Some(shell) = shell {
let args = shell.derive_exec_args(command, false);
let output = Command::new(args[0].clone())
.args(&args[1..])
.output()
.unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("Works"));
true
} else {
!required
}
}
#[tokio::test]
async fn test_current_shell_detects_zsh() {
let shell = Command::new("sh")
@@ -323,10 +419,11 @@ mod tests {
let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
if shell_path.ends_with("/zsh") {
assert_eq!(
default_user_shell().await,
Shell::Zsh(ZshShell {
default_user_shell(),
Shell {
shell_type: ShellType::Zsh,
shell_path: PathBuf::from(shell_path),
})
}
);
}
}
@@ -337,11 +434,8 @@ mod tests {
return;
}
let powershell_shell = default_user_shell().await;
let PowerShellConfig { shell_path } = match powershell_shell {
Shell::PowerShell(powershell_shell) => powershell_shell,
_ => panic!("expected powershell shell"),
};
let powershell_shell = default_user_shell();
let shell_path = powershell_shell.shell_path;
assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe"));
}
@@ -353,10 +447,7 @@ mod tests {
}
let powershell_shell = get_shell(ShellType::PowerShell, None).unwrap();
let PowerShellConfig { shell_path } = match powershell_shell {
Shell::PowerShell(powershell_shell) => powershell_shell,
_ => panic!("expected powershell shell"),
};
let shell_path = powershell_shell.shell_path;
assert!(shell_path.ends_with("pwsh.exe") || shell_path.ends_with("powershell.exe"));
}

View File

@@ -31,6 +31,8 @@ use crate::user_shell_command::user_shell_command_record_item;
use super::SessionTask;
use super::SessionTaskContext;
const USER_SHELL_TIMEOUT_MS: u64 = 60 * 60 * 1000; // 1 hour
#[derive(Clone)]
pub(crate) struct UserShellCommandTask {
command: String,
@@ -93,11 +95,15 @@ impl SessionTask for UserShellCommandTask {
command: command.clone(),
cwd: cwd.clone(),
env: create_env(&turn_context.shell_environment_policy),
timeout_ms: None,
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),
sandbox: SandboxType::None,
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let stdout_stream = Some(StdoutStream {

View File

@@ -0,0 +1,461 @@
//! Text encoding detection and conversion utilities for shell output.
//!
//! Windows users frequently run into code pages such as CP1251 or CP866 when invoking commands
//! through VS Code. Those bytes show up as invalid UTF-8 and used to be replaced with the standard
//! Unicode replacement character. We now lean on `chardetng` and `encoding_rs` so we can
//! automatically detect and decode the vast majority of legacy encodings before falling back to
//! lossy UTF-8 decoding.
use chardetng::EncodingDetector;
use encoding_rs::Encoding;
use encoding_rs::IBM866;
use encoding_rs::WINDOWS_1252;
/// Attempts to convert arbitrary bytes to UTF-8 with best-effort encoding detection.
pub fn bytes_to_string_smart(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
if let Ok(utf8_str) = std::str::from_utf8(bytes) {
return utf8_str.to_owned();
}
let encoding = detect_encoding(bytes);
decode_bytes(bytes, encoding)
}
// Windows-1252 reassigns a handful of 0x80-0x9F slots to smart punctuation (curly quotes, dashes,
// ™). CP866 uses those *same byte values* for uppercase Cyrillic letters. When chardetng sees shell
// snippets that mix these bytes with ASCII it sometimes guesses IBM866, so “smart quotes” render as
// Cyrillic garbage (“УФЦ”) in VS Code. However, CP866 uppercase tokens are perfectly valid output
// (e.g., `ПРИ test`) so we cannot flip every 0x80-0x9F byte to Windows-1252 either. The compromise
// is to only coerce IBM866 to Windows-1252 when (a) the high bytes are exclusively the punctuation
// values listed below and (b) we spot adjacent ASCII. This targets the real failure case without
// clobbering legitimate Cyrillic text. If another code page has a similar collision, introduce a
// dedicated allowlist (like this one) plus unit tests that capture the actual shell output we want
// to preserve. Windows-1252 byte values for smart punctuation.
const WINDOWS_1252_PUNCT_BYTES: [u8; 8] = [
0x91, // (left single quotation mark)
0x92, // (right single quotation mark)
0x93, // “ (left double quotation mark)
0x94, // ” (right double quotation mark)
0x95, // • (bullet)
0x96, // (en dash)
0x97, // — (em dash)
0x99, // ™ (trade mark sign)
];
fn detect_encoding(bytes: &[u8]) -> &'static Encoding {
let mut detector = EncodingDetector::new();
detector.feed(bytes, true);
let (encoding, _is_confident) = detector.guess_assess(None, true);
// chardetng occasionally reports IBM866 for short strings that only contain Windows-1252 “smart
// punctuation” bytes (0x80-0x9F) because that range maps to Cyrillic letters in IBM866. When
// those bytes show up alongside an ASCII word (typical shell output: `"“`test), we know the
// intent was likely CP1252 quotes/dashes. Prefer WINDOWS_1252 in that specific situation so we
// render the characters users expect instead of Cyrillic junk. References:
// - Windows-1252 reserving 0x80-0x9F for curly quotes/dashes:
// https://en.wikipedia.org/wiki/Windows-1252
// - CP866 mapping 0x93/0x94/0x96 to Cyrillic letters, so the same bytes show up as “УФЦ” when
// mis-decoded: https://www.unicode.org/Public/MAPPINGS/VENDORS/MICSFT/PC/CP866.TXT
if encoding == IBM866 && looks_like_windows_1252_punctuation(bytes) {
return WINDOWS_1252;
}
encoding
}
fn decode_bytes(bytes: &[u8], encoding: &'static Encoding) -> String {
let (decoded, _, had_errors) = encoding.decode(bytes);
if had_errors {
return String::from_utf8_lossy(bytes).into_owned();
}
decoded.into_owned()
}
/// Detect whether the byte stream looks like Windows-1252 “smart punctuation” wrapped around
/// otherwise-ASCII text.
///
/// Context: IBM866 and Windows-1252 share the 0x80-0x9F slot range. In IBM866 these bytes decode to
/// Cyrillic letters, whereas Windows-1252 maps them to curly quotes and dashes. chardetng can guess
/// IBM866 for short snippets that only contain those bytes, which turns shell output such as
/// `“test”` into unreadable Cyrillic. To avoid that, we treat inputs comprising a handful of bytes
/// from the problematic range plus ASCII letters as CP1252 punctuation. We deliberately do *not*
/// cap how many of those punctuation bytes we accept: VS Code frequently prints several quoted
/// phrases (e.g., `"foo" "bar"`), and truncating the count would once again mis-decode those as
/// Cyrillic. If we discover additional encodings with overlapping byte ranges, prefer adding
/// encoding-specific byte allowlists like `WINDOWS_1252_PUNCT` and tests that exercise real-world
/// shell snippets.
fn looks_like_windows_1252_punctuation(bytes: &[u8]) -> bool {
let mut saw_extended_punctuation = false;
let mut saw_ascii_word = false;
for &byte in bytes {
if byte >= 0xA0 {
return false;
}
if (0x80..=0x9F).contains(&byte) {
if !is_windows_1252_punct(byte) {
return false;
}
saw_extended_punctuation = true;
}
if byte.is_ascii_alphabetic() {
saw_ascii_word = true;
}
}
saw_extended_punctuation && saw_ascii_word
}
fn is_windows_1252_punct(byte: u8) -> bool {
WINDOWS_1252_PUNCT_BYTES.contains(&byte)
}
#[cfg(test)]
mod tests {
use super::*;
use encoding_rs::BIG5;
use encoding_rs::EUC_KR;
use encoding_rs::GBK;
use encoding_rs::ISO_8859_2;
use encoding_rs::ISO_8859_3;
use encoding_rs::ISO_8859_4;
use encoding_rs::ISO_8859_5;
use encoding_rs::ISO_8859_6;
use encoding_rs::ISO_8859_7;
use encoding_rs::ISO_8859_8;
use encoding_rs::ISO_8859_10;
use encoding_rs::ISO_8859_13;
use encoding_rs::SHIFT_JIS;
use encoding_rs::WINDOWS_874;
use encoding_rs::WINDOWS_1250;
use encoding_rs::WINDOWS_1251;
use encoding_rs::WINDOWS_1253;
use encoding_rs::WINDOWS_1254;
use encoding_rs::WINDOWS_1255;
use encoding_rs::WINDOWS_1256;
use encoding_rs::WINDOWS_1257;
use encoding_rs::WINDOWS_1258;
use pretty_assertions::assert_eq;
#[test]
fn test_utf8_passthrough() {
// Fast path: when UTF-8 is valid we should avoid copies and return as-is.
let utf8_text = "Hello, мир! 世界";
let bytes = utf8_text.as_bytes();
assert_eq!(bytes_to_string_smart(bytes), utf8_text);
}
#[test]
fn test_cp1251_russian_text() {
// Cyrillic text emitted by PowerShell/WSL in CP1251 should decode cleanly.
let bytes = b"\xEF\xF0\xE8\xEC\xE5\xF0"; // "пример" encoded with Windows-1251
assert_eq!(bytes_to_string_smart(bytes), "пример");
}
#[test]
fn test_cp1251_privet_word() {
// Regression: CP1251 words like "Привет" must not be mis-identified as Windows-1252.
let bytes = b"\xCF\xF0\xE8\xE2\xE5\xF2"; // "Привет" encoded with Windows-1251
assert_eq!(bytes_to_string_smart(bytes), "Привет");
}
#[test]
fn test_koi8_r_privet_word() {
// KOI8-R output should decode to the original Cyrillic as well.
let bytes = b"\xF0\xD2\xC9\xD7\xC5\xD4"; // "Привет" encoded with KOI8-R
assert_eq!(bytes_to_string_smart(bytes), "Привет");
}
#[test]
fn test_cp866_russian_text() {
// Legacy consoles (cmd.exe) commonly emit CP866 bytes for Cyrillic content.
let bytes = b"\xAF\xE0\xA8\xAC\xA5\xE0"; // "пример" encoded with CP866
assert_eq!(bytes_to_string_smart(bytes), "пример");
}
#[test]
fn test_cp866_uppercase_text() {
// Ensure the IBM866 heuristic still returns IBM866 for uppercase-only words.
let bytes = b"\x8F\x90\x88"; // "ПРИ" encoded with CP866 uppercase letters
assert_eq!(bytes_to_string_smart(bytes), "ПРИ");
}
#[test]
fn test_cp866_uppercase_followed_by_ascii() {
// Regression test: uppercase CP866 tokens next to ASCII text should not be treated as
// CP1252.
let bytes = b"\x8F\x90\x88 test"; // "ПРИ test" encoded with CP866 uppercase letters followed by ASCII
assert_eq!(bytes_to_string_smart(bytes), "ПРИ test");
}
#[test]
fn test_windows_1252_quotes() {
// Smart detection should map Windows-1252 punctuation into proper Unicode.
let bytes = b"\x93\x94test";
assert_eq!(bytes_to_string_smart(bytes), "\u{201C}\u{201D}test");
}
#[test]
fn test_windows_1252_multiple_quotes() {
// Longer snippets of punctuation (e.g., “foo” “bar”) should still flip to CP1252.
let bytes = b"\x93foo\x94 \x96 \x93bar\x94";
assert_eq!(
bytes_to_string_smart(bytes),
"\u{201C}foo\u{201D} \u{2013} \u{201C}bar\u{201D}"
);
}
#[test]
fn test_windows_1252_privet_gibberish_is_preserved() {
// Windows-1252 cannot encode Cyrillic; if the input literally contains "ПÑ..." we should not "fix" it.
let bytes = "Привет".as_bytes();
assert_eq!(bytes_to_string_smart(bytes), "Привет");
}
#[test]
fn test_iso8859_1_latin_text() {
// ISO-8859-1 (code page 28591) is the Latin segment used by LatArCyrHeb.
// encoding_rs unifies ISO-8859-1 with Windows-1252, so reuse that constant here.
let (encoded, _, had_errors) = WINDOWS_1252.encode("Hello");
assert!(!had_errors, "failed to encode Latin sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Hello");
}
#[test]
fn test_iso8859_2_central_european_text() {
// ISO-8859-2 (code page 28592) covers additional Central European glyphs.
let (encoded, _, had_errors) = ISO_8859_2.encode("Příliš žluťoučký kůň");
assert!(!had_errors, "failed to encode ISO-8859-2 sample");
assert_eq!(
bytes_to_string_smart(encoded.as_ref()),
"Příliš žluťoučký kůň"
);
}
#[test]
fn test_iso8859_3_south_europe_text() {
// ISO-8859-3 (code page 28593) adds support for Maltese/Esperanto letters.
// chardetng rarely distinguishes ISO-8859-3 from neighboring Latin code pages, so we rely on
// an ASCII-only sample to ensure round-tripping still succeeds.
let (encoded, _, had_errors) = ISO_8859_3.encode("Esperanto and Maltese");
assert!(!had_errors, "failed to encode ISO-8859-3 sample");
assert_eq!(
bytes_to_string_smart(encoded.as_ref()),
"Esperanto and Maltese"
);
}
#[test]
fn test_iso8859_4_baltic_text() {
// ISO-8859-4 (code page 28594) targets the Baltic/Nordic repertoire.
let sample = "Šis ir rakstzīmju kodēšanas tests. Dažās valodās, kurās tiek \
izmantotas latīņu valodas burti, lēmuma pieņemšanai mums ir nepieciešams \
vairāk ieguldījuma.";
let (encoded, _, had_errors) = ISO_8859_4.encode(sample);
assert!(!had_errors, "failed to encode ISO-8859-4 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample);
}
#[test]
fn test_iso8859_5_cyrillic_text() {
// ISO-8859-5 (code page 28595) covers the Cyrillic portion.
let (encoded, _, had_errors) = ISO_8859_5.encode("Привет");
assert!(!had_errors, "failed to encode Cyrillic sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Привет");
}
#[test]
fn test_iso8859_6_arabic_text() {
// ISO-8859-6 (code page 28596) covers the Arabic glyphs.
let (encoded, _, had_errors) = ISO_8859_6.encode("مرحبا");
assert!(!had_errors, "failed to encode Arabic sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "مرحبا");
}
#[test]
fn test_iso8859_7_greek_text() {
// ISO-8859-7 (code page 28597) is used for Greek locales.
let (encoded, _, had_errors) = ISO_8859_7.encode("Καλημέρα");
assert!(!had_errors, "failed to encode ISO-8859-7 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Καλημέρα");
}
#[test]
fn test_iso8859_8_hebrew_text() {
// ISO-8859-8 (code page 28598) covers the Hebrew glyphs.
let (encoded, _, had_errors) = ISO_8859_8.encode("שלום");
assert!(!had_errors, "failed to encode Hebrew sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "שלום");
}
#[test]
fn test_iso8859_9_turkish_text() {
// ISO-8859-9 (code page 28599) mirrors Latin-1 but inserts Turkish letters.
// encoding_rs exposes the equivalent Windows-1254 mapping.
let (encoded, _, had_errors) = WINDOWS_1254.encode("İstanbul");
assert!(!had_errors, "failed to encode ISO-8859-9 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "İstanbul");
}
#[test]
fn test_iso8859_10_nordic_text() {
// ISO-8859-10 (code page 28600) adds additional Nordic letters.
let sample = "Þetta er prófun fyrir Ægir og Øystein.";
let (encoded, _, had_errors) = ISO_8859_10.encode(sample);
assert!(!had_errors, "failed to encode ISO-8859-10 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample);
}
#[test]
fn test_iso8859_11_thai_text() {
// ISO-8859-11 (code page 28601) mirrors TIS-620 / Windows-874 for Thai.
let sample = "ภาษาไทยสำหรับการทดสอบ ISO-8859-11";
// encoding_rs exposes the equivalent Windows-874 encoding, so use that constant.
let (encoded, _, had_errors) = WINDOWS_874.encode(sample);
assert!(!had_errors, "failed to encode ISO-8859-11 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), sample);
}
// ISO-8859-12 was never standardized, and encodings 1416 cannot be distinguished reliably
// without the heuristics we removed (chardetng generally reports neighboring Latin pages), so
// we intentionally omit coverage for those slots until the detector can identify them.
#[test]
fn test_iso8859_13_baltic_text() {
// ISO-8859-13 (code page 28603) is common across Baltic languages.
let (encoded, _, had_errors) = ISO_8859_13.encode("Sveiki");
assert!(!had_errors, "failed to encode ISO-8859-13 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Sveiki");
}
#[test]
fn test_windows_1250_central_european_text() {
let (encoded, _, had_errors) = WINDOWS_1250.encode("Příliš žluťoučký kůň");
assert!(!had_errors, "failed to encode Central European sample");
assert_eq!(
bytes_to_string_smart(encoded.as_ref()),
"Příliš žluťoučký kůň"
);
}
#[test]
fn test_windows_1251_encoded_text() {
let (encoded, _, had_errors) = WINDOWS_1251.encode("Привет из Windows-1251");
assert!(!had_errors, "failed to encode Windows-1251 sample");
assert_eq!(
bytes_to_string_smart(encoded.as_ref()),
"Привет из Windows-1251"
);
}
#[test]
fn test_windows_1253_greek_text() {
let (encoded, _, had_errors) = WINDOWS_1253.encode("Γειά σου");
assert!(!had_errors, "failed to encode Greek sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Γειά σου");
}
#[test]
fn test_windows_1254_turkish_text() {
let (encoded, _, had_errors) = WINDOWS_1254.encode("İstanbul");
assert!(!had_errors, "failed to encode Turkish sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "İstanbul");
}
#[test]
fn test_windows_1255_hebrew_text() {
let (encoded, _, had_errors) = WINDOWS_1255.encode("שלום");
assert!(!had_errors, "failed to encode Windows-1255 Hebrew sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "שלום");
}
#[test]
fn test_windows_1256_arabic_text() {
let (encoded, _, had_errors) = WINDOWS_1256.encode("مرحبا");
assert!(!had_errors, "failed to encode Windows-1256 Arabic sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "مرحبا");
}
#[test]
fn test_windows_1257_baltic_text() {
let (encoded, _, had_errors) = WINDOWS_1257.encode("Pērkons");
assert!(!had_errors, "failed to encode Baltic sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Pērkons");
}
#[test]
fn test_windows_1258_vietnamese_text() {
let (encoded, _, had_errors) = WINDOWS_1258.encode("Xin chào");
assert!(!had_errors, "failed to encode Vietnamese sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "Xin chào");
}
#[test]
fn test_windows_874_thai_text() {
let (encoded, _, had_errors) = WINDOWS_874.encode("สวัสดีครับ นี่คือการทดสอบภาษาไทย");
assert!(!had_errors, "failed to encode Thai sample");
assert_eq!(
bytes_to_string_smart(encoded.as_ref()),
"สวัสดีครับ นี่คือการทดสอบภาษาไทย"
);
}
#[test]
fn test_windows_932_shift_jis_text() {
let (encoded, _, had_errors) = SHIFT_JIS.encode("こんにちは");
assert!(!had_errors, "failed to encode Shift-JIS sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "こんにちは");
}
#[test]
fn test_windows_936_gbk_text() {
let (encoded, _, had_errors) = GBK.encode("你好,世界,这是一个测试");
assert!(!had_errors, "failed to encode GBK sample");
assert_eq!(
bytes_to_string_smart(encoded.as_ref()),
"你好,世界,这是一个测试"
);
}
#[test]
fn test_windows_949_korean_text() {
let (encoded, _, had_errors) = EUC_KR.encode("안녕하세요");
assert!(!had_errors, "failed to encode Korean sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "안녕하세요");
}
#[test]
fn test_windows_950_big5_text() {
let (encoded, _, had_errors) = BIG5.encode("繁體");
assert!(!had_errors, "failed to encode Big5 sample");
assert_eq!(bytes_to_string_smart(encoded.as_ref()), "繁體");
}
#[test]
fn test_latin1_cafe() {
// Latin-1 bytes remain common in Western-European locales; decode them directly.
let bytes = b"caf\xE9"; // codespell:ignore caf
assert_eq!(bytes_to_string_smart(bytes), "café");
}
#[test]
fn test_preserves_ansi_sequences() {
// ANSI escape sequences should survive regardless of the detected encoding.
let bytes = b"\x1b[31mred\x1b[0m";
assert_eq!(bytes_to_string_smart(bytes), "\x1b[31mred\x1b[0m");
}
#[test]
fn test_fallback_to_lossy() {
// Completely invalid sequences fall back to the old lossy behavior.
let invalid_bytes = [0xFF, 0xFE, 0xFD];
let result = bytes_to_string_smart(&invalid_bytes);
assert_eq!(result, String::from_utf8_lossy(&invalid_bytes));
}
}

View File

@@ -15,6 +15,8 @@ use crate::protocol::PatchApplyEndEvent;
use crate::protocol::TurnDiffEvent;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::sandboxing::ToolError;
use crate::truncate::TruncationPolicy;
use crate::truncate::formatted_truncate_text;
use codex_protocol::parse_command::ParsedCommand;
use std::collections::HashMap;
use std::path::Path;
@@ -29,6 +31,7 @@ pub(crate) struct ToolEventCtx<'a> {
pub turn: &'a TurnContext,
pub call_id: &'a str,
pub turn_diff_tracker: Option<&'a SharedTurnDiffTracker>,
pub override_truncation_policy: Option<&'a TruncationPolicy>,
}
impl<'a> ToolEventCtx<'a> {
@@ -37,12 +40,14 @@ impl<'a> ToolEventCtx<'a> {
turn: &'a TurnContext,
call_id: &'a str,
turn_diff_tracker: Option<&'a SharedTurnDiffTracker>,
override_truncation_policy: Option<&'a TruncationPolicy>,
) -> Self {
Self {
session,
turn,
call_id,
turn_diff_tracker,
override_truncation_policy,
}
}
}
@@ -255,13 +260,13 @@ impl ToolEmitter {
fn format_exec_output_for_model(
&self,
output: &ExecToolCallOutput,
ctx: ToolEventCtx<'_>,
truncation_policy: &TruncationPolicy,
) -> String {
match self {
Self::Shell { freeform: true, .. } => {
super::format_exec_output_for_model_freeform(output, ctx.turn.truncation_policy)
super::format_exec_output_for_model_freeform(output, *truncation_policy)
}
_ => super::format_exec_output_for_model_structured(output, ctx.turn.truncation_policy),
_ => super::format_exec_output_for_model_structured(output, *truncation_policy),
}
}
@@ -270,9 +275,12 @@ impl ToolEmitter {
ctx: ToolEventCtx<'_>,
out: Result<ExecToolCallOutput, ToolError>,
) -> Result<String, FunctionCallError> {
let truncation_policy = ctx
.override_truncation_policy
.unwrap_or(&ctx.turn.truncation_policy);
let (event, result) = match out {
Ok(output) => {
let content = self.format_exec_output_for_model(&output, ctx);
let content = self.format_exec_output_for_model(&output, truncation_policy);
let exit_code = output.exit_code;
let event = ToolEventStage::Success(output);
let result = if exit_code == 0 {
@@ -284,24 +292,26 @@ impl ToolEmitter {
}
Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { output })))
| Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output }))) => {
let response = self.format_exec_output_for_model(&output, ctx);
let response = self.format_exec_output_for_model(&output, truncation_policy);
let event = ToolEventStage::Failure(ToolEventFailure::Output(*output));
let result = Err(FunctionCallError::RespondToModel(response));
(event, result)
}
Err(ToolError::Codex(err)) => {
let message = format!("execution error: {err:?}");
let event = ToolEventStage::Failure(ToolEventFailure::Message(message.clone()));
let result = Err(FunctionCallError::RespondToModel(message));
let formatted_error = formatted_truncate_text(&err.to_string(), *truncation_policy);
let message = format!("execution error: {formatted_error}");
let event = ToolEventStage::Failure(ToolEventFailure::Message(message));
let result = Err(FunctionCallError::RespondToModel(formatted_error));
(event, result)
}
Err(ToolError::Rejected(msg)) => {
let formatted_msg = formatted_truncate_text(&msg, *truncation_policy);
// Normalize common rejection messages for exec tools so tests and
// users see a clear, consistent phrase.
let normalized = if msg == "rejected by user" {
let normalized = if formatted_msg == "rejected by user" {
"exec command rejected by user".to_string()
} else {
msg
formatted_msg
};
let event = ToolEventStage::Failure(ToolEventFailure::Message(normalized.clone()));
let result = Err(FunctionCallError::RespondToModel(normalized));

View File

@@ -100,6 +100,7 @@ impl ToolHandler for ApplyPatchHandler {
turn.as_ref(),
&call_id,
Some(&tracker),
None,
);
emitter.begin(event_ctx).await;
@@ -127,6 +128,7 @@ impl ToolHandler for ApplyPatchHandler {
turn.as_ref(),
&call_id,
Some(&tracker),
None,
);
let content = emitter.finish(event_ctx, out).await?;
Ok(ToolOutput::Function {

View File

@@ -27,6 +27,7 @@ use crate::tools::runtimes::apply_patch::ApplyPatchRuntime;
use crate::tools::runtimes::shell::ShellRequest;
use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::sandboxing::ToolCtx;
use crate::truncate::TruncationPolicy;
pub struct ShellHandler;
@@ -37,11 +38,13 @@ impl ShellHandler {
ExecParams {
command: params.command,
cwd: turn_context.resolve_path(params.workdir.clone()),
timeout_ms: params.timeout_ms,
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification,
arg0: None,
max_output_tokens: params.max_output_tokens,
max_output_chars: params.max_output_chars,
}
}
}
@@ -59,11 +62,13 @@ impl ShellCommandHandler {
ExecParams {
command,
cwd: turn_context.resolve_path(params.workdir.clone()),
timeout_ms: params.timeout_ms,
expiration: params.timeout_ms.into(),
env: create_env(&turn_context.shell_environment_policy),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification,
arg0: None,
max_output_tokens: params.max_output_tokens,
max_output_chars: params.max_output_chars,
}
}
}
@@ -209,6 +214,9 @@ impl ShellHandler {
)));
}
let override_truncation_policy =
create_truncation_policy(exec_params.max_output_tokens, exec_params.max_output_chars);
// Intercept apply_patch if present.
match codex_apply_patch::maybe_parse_apply_patch_verified(
&exec_params.command,
@@ -237,13 +245,14 @@ impl ShellHandler {
turn.as_ref(),
&call_id,
Some(&tracker),
override_truncation_policy.as_ref(),
);
emitter.begin(event_ctx).await;
let req = ApplyPatchRequest {
patch: apply.action.patch.clone(),
cwd: apply.action.cwd.clone(),
timeout_ms: exec_params.timeout_ms,
timeout_ms: exec_params.expiration.timeout_ms(),
user_explicitly_approved: apply.user_explicitly_approved_this_action,
codex_exe: turn.codex_linux_sandbox_exe.clone(),
};
@@ -263,6 +272,7 @@ impl ShellHandler {
turn.as_ref(),
&call_id,
Some(&tracker),
override_truncation_policy.as_ref(),
);
let content = emitter.finish(event_ctx, out).await?;
return Ok(ToolOutput::Function {
@@ -294,19 +304,26 @@ impl ShellHandler {
source,
freeform,
);
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
None,
override_truncation_policy.as_ref(),
);
emitter.begin(event_ctx).await;
let exec_policy = session.current_exec_policy().await;
let req = ShellRequest {
command: exec_params.command.clone(),
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.timeout_ms,
timeout_ms: exec_params.expiration.timeout_ms(),
env: exec_params.env.clone(),
with_escalated_permissions: exec_params.with_escalated_permissions,
justification: exec_params.justification.clone(),
max_output_tokens: exec_params.max_output_tokens,
max_output_chars: exec_params.max_output_chars,
approval_requirement: create_approval_requirement_for_command(
exec_policy.as_ref(),
&turn.exec_policy,
&exec_params.command,
turn.approval_policy,
&turn.sandbox_policy,
@@ -324,7 +341,13 @@ impl ShellHandler {
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy)
.await;
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
None,
override_truncation_policy.as_ref(),
);
let content = emitter.finish(event_ctx, out).await?;
Ok(ToolOutput::Function {
content,
@@ -334,34 +357,45 @@ impl ShellHandler {
}
}
fn create_truncation_policy(
max_output_tokens: Option<usize>,
max_output_chars: Option<usize>,
) -> Option<TruncationPolicy> {
if let Some(max_output_tokens) = max_output_tokens {
Some(TruncationPolicy::Tokens(max_output_tokens))
} else {
max_output_chars.map(TruncationPolicy::Bytes)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::is_safe_command::is_known_safe_command;
use crate::shell::BashShell;
use crate::shell::PowerShellConfig;
use crate::shell::Shell;
use crate::shell::ZshShell;
use crate::shell::ShellType;
/// The logic for is_known_safe_command() has heuristics for known shells,
/// so we must ensure the commands generated by [ShellCommandHandler] can be
/// recognized as safe if the `command` is safe.
#[test]
fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() {
let bash_shell = Shell::Bash(BashShell {
let bash_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
});
};
assert_safe(&bash_shell, "ls -la");
let zsh_shell = Shell::Zsh(ZshShell {
let zsh_shell = Shell {
shell_type: ShellType::Zsh,
shell_path: PathBuf::from("/bin/zsh"),
});
};
assert_safe(&zsh_shell, "ls -la");
let powershell = Shell::PowerShell(PowerShellConfig {
let powershell = Shell {
shell_type: ShellType::PowerShell,
shell_path: PathBuf::from("pwsh.exe"),
});
};
assert_safe(&powershell, "ls -Name");
}

View File

@@ -162,6 +162,7 @@ impl ToolHandler for UnifiedExecHandler {
context.turn.as_ref(),
&context.call_id,
None,
None,
);
let emitter = ToolEmitter::unified_exec(
&command,

View File

@@ -63,7 +63,7 @@ impl ToolOrchestrator {
ApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
}
ApprovalRequirement::NeedsApproval { reason, .. } => {
ApprovalRequirement::NeedsApproval { reason } => {
let mut risk = None;
if let Some(metadata) = req.sandbox_retry_data() {

View File

@@ -116,6 +116,8 @@ impl ToolRouter {
timeout_ms: exec.timeout_ms,
with_escalated_permissions: None,
justification: None,
max_output_tokens: None,
max_output_chars: None,
};
Ok(Some(ToolCall {
tool_name: "local_shell".to_string(),

View File

@@ -67,11 +67,13 @@ impl ApplyPatchRuntime {
program,
args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), req.patch.clone()],
cwd: req.cwd.clone(),
timeout_ms: req.timeout_ms,
expiration: req.timeout_ms.into(),
// Run apply_patch with a minimal environment for determinism and to avoid leaks.
env: HashMap::new(),
with_escalated_permissions: None,
justification: None,
max_output_tokens: None,
max_output_chars: None,
})
}
@@ -127,7 +129,6 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
cwd,
Some(reason),
risk,
None,
)
.await
} else if user_explicitly_approved {
@@ -154,9 +155,9 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
) -> Result<ExecToolCallOutput, ToolError> {
let spec = Self::build_command_spec(req)?;
let env = attempt
.env_for(&spec)
.env_for(spec)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx))
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
Ok(out)

View File

@@ -4,6 +4,7 @@ Module: runtimes
Concrete ToolRuntime implementations for specific tools. Each runtime stays
small and focused and reuses the orchestrator for approvals + sandbox + retry.
*/
use crate::exec::ExecExpiration;
use crate::sandboxing::CommandSpec;
use crate::tools::sandboxing::ToolError;
use std::collections::HashMap;
@@ -15,13 +16,16 @@ pub mod unified_exec;
/// Shared helper to construct a CommandSpec from a tokenized command line.
/// Validates that at least a program is present.
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_command_spec(
command: &[String],
cwd: &Path,
env: &HashMap<String, String>,
timeout_ms: Option<u64>,
expiration: ExecExpiration,
with_escalated_permissions: Option<bool>,
justification: Option<String>,
max_output_tokens: Option<usize>,
max_output_chars: Option<usize>,
) -> Result<CommandSpec, ToolError> {
let (program, args) = command
.split_first()
@@ -31,8 +35,10 @@ pub(crate) fn build_command_spec(
args: args.to_vec(),
cwd: cwd.to_path_buf(),
env: env.clone(),
timeout_ms,
expiration,
with_escalated_permissions,
justification,
max_output_tokens,
max_output_chars,
})
}

View File

@@ -31,6 +31,8 @@ pub struct ShellRequest {
pub env: std::collections::HashMap<String, String>,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub max_output_tokens: Option<usize>,
pub max_output_chars: Option<usize>,
pub approval_requirement: ApprovalRequirement,
}
@@ -106,15 +108,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
Box::pin(async move {
with_cached_approval(&session.services, key, move || async move {
session
.request_command_approval(
turn,
call_id,
command,
cwd,
reason,
risk,
req.approval_requirement.allow_prefix().cloned(),
)
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.await
})
.await
@@ -141,14 +135,16 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
&req.command,
&req.cwd,
&req.env,
req.timeout_ms,
req.timeout_ms.into(),
req.with_escalated_permissions,
req.justification.clone(),
req.max_output_tokens,
req.max_output_chars,
)?;
let env = attempt
.env_for(&spec)
.env_for(spec)
.map_err(|err| ToolError::Codex(err.into()))?;
let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx))
let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?;
Ok(out)

View File

@@ -6,6 +6,7 @@ the session manager to spawn PTYs once an ExecEnv is prepared.
*/
use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::exec::ExecExpiration;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
@@ -34,6 +35,8 @@ pub struct UnifiedExecRequest {
pub env: HashMap<String, String>,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub max_output_tokens: Option<usize>,
pub max_output_chars: Option<usize>,
pub approval_requirement: ApprovalRequirement,
}
@@ -72,6 +75,8 @@ impl UnifiedExecRequest {
env,
with_escalated_permissions,
justification,
max_output_tokens: None,
max_output_chars: None,
approval_requirement,
}
}
@@ -123,15 +128,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
Box::pin(async move {
with_cached_approval(&session.services, key, || async move {
session
.request_command_approval(
turn,
call_id,
command,
cwd,
reason,
risk,
req.approval_requirement.allow_prefix().cloned(),
)
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.await
})
.await
@@ -158,13 +155,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRunt
&req.command,
&req.cwd,
&req.env,
None,
ExecExpiration::DefaultTimeout,
req.with_escalated_permissions,
req.justification.clone(),
req.max_output_tokens,
req.max_output_chars,
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(&spec)
.env_for(spec)
.map_err(|err| ToolError::Codex(err.into()))?;
self.manager
.open_session_with_exec_env(&exec_env)

View File

@@ -92,26 +92,11 @@ pub(crate) enum ApprovalRequirement {
/// No approval required for this tool call
Skip,
/// Approval required for this tool call
NeedsApproval {
reason: Option<String>,
allow_prefix: Option<Vec<String>>,
},
NeedsApproval { reason: Option<String> },
/// Execution forbidden for this tool call
Forbidden { reason: String },
}
impl ApprovalRequirement {
pub fn allow_prefix(&self) -> Option<&Vec<String>> {
match self {
Self::NeedsApproval {
allow_prefix: Some(prefix),
..
} => Some(prefix),
_ => None,
}
}
}
/// - Never, OnFailure: do not ask
/// - OnRequest: ask unless sandbox policy is DangerFullAccess
/// - UnlessTrusted: always ask
@@ -126,10 +111,7 @@ pub(crate) fn default_approval_requirement(
};
if needs_approval {
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: None,
}
ApprovalRequirement::NeedsApproval { reason: None }
} else {
ApprovalRequirement::Skip
}
@@ -234,7 +216,7 @@ pub(crate) struct SandboxAttempt<'a> {
impl<'a> SandboxAttempt<'a> {
pub fn env_for(
&self,
spec: &CommandSpec,
spec: CommandSpec,
) -> Result<crate::sandboxing::ExecEnv, SandboxTransformError> {
self.manager.transform(
spec,

View File

@@ -8,6 +8,7 @@ use crate::tools::handlers::apply_patch::ApplyPatchToolType;
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
use crate::tools::registry::ToolRegistryBuilder;
use crate::truncate::TruncationPolicy;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -17,7 +18,7 @@ use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ConfigShellToolType {
Default,
Default(TruncationPolicy),
Local,
UnifiedExec,
/// Do not include a shell tool by default. Useful when using Codex
@@ -26,7 +27,7 @@ pub enum ConfigShellToolType {
/// to customize agent behavior.
Disabled,
/// Takes a command as a single string to be run in the user's default shell.
ShellCommand,
ShellCommand(TruncationPolicy),
}
#[derive(Debug, Clone)]
@@ -57,8 +58,6 @@ impl ToolsConfig {
ConfigShellToolType::Disabled
} else if features.enabled(Feature::UnifiedExec) {
ConfigShellToolType::UnifiedExec
} else if features.enabled(Feature::ShellCommandTool) {
ConfigShellToolType::ShellCommand
} else {
model_family.shell_type.clone()
};
@@ -266,7 +265,7 @@ fn create_write_stdin_tool() -> ToolSpec {
})
}
fn create_shell_tool() -> ToolSpec {
fn create_shell_tool(truncation_policy: TruncationPolicy) -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"command".to_string(),
@@ -300,6 +299,24 @@ fn create_shell_tool() -> ToolSpec {
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
},
);
match truncation_policy {
TruncationPolicy::Tokens(_) => {
properties.insert(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some("Maximum number of tokens to return from stdout/stderr. Excess tokens will be truncated".to_string()),
},
);
}
TruncationPolicy::Bytes(_) => {
properties.insert(
"max_output_chars".to_string(),
JsonSchema::Number {
description: Some("Maximum number of characters to return from stdout/stderr. Excess characters will be truncated".to_string()),
},
);
}
}
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
@@ -330,7 +347,7 @@ Examples of valid command strings:
})
}
fn create_shell_command_tool() -> ToolSpec {
fn create_shell_command_tool(truncation_policy: TruncationPolicy) -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"command".to_string(),
@@ -364,6 +381,30 @@ fn create_shell_command_tool() -> ToolSpec {
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
},
);
match truncation_policy {
TruncationPolicy::Tokens(_) => {
properties.insert(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
);
}
TruncationPolicy::Bytes(_) => {
properties.insert(
"max_output_chars".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
);
}
}
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output.
@@ -1001,8 +1042,8 @@ pub(crate) fn build_specs(
let shell_command_handler = Arc::new(ShellCommandHandler);
match &config.shell_type {
ConfigShellToolType::Default => {
builder.push_spec(create_shell_tool());
ConfigShellToolType::Default(truncation_policy) => {
builder.push_spec(create_shell_tool(*truncation_policy));
}
ConfigShellToolType::Local => {
builder.push_spec(ToolSpec::LocalShell {});
@@ -1016,8 +1057,8 @@ pub(crate) fn build_specs(
ConfigShellToolType::Disabled => {
// Do nothing.
}
ConfigShellToolType::ShellCommand => {
builder.push_spec(create_shell_command_tool());
ConfigShellToolType::ShellCommand(truncation_policy) => {
builder.push_spec(create_shell_command_tool(*truncation_policy));
}
}
@@ -1160,11 +1201,11 @@ mod tests {
fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
match config.shell_type {
ConfigShellToolType::Default => Some("shell"),
ConfigShellToolType::Default(_) => Some("shell"),
ConfigShellToolType::Local => Some("local_shell"),
ConfigShellToolType::UnifiedExec => None,
ConfigShellToolType::Disabled => None,
ConfigShellToolType::ShellCommand => Some("shell_command"),
ConfigShellToolType::ShellCommand(_) => Some("shell_command"),
}
}
@@ -1468,22 +1509,6 @@ mod tests {
assert_contains_tool_names(&tools, &subset);
}
#[test]
fn test_build_specs_shell_command_present() {
assert_model_tools(
"codex-mini-latest",
Features::with_defaults().enable(Feature::ShellCommandTool),
&[
"shell_command",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"view_image",
],
);
}
#[test]
#[ignore]
fn test_parallel_support_flags() {
@@ -1926,7 +1951,7 @@ mod tests {
#[test]
fn test_shell_tool() {
let tool = super::create_shell_tool();
let tool = super::create_shell_tool(TruncationPolicy::Bytes(10_000));
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
@@ -1956,7 +1981,7 @@ Examples of valid command strings:
#[test]
fn test_shell_command_tool() {
let tool = super::create_shell_command_tool();
let tool = super::create_shell_command_tool(TruncationPolicy::Tokens(10_000));
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool

View File

@@ -2,13 +2,13 @@
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::sync::oneshot::error::TryRecvError;
use tokio::task::JoinHandle;
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
@@ -67,13 +67,18 @@ impl OutputBufferState {
}
pub(crate) type OutputBuffer = Arc<Mutex<OutputBufferState>>;
pub(crate) type OutputHandles = (OutputBuffer, Arc<Notify>);
pub(crate) struct OutputHandles {
pub(crate) output_buffer: OutputBuffer,
pub(crate) output_notify: Arc<Notify>,
pub(crate) cancellation_token: CancellationToken,
}
#[derive(Debug)]
pub(crate) struct UnifiedExecSession {
session: ExecCommandSession,
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
cancellation_token: CancellationToken,
output_task: JoinHandle<()>,
sandbox_type: SandboxType,
}
@@ -86,9 +91,11 @@ impl UnifiedExecSession {
) -> Self {
let output_buffer = Arc::new(Mutex::new(OutputBufferState::default()));
let output_notify = Arc::new(Notify::new());
let cancellation_token = CancellationToken::new();
let mut receiver = initial_output_rx;
let buffer_clone = Arc::clone(&output_buffer);
let notify_clone = Arc::clone(&output_notify);
let cancellation_token_clone = cancellation_token.clone();
let output_task = tokio::spawn(async move {
loop {
match receiver.recv().await {
@@ -99,7 +106,10 @@ impl UnifiedExecSession {
notify_clone.notify_waiters();
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
cancellation_token_clone.cancel();
break;
}
}
}
});
@@ -108,6 +118,7 @@ impl UnifiedExecSession {
session,
output_buffer,
output_notify,
cancellation_token,
output_task,
sandbox_type,
}
@@ -118,10 +129,11 @@ impl UnifiedExecSession {
}
pub(super) fn output_handles(&self) -> OutputHandles {
(
Arc::clone(&self.output_buffer),
Arc::clone(&self.output_notify),
)
OutputHandles {
output_buffer: Arc::clone(&self.output_buffer),
output_notify: Arc::clone(&self.output_notify),
cancellation_token: self.cancellation_token.clone(),
}
}
pub(super) fn has_exited(&self) -> bool {
@@ -199,20 +211,34 @@ impl UnifiedExecSession {
};
if exit_ready {
managed.signal_exit();
managed.check_for_sandbox_denial().await?;
return Ok(managed);
}
tokio::pin!(exit_rx);
if tokio::time::timeout(Duration::from_millis(50), &mut exit_rx)
.await
.is_ok()
{
managed.signal_exit();
managed.check_for_sandbox_denial().await?;
return Ok(managed);
}
tokio::spawn({
let cancellation_token = managed.cancellation_token.clone();
async move {
let _ = exit_rx.await;
cancellation_token.cancel();
}
});
Ok(managed)
}
fn signal_exit(&self) {
self.cancellation_token.cancel();
}
}
impl Drop for UnifiedExecSession {

View File

@@ -5,6 +5,7 @@ use tokio::sync::Notify;
use tokio::sync::mpsc;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::codex::Session;
use crate::codex::TurnContext;
@@ -40,8 +41,20 @@ use super::clamp_yield_time;
use super::generate_chunk_id;
use super::resolve_max_tokens;
use super::session::OutputBuffer;
use super::session::OutputHandles;
use super::session::UnifiedExecSession;
struct PreparedSessionHandles {
writer_tx: mpsc::Sender<Vec<u8>>,
output_buffer: OutputBuffer,
output_notify: Arc<Notify>,
cancellation_token: CancellationToken,
session_ref: Arc<Session>,
turn_ref: Arc<TurnContext>,
command: Vec<String>,
cwd: PathBuf,
}
impl UnifiedExecSessionManager {
pub(crate) async fn exec_command(
&self,
@@ -67,10 +80,19 @@ impl UnifiedExecSessionManager {
let yield_time_ms = clamp_yield_time(request.yield_time_ms);
let start = Instant::now();
let (output_buffer, output_notify) = session.output_handles();
let OutputHandles {
output_buffer,
output_notify,
cancellation_token,
} = session.output_handles();
let deadline = start + Duration::from_millis(yield_time_ms);
let collected =
Self::collect_output_until_deadline(&output_buffer, &output_notify, deadline).await;
let collected = Self::collect_output_until_deadline(
&output_buffer,
&output_notify,
&cancellation_token,
deadline,
)
.await;
let wall_time = Instant::now().saturating_duration_since(start);
let text = String::from_utf8_lossy(&collected).to_string();
@@ -129,15 +151,16 @@ impl UnifiedExecSessionManager {
) -> Result<UnifiedExecResponse, UnifiedExecError> {
let session_id = request.session_id;
let (
let PreparedSessionHandles {
writer_tx,
output_buffer,
output_notify,
cancellation_token,
session_ref,
turn_ref,
session_command,
session_cwd,
) = self.prepare_session_handles(session_id).await?;
command: session_command,
cwd: session_cwd,
} = self.prepare_session_handles(session_id).await?;
let interaction_emitter = ToolEmitter::unified_exec(
&session_command,
@@ -151,6 +174,7 @@ impl UnifiedExecSessionManager {
turn_ref.as_ref(),
request.call_id,
None,
None,
)
};
interaction_emitter
@@ -176,8 +200,13 @@ impl UnifiedExecSessionManager {
let yield_time_ms = clamp_yield_time(request.yield_time_ms);
let start = Instant::now();
let deadline = start + Duration::from_millis(yield_time_ms);
let collected =
Self::collect_output_until_deadline(&output_buffer, &output_notify, deadline).await;
let collected = Self::collect_output_until_deadline(
&output_buffer,
&output_notify,
&cancellation_token,
deadline,
)
.await;
let wall_time = Instant::now().saturating_duration_since(start);
let text = String::from_utf8_lossy(&collected).to_string();
@@ -265,44 +294,27 @@ impl UnifiedExecSessionManager {
async fn prepare_session_handles(
&self,
session_id: i32,
) -> Result<
(
mpsc::Sender<Vec<u8>>,
OutputBuffer,
Arc<Notify>,
Arc<Session>,
Arc<TurnContext>,
Vec<String>,
PathBuf,
),
UnifiedExecError,
> {
) -> Result<PreparedSessionHandles, UnifiedExecError> {
let sessions = self.sessions.lock().await;
let (output_buffer, output_notify, writer_tx, session, turn, command, cwd) =
if let Some(entry) = sessions.get(&session_id) {
let (buffer, notify) = entry.session.output_handles();
(
buffer,
notify,
entry.session.writer_sender(),
Arc::clone(&entry.session_ref),
Arc::clone(&entry.turn_ref),
entry.command.clone(),
entry.cwd.clone(),
)
} else {
return Err(UnifiedExecError::UnknownSessionId { session_id });
};
Ok((
writer_tx,
let entry = sessions
.get(&session_id)
.ok_or(UnifiedExecError::UnknownSessionId { session_id })?;
let OutputHandles {
output_buffer,
output_notify,
session,
turn,
command,
cwd,
))
cancellation_token,
} = entry.session.output_handles();
Ok(PreparedSessionHandles {
writer_tx: entry.session.writer_sender(),
output_buffer,
output_notify,
cancellation_token,
session_ref: Arc::clone(&entry.session_ref),
turn_ref: Arc::clone(&entry.turn_ref),
command: entry.command.clone(),
cwd: entry.cwd.clone(),
})
}
async fn send_input(
@@ -358,6 +370,7 @@ impl UnifiedExecSessionManager {
entry.turn_ref.as_ref(),
&entry.call_id,
None,
None,
);
let emitter = ToolEmitter::unified_exec(
&entry.command,
@@ -391,6 +404,7 @@ impl UnifiedExecSessionManager {
context.turn.as_ref(),
&context.call_id,
None,
None,
);
let emitter =
ToolEmitter::unified_exec(command, cwd, ExecCommandSource::UnifiedExecStartup, None);
@@ -445,7 +459,6 @@ impl UnifiedExecSessionManager {
) -> Result<UnifiedExecSession, UnifiedExecError> {
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let exec_policy = context.session.current_exec_policy().await;
let req = UnifiedExecToolRequest::new(
command.to_vec(),
cwd,
@@ -453,7 +466,7 @@ impl UnifiedExecSessionManager {
with_escalated_permissions,
justification,
create_approval_requirement_for_command(
exec_policy.as_ref(),
&context.turn.exec_policy,
command,
context.turn.approval_policy,
&context.turn.sandbox_policy,
@@ -481,9 +494,13 @@ impl UnifiedExecSessionManager {
pub(super) async fn collect_output_until_deadline(
output_buffer: &OutputBuffer,
output_notify: &Arc<Notify>,
cancellation_token: &CancellationToken,
deadline: Instant,
) -> Vec<u8> {
const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25);
let mut collected: Vec<u8> = Vec::with_capacity(4096);
let mut exit_signal_received = cancellation_token.is_cancelled();
loop {
let drained_chunks;
let mut wait_for_output = None;
@@ -496,15 +513,27 @@ impl UnifiedExecSessionManager {
}
if drained_chunks.is_empty() {
exit_signal_received |= cancellation_token.is_cancelled();
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining == Duration::ZERO {
break;
}
let notified = wait_for_output.unwrap_or_else(|| output_notify.notified());
if exit_signal_received {
let grace = remaining.min(POST_EXIT_OUTPUT_GRACE);
if tokio::time::timeout(grace, notified).await.is_err() {
break;
}
continue;
}
tokio::pin!(notified);
let exit_notified = cancellation_token.cancelled();
tokio::pin!(exit_notified);
tokio::select! {
_ = &mut notified => {}
_ = &mut exit_notified => exit_signal_received = true,
_ = tokio::time::sleep(remaining) => break,
}
continue;
@@ -514,6 +543,7 @@ impl UnifiedExecSessionManager {
collected.extend_from_slice(&chunk);
}
exit_signal_received |= cancellation_token.is_cancelled();
if Instant::now() >= deadline {
break;
}

View File

@@ -1524,7 +1524,6 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
.submit(Op::ExecApproval {
id: "0".into(),
decision: *decision,
allow_prefix: None,
})
.await?;
wait_for_completion(&test).await;

View File

@@ -93,7 +93,6 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Approved,
allow_prefix: None,
})
.await
.expect("submit exec approval");

View File

@@ -384,7 +384,7 @@ async fn manual_compact_uses_custom_prompt() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn manual_compact_emits_estimated_token_usage_event() {
async fn manual_compact_emits_api_and_local_token_usage_events() {
skip_if_no_network!();
let server = start_mock_server().await;

View File

@@ -32,11 +32,13 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
let params = ExecParams {
command: cmd.iter().map(ToString::to_string).collect(),
cwd: tmp.path().to_path_buf(),
timeout_ms: Some(1000),
expiration: 1000.into(),
env: HashMap::new(),
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let policy = SandboxPolicy::new_read_only_policy();

View File

@@ -49,6 +49,7 @@ mod seatbelt;
mod shell_serialization;
mod stream_error_allows_next_turn;
mod stream_no_completed;
mod text_encoding_fix;
mod tool_harness;
mod tool_parallelism;
mod tools;

View File

@@ -1,21 +1,10 @@
#![allow(clippy::unwrap_used)]
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
use codex_core::features::Feature;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::load_default_config_for_test;
use core_test_support::load_sse_fixture_with_id;
use core_test_support::responses;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::wait_for_event;
use tempfile::TempDir;
use wiremock::MockServer;
use core_test_support::test_codex::test_codex;
fn sse_completed(id: &str) -> String {
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
@@ -39,46 +28,17 @@ fn tool_identifiers(body: &serde_json::Value) -> Vec<String> {
#[allow(clippy::expect_used)]
async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
let server = MockServer::start().await;
let server = start_mock_server().await;
let sse = sse_completed(model);
let resp_mock = responses::mount_sse_once(&server, sse).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
};
let cwd = TempDir::new().unwrap();
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.cwd = cwd.path().to_path_buf();
config.model_provider = model_provider;
config.model = model.to_string();
config.model_family =
find_family_for_model(model).unwrap_or_else(|| panic!("unknown model family for {model}"));
config.features.disable(Feature::ApplyPatchFreeform);
config.features.disable(Feature::ViewImageTool);
config.features.disable(Feature::WebSearchRequest);
config.features.disable(Feature::UnifiedExec);
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
let codex = conversation_manager
.new_conversation(config)
let mut builder = test_codex().with_model(model);
let test = builder
.build(&server)
.await
.expect("create new conversation")
.conversation;
.expect("create test Codex conversation");
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello tools".into(),
}],
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
test.submit_turn("hello tools").await.expect("submit turn");
let body = resp_mock.single_request().body_json();
tool_identifiers(&body)
@@ -97,7 +57,8 @@ async fn model_selects_expected_tools() {
"list_mcp_resources".to_string(),
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string()
"update_plan".to_string(),
"view_image".to_string()
],
"codex-mini-latest should expose the local shell tool",
);
@@ -111,7 +72,8 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string()
"apply_patch".to_string(),
"view_image".to_string()
],
"gpt-5-codex should expose the apply_patch tool",
);
@@ -125,7 +87,8 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string()
"apply_patch".to_string(),
"view_image".to_string()
],
"gpt-5.1-codex should expose the apply_patch tool",
);
@@ -139,6 +102,7 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"view_image".to_string()
],
"gpt-5 should expose the apply_patch tool",
);
@@ -152,7 +116,8 @@ async fn model_selects_expected_tools() {
"list_mcp_resource_templates".to_string(),
"read_mcp_resource".to_string(),
"update_plan".to_string(),
"apply_patch".to_string()
"apply_patch".to_string(),
"view_image".to_string()
],
"gpt-5.1 should expose the apply_patch tool",
);

View File

@@ -843,7 +843,6 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Approved,
allow_prefix: None,
})
.await
.unwrap();
@@ -902,7 +901,6 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::ApprovedForSession,
allow_prefix: None,
})
.await
.unwrap();
@@ -961,7 +959,6 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Approved,
allow_prefix: None,
})
.await
.unwrap();
@@ -1020,7 +1017,6 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Denied,
allow_prefix: None,
})
.await
.unwrap();
@@ -1079,7 +1075,6 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::ApprovedForSession,
allow_prefix: None,
})
.await
.unwrap();
@@ -1139,7 +1134,6 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Denied,
allow_prefix: None,
})
.await
.unwrap();

View File

@@ -30,18 +30,15 @@ fn text_user_input(text: String) -> serde_json::Value {
}
fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
let shell_name = shell.name();
format!(
r#"<environment_context>
<cwd>{}</cwd>
<cwd>{cwd}</cwd>
<approval_policy>on-request</approval_policy>
<sandbox_mode>read-only</sandbox_mode>
<network_access>restricted</network_access>
{}</environment_context>"#,
cwd,
match shell.name() {
Some(name) => format!(" <shell>{name}</shell>\n"),
None => String::new(),
}
<shell>{shell_name}</shell>
</environment_context>"#
)
}
@@ -227,7 +224,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let shell = default_user_shell().await;
let shell = default_user_shell();
let cwd_str = config.cwd.to_string_lossy();
let expected_env_text = default_env_context_str(&cwd_str, &shell);
let expected_ui_text = format!(
@@ -345,6 +342,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
// After overriding the turn context, the environment context should be emitted again
// reflecting the new approval policy and sandbox settings. Omit cwd because it did
// not change.
let shell = default_user_shell();
let expected_env_text_2 = format!(
r#"<environment_context>
<approval_policy>never</approval_policy>
@@ -353,8 +351,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
<writable_roots>
<root>{}</root>
</writable_roots>
<shell>{}</shell>
</environment_context>"#,
writable.path().to_string_lossy(),
writable.path().display(),
shell.name()
);
let expected_env_msg_2 = serde_json::json!({
"type": "message",
@@ -522,6 +522,8 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
"role": "user",
"content": [ { "type": "input_text", "text": "hello 2" } ]
});
let shell = default_user_shell();
let expected_env_text_2 = format!(
r#"<environment_context>
<cwd>{}</cwd>
@@ -531,9 +533,11 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
<writable_roots>
<root>{}</root>
</writable_roots>
<shell>{}</shell>
</environment_context>"#,
new_cwd.path().to_string_lossy(),
writable.path().to_string_lossy(),
new_cwd.path().display(),
writable.path().display(),
shell.name(),
);
let expected_env_msg_2 = serde_json::json!({
"type": "message",
@@ -610,7 +614,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
let body1 = req1.single_request().body_json();
let body2 = req2.single_request().body_json();
let shell = default_user_shell().await;
let shell = default_user_shell();
let default_cwd_lossy = default_cwd.to_string_lossy();
let expected_ui_text = format!(
"# AGENTS.md instructions for {default_cwd_lossy}\n\n<INSTRUCTIONS>\nbe consistent and helpful\n</INSTRUCTIONS>"
@@ -697,7 +701,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
let body1 = req1.single_request().body_json();
let body2 = req2.single_request().body_json();
let shell = default_user_shell().await;
let shell = default_user_shell();
let expected_ui_text = format!(
"# AGENTS.md instructions for {}\n\n<INSTRUCTIONS>\nbe consistent and helpful\n</INSTRUCTIONS>",
default_cwd.to_string_lossy()
@@ -717,14 +721,15 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
]);
assert_eq!(body1["input"], expected_input_1);
let expected_env_msg_2 = text_user_input(
let shell_name = shell.name();
let expected_env_msg_2 = text_user_input(format!(
r#"<environment_context>
<approval_policy>never</approval_policy>
<sandbox_mode>danger-full-access</sandbox_mode>
<network_access>enabled</network_access>
<shell>{shell_name}</shell>
</environment_context>"#
.to_string(),
);
));
let expected_user_message_2 = text_user_input("hello 2".to_string());
let expected_input_2 = serde_json::Value::Array(vec![
expected_ui_msg,

View File

@@ -2,6 +2,7 @@
#![allow(clippy::expect_used)]
use anyhow::Result;
use codex_core::config::Config;
use codex_core::features::Feature;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::SandboxPolicy;
@@ -40,6 +41,20 @@ const FIXTURE_JSON: &str = r#"{
}
"#;
fn configure_shell_command_model(output_type: ShellModelOutput, config: &mut Config) {
if !matches!(output_type, ShellModelOutput::ShellCommand) {
return;
}
if let Some(shell_command_family) = find_family_for_model("test-gpt-5-codex") {
if config.model_family.shell_type == shell_command_family.shell_type {
return;
}
config.model = shell_command_family.slug.clone();
config.model_family = shell_command_family;
}
}
fn shell_responses(
call_id: &str,
command: Vec<&str>,
@@ -112,10 +127,7 @@ async fn shell_output_stays_json_without_freeform_apply_patch(
config.features.disable(Feature::ApplyPatchFreeform);
config.model = "gpt-5".to_string();
config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is a model family");
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
let _ = output_type;
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
@@ -170,10 +182,7 @@ async fn shell_output_is_structured_with_freeform_apply_patch(
let server = start_mock_server().await;
let mut builder = test_codex().with_config(move |config| {
config.features.enable(Feature::ApplyPatchFreeform);
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
let _ = output_type;
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
@@ -223,10 +232,7 @@ async fn shell_output_preserves_fixture_json_without_serialization(
config.features.disable(Feature::ApplyPatchFreeform);
config.model = "gpt-5".to_string();
config.model_family = find_family_for_model("gpt-5").expect("gpt-5 is a model family");
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
let _ = output_type;
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
@@ -293,10 +299,7 @@ async fn shell_output_structures_fixture_with_serialization(
let server = start_mock_server().await;
let mut builder = test_codex().with_config(move |config| {
config.features.enable(Feature::ApplyPatchFreeform);
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
let _ = output_type;
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
@@ -358,15 +361,12 @@ async fn shell_output_for_freeform_tool_records_duration(
let server = start_mock_server().await;
let mut builder = test_codex().with_config(move |config| {
config.include_apply_patch_tool = true;
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
let _ = output_type;
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
let call_id = "shell-structured";
let responses = shell_responses(call_id, vec!["/bin/bash", "-c", "sleep 1"], output_type)?;
let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 1"], output_type)?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_policy(
@@ -417,10 +417,7 @@ async fn shell_output_reserializes_truncated_content(output_type: ShellModelOutp
config.model_family =
find_family_for_model("gpt-5.1-codex").expect("gpt-5.1-codex is a model family");
config.tool_output_token_limit = Some(200);
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
let _ = output_type;
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
@@ -722,9 +719,7 @@ async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutp
config.model_family =
find_family_for_model("gpt-5.1-codex").expect("gpt-5.1-codex is a model family");
config.include_apply_patch_tool = true;
if matches!(output_type, ShellModelOutput::ShellCommand) {
config.features.enable(Feature::ShellCommandTool);
}
configure_shell_command_model(output_type, config);
});
let test = builder.build(&server).await?;
@@ -760,7 +755,7 @@ async fn shell_command_output_is_freeform() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(move |config| {
config.features.enable(Feature::ShellCommandTool);
configure_shell_command_model(ShellModelOutput::ShellCommand, config);
});
let test = builder.build(&server).await?;
@@ -812,11 +807,7 @@ async fn shell_command_output_is_not_truncated_under_10k_bytes() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex()
.with_model("gpt-5.1")
.with_config(move |config| {
config.features.enable(Feature::ShellCommandTool);
});
let mut builder = test_codex().with_model("gpt-5.1");
let test = builder.build(&server).await?;
let call_id = "shell-command";
@@ -866,11 +857,7 @@ async fn shell_command_output_is_not_truncated_over_10k_bytes() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex()
.with_model("gpt-5.1")
.with_config(move |config| {
config.features.enable(Feature::ShellCommandTool);
});
let mut builder = test_codex().with_model("gpt-5.1");
let test = builder.build(&server).await?;
let call_id = "shell-command";

View File

@@ -0,0 +1,77 @@
//! Integration test for the text encoding fix for issue #6178.
//!
//! These tests simulate VSCode's shell preview on Windows/WSL where the output
//! may be encoded with a legacy code page before it reaches Codex.
use codex_core::exec::StreamOutput;
use pretty_assertions::assert_eq;
#[test]
fn test_utf8_shell_output() {
// Baseline: UTF-8 output should bypass the detector and remain unchanged.
assert_eq!(decode_shell_output("пример".as_bytes()), "пример");
}
#[test]
fn test_cp1251_shell_output() {
// VS Code shells on Windows frequently surface CP1251 bytes for Cyrillic text.
assert_eq!(decode_shell_output(b"\xEF\xF0\xE8\xEC\xE5\xF0"), "пример");
}
#[test]
fn test_cp866_shell_output() {
// Native cmd.exe still defaults to CP866; make sure we recognize that too.
assert_eq!(decode_shell_output(b"\xAF\xE0\xA8\xAC\xA5\xE0"), "пример");
}
#[test]
fn test_windows_1252_smart_decoding() {
// Smart detection should turn fancy quotes/dashes into the proper Unicode glyphs.
assert_eq!(
decode_shell_output(b"\x93\x94 test \x96 dash"),
"\u{201C}\u{201D} test \u{2013} dash"
);
}
#[test]
fn test_smart_decoding_improves_over_lossy_utf8() {
// Regression guard: String::from_utf8_lossy() alone used to emit replacement chars here.
let bytes = b"\x93\x94 test \x96 dash";
assert!(
String::from_utf8_lossy(bytes).contains('\u{FFFD}'),
"lossy UTF-8 should inject replacement chars"
);
assert_eq!(
decode_shell_output(bytes),
"\u{201C}\u{201D} test \u{2013} dash",
"smart decoding should keep curly quotes intact"
);
}
#[test]
fn test_mixed_ascii_and_legacy_encoding() {
// Commands tend to mix ASCII status text with Latin-1 bytes (e.g. café).
assert_eq!(decode_shell_output(b"Output: caf\xE9"), "Output: café"); // codespell:ignore caf
}
#[test]
fn test_pure_latin1_shell_output() {
// Latin-1 by itself should still decode correctly (regression coverage for the older tests).
assert_eq!(decode_shell_output(b"caf\xE9"), "café"); // codespell:ignore caf
}
#[test]
fn test_invalid_bytes_still_fall_back_to_lossy() {
// If detection fails, we still want the user to see replacement characters.
let bytes = b"\xFF\xFE\xFD";
assert_eq!(decode_shell_output(bytes), String::from_utf8_lossy(bytes));
}
fn decode_shell_output(bytes: &[u8]) -> String {
StreamOutput {
text: bytes.to_vec(),
truncated_after_lines: None,
}
.from_utf8_lossy()
.text
}

View File

@@ -244,11 +244,16 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> {
"expected truncated shell output to be plain text"
);
assert_eq!(output.len(), 9976); // ~10k characters
let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: 0 seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#;
let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#;
assert_regex_match(truncated_pattern, &output);
let len = output.len();
assert!(
(9_900..=10_000).contains(&len),
"expected ~10k chars after truncation, got {len}"
);
Ok(())
}

View File

@@ -904,6 +904,98 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_respects_early_exit_notifications() -> 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 call_id = "uexec-early-exit";
let args = serde_json::json!({
"cmd": "sleep 0.05",
"yield_time_ms": 31415,
});
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: "watch early exit timing".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 early exit unified_exec output");
assert!(
output.session_id.is_none(),
"short-lived process should not keep a session alive"
);
assert_eq!(
output.exit_code,
Some(0),
"short-lived process should exit successfully"
);
let wall_time = output.wall_time_seconds;
assert!(
wall_time < 0.75,
"wall_time should reflect early exit rather than the full yield time; got {wall_time}"
);
assert!(
output.output.is_empty(),
"sleep command should not emit output, got {:?}",
output.output
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1530,8 +1622,8 @@ async fn unified_exec_formats_large_output_summary() -> Result<()> {
} = builder.build(&server).await?;
let script = r#"python3 - <<'PY'
for i in range(10000):
print("token token ")
import sys
sys.stdout.write("token token \n" * 5000)
PY
"#;

View File

@@ -49,6 +49,7 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }

View File

@@ -71,6 +71,7 @@ mod escalation_policy;
mod mcp;
mod mcp_escalation_policy;
mod socket;
mod stopwatch;
/// Default value of --execve option relative to the current executable.
/// Note this must match the name of the binary as specified in Cargo.toml.

View File

@@ -13,6 +13,7 @@ use codex_core::exec::process_exec_tool_call;
use codex_core::get_platform_sandbox;
use codex_core::protocol::SandboxPolicy;
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
use crate::posix::escalate_protocol::BASH_EXEC_WRAPPER_ENV_VAR;
use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR;
@@ -24,6 +25,7 @@ use crate::posix::escalate_protocol::SuperExecResult;
use crate::posix::escalation_policy::EscalationPolicy;
use crate::posix::socket::AsyncDatagramSocket;
use crate::posix::socket::AsyncSocket;
use codex_core::exec::ExecExpiration;
pub(crate) struct EscalateServer {
bash_path: PathBuf,
@@ -48,7 +50,7 @@ impl EscalateServer {
command: String,
env: HashMap<String, String>,
workdir: PathBuf,
timeout_ms: Option<u64>,
cancel_rx: CancellationToken,
) -> anyhow::Result<ExecResult> {
let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?;
let client_socket = escalate_client.into_inner();
@@ -79,11 +81,13 @@ impl EscalateServer {
command,
],
cwd: PathBuf::from(&workdir),
timeout_ms,
expiration: ExecExpiration::Cancellation(cancel_rx),
env,
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
},
get_platform_sandbox().unwrap_or(SandboxType::None),
&sandbox_policy,

View File

@@ -22,6 +22,7 @@ use crate::posix::escalate_server::EscalateServer;
use crate::posix::escalate_server::{self};
use crate::posix::mcp_escalation_policy::ExecPolicy;
use crate::posix::mcp_escalation_policy::McpEscalationPolicy;
use crate::posix::stopwatch::Stopwatch;
/// Path to our patched bash.
const CODEX_BASH_PATH_ENV_VAR: &str = "CODEX_BASH_PATH";
@@ -87,10 +88,17 @@ impl ExecTool {
context: RequestContext<RoleServer>,
Parameters(params): Parameters<ExecParams>,
) -> Result<CallToolResult, McpError> {
let effective_timeout = Duration::from_millis(
params
.timeout_ms
.unwrap_or(codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
);
let stopwatch = Stopwatch::new(effective_timeout);
let cancel_token = stopwatch.cancellation_token();
let escalate_server = EscalateServer::new(
self.bash_path.clone(),
self.execve_wrapper.clone(),
McpEscalationPolicy::new(self.policy, context),
McpEscalationPolicy::new(self.policy, context, stopwatch.clone()),
);
let result = escalate_server
.exec(
@@ -98,7 +106,7 @@ impl ExecTool {
// TODO: use ShellEnvironmentPolicy
std::env::vars().collect(),
PathBuf::from(&params.workdir),
params.timeout_ms,
cancel_token,
)
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;

View File

@@ -10,6 +10,7 @@ use rmcp::service::RequestContext;
use crate::posix::escalate_protocol::EscalateAction;
use crate::posix::escalation_policy::EscalationPolicy;
use crate::posix::stopwatch::Stopwatch;
/// This is the policy which decides how to handle an exec() call.
///
@@ -34,11 +35,20 @@ pub(crate) enum ExecPolicyOutcome {
pub(crate) struct McpEscalationPolicy {
policy: ExecPolicy,
context: RequestContext<RoleServer>,
stopwatch: Stopwatch,
}
impl McpEscalationPolicy {
pub(crate) fn new(policy: ExecPolicy, context: RequestContext<RoleServer>) -> Self {
Self { policy, context }
pub(crate) fn new(
policy: ExecPolicy,
context: RequestContext<RoleServer>,
stopwatch: Stopwatch,
) -> Self {
Self {
policy,
context,
stopwatch,
}
}
async fn prompt(
@@ -54,25 +64,34 @@ impl McpEscalationPolicy {
} else {
format!("{} {}", file.display(), args)
};
context
.peer
.create_elicitation(CreateElicitationRequestParam {
message: format!("Allow agent to run `{command}` in `{}`?", workdir.display()),
requested_schema: ElicitationSchema::builder()
.title("Execution Permission Request")
.optional_string_with("reason", |schema| {
schema.description("Optional reason for allowing or denying execution")
self.stopwatch
.pause_for(async {
context
.peer
.create_elicitation(CreateElicitationRequestParam {
message: format!(
"Allow agent to run `{command}` in `{}`?",
workdir.display()
),
requested_schema: ElicitationSchema::builder()
.title("Execution Permission Request")
.optional_string_with("reason", |schema| {
schema.description(
"Optional reason for allowing or denying execution",
)
})
.build()
.map_err(|e| {
McpError::internal_error(
format!("failed to build elicitation schema: {e}"),
None,
)
})?,
})
.build()
.map_err(|e| {
McpError::internal_error(
format!("failed to build elicitation schema: {e}"),
None,
)
})?,
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))
})
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))
}
}

View File

@@ -0,0 +1,211 @@
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug)]
pub(crate) struct Stopwatch {
limit: Duration,
inner: Arc<Mutex<StopwatchState>>,
notify: Arc<Notify>,
}
#[derive(Debug)]
struct StopwatchState {
elapsed: Duration,
running_since: Option<Instant>,
active_pauses: u32,
}
impl Stopwatch {
pub(crate) fn new(limit: Duration) -> Self {
Self {
inner: Arc::new(Mutex::new(StopwatchState {
elapsed: Duration::ZERO,
running_since: Some(Instant::now()),
active_pauses: 0,
})),
notify: Arc::new(Notify::new()),
limit,
}
}
pub(crate) fn cancellation_token(&self) -> CancellationToken {
let limit = self.limit;
let token = CancellationToken::new();
let cancel = token.clone();
let inner = Arc::clone(&self.inner);
let notify = Arc::clone(&self.notify);
tokio::spawn(async move {
loop {
let (remaining, running) = {
let guard = inner.lock().await;
let elapsed = guard.elapsed
+ guard
.running_since
.map(|since| since.elapsed())
.unwrap_or_default();
if elapsed >= limit {
break;
}
(limit - elapsed, guard.running_since.is_some())
};
if !running {
notify.notified().await;
continue;
}
let sleep = tokio::time::sleep(remaining);
tokio::pin!(sleep);
tokio::select! {
_ = &mut sleep => {
break;
}
_ = notify.notified() => {
continue;
}
}
}
cancel.cancel();
});
token
}
/// Runs `fut`, pausing the stopwatch while the future is pending. The clock
/// resumes automatically when the future completes. Nested/overlapping
/// calls are reference-counted so the stopwatch only resumes when every
/// pause is lifted.
pub(crate) async fn pause_for<F, T>(&self, fut: F) -> T
where
F: Future<Output = T>,
{
self.pause().await;
let result = fut.await;
self.resume().await;
result
}
async fn pause(&self) {
let mut guard = self.inner.lock().await;
guard.active_pauses += 1;
if guard.active_pauses == 1
&& let Some(since) = guard.running_since.take()
{
guard.elapsed += since.elapsed();
self.notify.notify_waiters();
}
}
async fn resume(&self) {
let mut guard = self.inner.lock().await;
if guard.active_pauses == 0 {
return;
}
guard.active_pauses -= 1;
if guard.active_pauses == 0 && guard.running_since.is_none() {
guard.running_since = Some(Instant::now());
self.notify.notify_waiters();
}
}
}
#[cfg(test)]
mod tests {
use super::Stopwatch;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
#[tokio::test]
async fn cancellation_receiver_fires_after_limit() {
let stopwatch = Stopwatch::new(Duration::from_millis(50));
let token = stopwatch.cancellation_token();
let start = Instant::now();
token.cancelled().await;
assert!(start.elapsed() >= Duration::from_millis(50));
}
#[tokio::test]
async fn pause_prevents_timeout_until_resumed() {
let stopwatch = Stopwatch::new(Duration::from_millis(50));
let token = stopwatch.cancellation_token();
let pause_handle = tokio::spawn({
let stopwatch = stopwatch.clone();
async move {
stopwatch
.pause_for(async {
sleep(Duration::from_millis(100)).await;
})
.await;
}
});
assert!(
timeout(Duration::from_millis(30), token.cancelled())
.await
.is_err()
);
pause_handle.await.expect("pause task should finish");
token.cancelled().await;
}
#[tokio::test]
async fn overlapping_pauses_only_resume_once() {
let stopwatch = Stopwatch::new(Duration::from_millis(50));
let token = stopwatch.cancellation_token();
// First pause.
let pause1 = {
let stopwatch = stopwatch.clone();
tokio::spawn(async move {
stopwatch
.pause_for(async {
sleep(Duration::from_millis(80)).await;
})
.await;
})
};
// Overlapping pause that ends sooner.
let pause2 = {
let stopwatch = stopwatch.clone();
tokio::spawn(async move {
stopwatch
.pause_for(async {
sleep(Duration::from_millis(30)).await;
})
.await;
})
};
// While both pauses are active, the cancellation should not fire.
assert!(
timeout(Duration::from_millis(40), token.cancelled())
.await
.is_err()
);
pause2.await.expect("short pause should complete");
// Still paused because the long pause is active.
assert!(
timeout(Duration::from_millis(30), token.cancelled())
.await
.is_err()
);
pause1.await.expect("long pause should complete");
// Now the stopwatch should resume and hit the limit shortly after.
token.cancelled().await;
}
}

View File

@@ -101,7 +101,7 @@ pub struct ResumeArgs {
pub session_id: Option<String>,
/// Resume the most recent recorded session (newest) without specifying an id.
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
#[arg(long = "last", default_value_t = false)]
pub last: bool,
/// Prompt to send after resuming the session. If `-` is used, read from stdin.

View File

@@ -161,7 +161,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
fn process_event(&mut self, event: Event) -> CodexStatus {
let Event { id: _, msg } = event;
match msg {
EventMsg::Error(ErrorEvent { message }) => {
EventMsg::Error(ErrorEvent { message, .. }) => {
let prefix = "ERROR:".style(self.red);
ts_msg!(self, "{prefix} {message}");
}
@@ -221,7 +221,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
ts_msg!(self, "{}", message.style(self.dimmed));
}
EventMsg::StreamError(StreamErrorEvent { message }) => {
EventMsg::StreamError(StreamErrorEvent { message, .. }) => {
ts_msg!(self, "{}", message.style(self.dimmed));
}
EventMsg::TaskStarted(_) => {

View File

@@ -82,7 +82,21 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let prompt_arg = match &command {
// Allow prompt before the subcommand by falling back to the parent-level prompt
// when the Resume subcommand did not provide its own prompt.
Some(ExecCommand::Resume(args)) => args.prompt.clone().or(prompt),
Some(ExecCommand::Resume(args)) => {
let resume_prompt = args
.prompt
.clone()
// When using `resume --last <PROMPT>`, clap still parses the first positional
// as `session_id`. Reinterpret it as the prompt so the flag works with JSON mode.
.or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
});
resume_prompt.or(prompt)
}
None => prompt,
};

View File

@@ -47,6 +47,7 @@ use codex_exec::exec_events::WebSearchItem;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
@@ -539,6 +540,7 @@ fn error_event_produces_error() {
"e1",
EventMsg::Error(codex_core::protocol::ErrorEvent {
message: "boom".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
));
assert_eq!(
@@ -578,6 +580,7 @@ fn stream_error_event_produces_error() {
"e1",
EventMsg::StreamError(codex_core::protocol::StreamErrorEvent {
message: "retrying".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
));
assert_eq!(
@@ -596,6 +599,7 @@ fn error_followed_by_task_complete_produces_turn_failed() {
"e1",
EventMsg::Error(ErrorEvent {
message: "boom".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
);
assert_eq!(

View File

@@ -123,6 +123,60 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<()> {
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session with a unique marker in the content.
let marker = format!("resume-last-json-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt)
.assert()
.success();
// Find the created session file containing the marker.
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
// 2) Second run: resume the most recent file and pass the prompt after --last.
let marker2 = format!("resume-last-json-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg("--json")
.arg("resume")
.arg("--last")
.arg(&prompt2)
.assert()
.success();
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");
assert_eq!(
resumed_path, path,
"resume --last should append to existing file"
);
let content = std::fs::read_to_string(&resumed_path)?;
assert!(content.contains(&marker));
assert!(content.contains(&marker2));
Ok(())
}
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let test = test_codex_exec();

View File

@@ -27,4 +27,3 @@ thiserror = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -20,18 +20,18 @@ prefix_rule(
```
## CLI
- Provide one or more policy files (for example `src/default.codexpolicy`) to check a command:
- From the Codex CLI, run `codex execpolicy check` subcommand with one or more policy files (for example `src/default.codexpolicy`) to check a command:
```bash
codex execpolicy check --policy path/to/policy.codexpolicy git status
```
- Pass multiple `--policy` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON.
- You can also run the standalone dev binary directly during development:
```bash
cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status
```
- Pass multiple `--policy` flags to merge rules, evaluated in the order provided:
```bash
cargo run -p codex-execpolicy -- check --policy base.codexpolicy --policy overrides.codexpolicy git status
```
- Output is JSON by default; pass `--pretty` for pretty-printed JSON
- Example outcomes:
- Match: `{"match": { ... "decision": "allow" ... }}`
- No match: `"noMatch"`
- No match: `{"noMatch": {}}`
## Response shapes
- Match:
@@ -53,8 +53,10 @@ cargo run -p codex-execpolicy -- check --policy base.codexpolicy --policy overri
- No match:
```json
"noMatch"
{"noMatch": {}}
```
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future.

View File

@@ -1,143 +0,0 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use serde_json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AmendError {
#[error("prefix rule requires at least one token")]
EmptyPrefix,
#[error("policy path has no parent: {path}")]
MissingParent { path: PathBuf },
#[error("failed to create policy directory {dir}: {source}")]
CreatePolicyDir {
dir: PathBuf,
source: std::io::Error,
},
#[error("failed to format prefix token {token}: {source}")]
SerializeToken {
token: String,
source: serde_json::Error,
},
#[error("failed to open policy file {path}: {source}")]
OpenPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to write to policy file {path}: {source}")]
WritePolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to read metadata for policy file {path}: {source}")]
PolicyMetadata {
path: PathBuf,
source: std::io::Error,
},
}
pub fn append_allow_prefix_rule(policy_path: &Path, prefix: &[String]) -> Result<(), AmendError> {
if prefix.is_empty() {
return Err(AmendError::EmptyPrefix);
}
let tokens: Vec<String> = prefix
.iter()
.map(|token| {
serde_json::to_string(token).map_err(|source| AmendError::SerializeToken {
token: token.clone(),
source,
})
})
.collect::<Result<_, _>>()?;
let pattern = tokens.join(", ");
let rule = format!("prefix_rule(pattern=[{pattern}], decision=\"allow\")\n");
let dir = policy_path
.parent()
.ok_or_else(|| AmendError::MissingParent {
path: policy_path.to_path_buf(),
})?;
match std::fs::create_dir(dir) {
Ok(()) => {}
Err(ref source) if source.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(source) => {
return Err(AmendError::CreatePolicyDir {
dir: dir.to_path_buf(),
source,
});
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(policy_path)
.map_err(|source| AmendError::OpenPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
let needs_newline = file
.metadata()
.map(|metadata| metadata.len() > 0)
.map_err(|source| AmendError::PolicyMetadata {
path: policy_path.to_path_buf(),
source,
})?;
let final_rule = if needs_newline {
format!("\n{rule}")
} else {
rule
};
file.write_all(final_rule.as_bytes())
.map_err(|source| AmendError::WritePolicyFile {
path: policy_path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn appends_rule_and_creates_directories() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
append_allow_prefix_rule(&policy_path, &[String::from("echo"), String::from("Hello, world!")])
.expect("append rule");
let contents =
std::fs::read_to_string(&policy_path).expect("default.codexpolicy should exist");
assert_eq!(
contents,
"prefix_rule(pattern=[\"echo\", \"Hello, world!\"], decision=\"allow\")\n"
);
}
#[test]
fn separates_rules_with_newlines_when_appending() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
std::fs::write(
&policy_path,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\n",
)
.expect("write seed rule");
append_allow_prefix_rule(&policy_path, &[String::from("echo"), String::from("Hello, world!")]).expect("append rule");
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\n\nprefix_rule(pattern=[\"echo\", \"Hello, world!\"], decision=\"allow\")\n"
);
}
}

View File

@@ -0,0 +1,67 @@
use std::fs;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use crate::Evaluation;
use crate::Policy;
use crate::PolicyParser;
/// Arguments for evaluating a command against one or more execpolicy files.
#[derive(Debug, Parser, Clone)]
pub struct ExecPolicyCheckCommand {
/// Paths to execpolicy files to evaluate (repeatable).
#[arg(short = 'p', long = "policy", value_name = "PATH", required = true)]
pub policies: Vec<PathBuf>,
/// Pretty-print the JSON output.
#[arg(long)]
pub pretty: bool,
/// Command tokens to check against the policy.
#[arg(
value_name = "COMMAND",
required = true,
trailing_var_arg = true,
allow_hyphen_values = true
)]
pub command: Vec<String>,
}
impl ExecPolicyCheckCommand {
/// Load the policies for this command, evaluate the command, and render JSON output.
pub fn run(&self) -> Result<()> {
let policy = load_policies(&self.policies)?;
let evaluation = policy.check(&self.command);
let json = format_evaluation_json(&evaluation, self.pretty)?;
println!("{json}");
Ok(())
}
}
pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result<String> {
if pretty {
serde_json::to_string_pretty(evaluation).map_err(Into::into)
} else {
serde_json::to_string(evaluation).map_err(Into::into)
}
}
pub fn load_policies(policy_paths: &[PathBuf]) -> Result<Policy> {
let mut parser = PolicyParser::new();
for policy_path in policy_paths {
let policy_file_contents = fs::read_to_string(policy_path)
.with_context(|| format!("failed to read policy at {}", policy_path.display()))?;
let policy_identifier = policy_path.to_string_lossy().to_string();
parser
.parse(&policy_identifier, &policy_file_contents)
.with_context(|| format!("failed to parse policy at {}", policy_path.display()))?;
}
Ok(parser.build())
}

View File

@@ -1,15 +1,14 @@
pub mod amend;
pub mod decision;
pub mod error;
pub mod execpolicycheck;
pub mod parser;
pub mod policy;
pub mod rule;
pub use amend::AmendError;
pub use amend::append_allow_prefix_rule;
pub use decision::Decision;
pub use error::Error;
pub use error::Result;
pub use execpolicycheck::ExecPolicyCheckCommand;
pub use parser::PolicyParser;
pub use policy::Evaluation;
pub use policy::Policy;

View File

@@ -1,66 +1,22 @@
use std::fs;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::ExecPolicyCheckCommand;
/// CLI for evaluating exec policies
#[derive(Parser)]
#[command(name = "codex-execpolicy")]
enum Cli {
/// Evaluate a command against a policy.
Check {
#[arg(short, long = "policy", value_name = "PATH", required = true)]
policies: Vec<PathBuf>,
/// Pretty-print the JSON output.
#[arg(long)]
pretty: bool,
/// Command tokens to check.
#[arg(
value_name = "COMMAND",
required = true,
trailing_var_arg = true,
allow_hyphen_values = true
)]
command: Vec<String>,
},
Check(ExecPolicyCheckCommand),
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli {
Cli::Check {
policies,
command,
pretty,
} => cmd_check(policies, command, pretty),
Cli::Check(cmd) => cmd_check(cmd),
}
}
fn cmd_check(policy_paths: Vec<PathBuf>, args: Vec<String>, pretty: bool) -> Result<()> {
let policy = load_policies(&policy_paths)?;
let eval = policy.check(&args);
let json = if pretty {
serde_json::to_string_pretty(&eval)?
} else {
serde_json::to_string(&eval)?
};
println!("{json}");
Ok(())
}
fn load_policies(policy_paths: &[PathBuf]) -> Result<codex_execpolicy::Policy> {
let mut parser = PolicyParser::new();
for policy_path in policy_paths {
let policy_file_contents = fs::read_to_string(policy_path)
.with_context(|| format!("failed to read policy at {}", policy_path.display()))?;
let policy_identifier = policy_path.to_string_lossy().to_string();
parser.parse(&policy_identifier, &policy_file_contents)?;
}
Ok(parser.build())
fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> {
cmd.run()
}

View File

@@ -27,9 +27,9 @@ impl Policy {
let rules = match cmd.first() {
Some(first) => match self.rules_by_program.get_vec(first) {
Some(rules) => rules,
None => return Evaluation::NoMatch,
None => return Evaluation::NoMatch {},
},
None => return Evaluation::NoMatch,
None => return Evaluation::NoMatch {},
};
let matched_rules: Vec<RuleMatch> =
@@ -39,7 +39,7 @@ impl Policy {
decision,
matched_rules,
},
None => Evaluation::NoMatch,
None => Evaluation::NoMatch {},
}
}
@@ -52,7 +52,7 @@ impl Policy {
.into_iter()
.flat_map(|command| match self.check(command.as_ref()) {
Evaluation::Match { matched_rules, .. } => matched_rules,
Evaluation::NoMatch => Vec::new(),
Evaluation::NoMatch { .. } => Vec::new(),
})
.collect();
@@ -61,7 +61,7 @@ impl Policy {
decision,
matched_rules,
},
None => Evaluation::NoMatch,
None => Evaluation::NoMatch {},
}
}
}
@@ -69,7 +69,7 @@ impl Policy {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Evaluation {
NoMatch,
NoMatch {},
Match {
decision: Decision,
#[serde(rename = "matchedRules")]

View File

@@ -288,7 +288,7 @@ prefix_rule(
"color.status=always",
"status",
]));
assert_eq!(Evaluation::NoMatch, no_match_eval);
assert_eq!(Evaluation::NoMatch {}, no_match_eval);
}
#[test]

View File

@@ -40,11 +40,13 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
let params = ExecParams {
command: cmd.iter().copied().map(str::to_owned).collect(),
cwd,
timeout_ms: Some(timeout_ms),
expiration: timeout_ms.into(),
env: create_env_from_core_vars(),
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
@@ -143,11 +145,13 @@ async fn assert_network_blocked(cmd: &[&str]) {
cwd,
// Give the tool a generous 2-second timeout so even slow DNS timeouts
// do not stall the suite.
timeout_ms: Some(NETWORK_TIMEOUT_MS),
expiration: NETWORK_TIMEOUT_MS.into(),
env: create_env_from_core_vars(),
with_escalated_permissions: None,
justification: None,
arg0: None,
max_output_tokens: None,
max_output_chars: None,
};
let sandbox_policy = SandboxPolicy::new_read_only_policy();

View File

@@ -180,7 +180,6 @@ async fn run_codex_tool_session_inner(
call_id,
reason: _,
risk,
allow_prefix: _,
parsed_cmd,
}) => {
handle_exec_approval_request(

View File

@@ -150,7 +150,6 @@ async fn on_exec_approval_response(
.submit(Op::ExecApproval {
id: event_id,
decision: response.decision,
allow_prefix: None,
})
.await
{

View File

@@ -50,10 +50,6 @@ pub struct ExecApprovalRequestEvent {
/// Optional model-provided risk assessment describing the blocked command.
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<SandboxCommandAssessment>,
/// Prefix rule that can be added to the user's execpolicy to allow future runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, type = "Array<string>")]
pub allow_prefix: Option<Vec<String>>,
pub parsed_cmd: Vec<ParsedCommand>,
}

View File

@@ -322,6 +322,10 @@ pub struct ShellToolCallParams {
pub with_escalated_permissions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_chars: Option<usize>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
@@ -338,6 +342,10 @@ pub struct ShellCommandToolCallParams {
pub with_escalated_permissions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_chars: Option<usize>,
}
/// Responses API compatible content items that can be returned by a tool call.
@@ -650,6 +658,8 @@ mod tests {
timeout_ms: Some(1000),
with_escalated_permissions: None,
justification: None,
max_output_tokens: None,
max_output_chars: None,
},
params
);

View File

@@ -143,9 +143,6 @@ pub enum Op {
id: String,
/// The user's decision in response to the request.
decision: ReviewDecision,
/// When set, persist this prefix to the execpolicy allow list.
#[serde(default, skip_serializing_if = "Option::is_none")]
allow_prefix: Option<Vec<String>>,
},
/// Approve a code patch
@@ -565,6 +562,35 @@ pub enum EventMsg {
ReasoningRawContentDelta(ReasoningRawContentDeltaEvent),
}
/// Codex errors that we expose to clients.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum CodexErrorInfo {
ContextWindowExceeded,
UsageLimitExceeded,
HttpConnectionFailed {
http_status_code: Option<u16>,
},
/// Failed to connect to the response SSE stream.
ResponseStreamConnectionFailed {
http_status_code: Option<u16>,
},
InternalServerError,
Unauthorized,
BadRequest,
SandboxError,
/// The response SSE stream disconnected in the middle of a turnbefore completion.
ResponseStreamDisconnected {
http_status_code: Option<u16>,
},
/// Reached the retry limit for responses.
ResponseTooManyFailedAttempts {
http_status_code: Option<u16>,
},
Other,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
pub struct RawResponseItemEvent {
pub item: ResponseItem,
@@ -689,6 +715,8 @@ pub struct ExitedReviewModeEvent {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ErrorEvent {
pub message: String,
#[serde(default)]
pub codex_error_info: Option<CodexErrorInfo>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@@ -1366,6 +1394,8 @@ pub struct UndoCompletedEvent {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct StreamErrorEvent {
pub message: String,
#[serde(default)]
pub codex_error_info: Option<CodexErrorInfo>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]

View File

@@ -343,7 +343,8 @@ impl App {
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);
let sandbox_policy = app.config.sandbox_policy.clone();
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx);
}
}
@@ -717,7 +718,14 @@ impl App {
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);
let sandbox_policy = self.config.sandbox_policy.clone();
Self::spawn_world_writable_scan(
cwd,
env_map,
logs_base_dir,
sandbox_policy,
tx,
);
}
}
}
@@ -911,6 +919,7 @@ impl App {
cwd: PathBuf,
env_map: std::collections::HashMap<String, String>,
logs_base_dir: PathBuf,
sandbox_policy: codex_core::protocol::SandboxPolicy,
tx: AppEventSender,
) {
#[inline]
@@ -920,8 +929,10 @@ impl App {
}
tokio::task::spawn_blocking(move || {
let result = codex_windows_sandbox::preflight_audit_everyone_writable(
&logs_base_dir,
&cwd,
&env_map,
&sandbox_policy,
Some(logs_base_dir.as_path()),
);
if let Ok(ref paths) = result

View File

@@ -41,7 +41,6 @@ pub(crate) enum ApprovalRequest {
command: Vec<String>,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
@@ -98,8 +97,8 @@ impl ApprovalOverlay {
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec { allow_prefix, .. } => (
exec_options(allow_prefix.clone()),
ApprovalVariant::Exec { .. } => (
exec_options(),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
@@ -151,8 +150,8 @@ impl ApprovalOverlay {
};
if let Some(variant) = self.current_variant.as_ref() {
match (&variant, option.decision) {
(ApprovalVariant::Exec { id, command, .. }, decision) => {
self.handle_exec_decision(id, command, decision, option.allow_prefix.clone());
(ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision);
}
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
self.handle_patch_decision(id, decision);
@@ -164,19 +163,12 @@ impl ApprovalOverlay {
self.advance_queue();
}
fn handle_exec_decision(
&self,
id: &str,
command: &[String],
decision: ReviewDecision,
allow_prefix: Option<Vec<String>>,
) {
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
id: id.to_string(),
decision,
allow_prefix,
}));
}
@@ -246,8 +238,8 @@ impl BottomPaneView for ApprovalOverlay {
&& let Some(variant) = self.current_variant.as_ref()
{
match &variant {
ApprovalVariant::Exec { id, command, .. } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort, None);
ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
ApprovalVariant::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, ReviewDecision::Abort);
@@ -299,7 +291,6 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command,
reason,
risk,
allow_prefix,
} => {
let reason = reason.filter(|item| !item.is_empty());
let has_reason = reason.is_some();
@@ -319,11 +310,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
}
header.extend(full_cmd_lines);
Self {
variant: ApprovalVariant::Exec {
id,
command,
allow_prefix,
},
variant: ApprovalVariant::Exec { id, command },
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
@@ -377,14 +364,8 @@ fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec<Line<'static>> {
#[derive(Clone)]
enum ApprovalVariant {
Exec {
id: String,
command: Vec<String>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
},
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
}
#[derive(Clone)]
@@ -393,7 +374,6 @@ struct ApprovalOption {
decision: ReviewDecision,
display_shortcut: Option<KeyBinding>,
additional_shortcuts: Vec<KeyBinding>,
allow_prefix: Option<Vec<String>>,
}
impl ApprovalOption {
@@ -404,39 +384,27 @@ impl ApprovalOption {
}
}
fn exec_options(allow_prefix: Option<Vec<String>>) -> Vec<ApprovalOption> {
fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
allow_prefix: None,
},
ApprovalOption {
label: "Yes, and don't ask again for this command".to_string(),
decision: ReviewDecision::ApprovedForSession,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
allow_prefix: None,
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
.into_iter()
.chain(allow_prefix.map(|prefix| ApprovalOption {
label: "Yes, and don't ask again for commands with this prefix".to_string(),
decision: ReviewDecision::ApprovedForSession,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
allow_prefix: Some(prefix),
}))
.chain([ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
allow_prefix: None,
}])
.collect()
}
fn patch_options() -> Vec<ApprovalOption> {
@@ -446,14 +414,12 @@ fn patch_options() -> Vec<ApprovalOption> {
decision: ReviewDecision::Approved,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
allow_prefix: None,
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
allow_prefix: None,
},
]
}
@@ -471,7 +437,6 @@ mod tests {
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
risk: None,
allow_prefix: None,
}
}
@@ -504,41 +469,6 @@ mod tests {
assert!(saw_op, "expected approval decision to emit an op");
}
#[test]
fn exec_prefix_option_emits_allow_prefix() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string()],
reason: None,
risk: None,
allow_prefix: Some(vec!["echo".to_string()]),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ExecApproval {
allow_prefix,
decision,
..
}) = ev
{
assert_eq!(decision, ReviewDecision::ApprovedForSession);
assert_eq!(allow_prefix, Some(vec!["echo".to_string()]));
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected approval decision to emit an op with allow prefix"
);
}
#[test]
fn header_includes_command_snippet() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -549,7 +479,6 @@ mod tests {
command,
reason: None,
risk: None,
allow_prefix: None,
};
let view = ApprovalOverlay::new(exec_request, tx);

View File

@@ -26,7 +26,8 @@ use super::popup_consts::standard_popup_hint_line;
use super::textarea::TextArea;
use super::textarea::TextAreaState;
const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml";
const BASE_BUG_ISSUE_URL: &str =
"https://github.com/openai/codex/issues/new?template=2-bug-report.yml";
/// Minimal input overlay to collect an optional feedback note, then upload
/// both logs and rollout with classification + metadata.
@@ -88,26 +89,38 @@ impl FeedbackNoteView {
match result {
Ok(()) => {
let issue_url = format!("{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}");
let prefix = if self.include_logs {
"• Feedback uploaded."
} else {
"• Feedback recorded (no logs)."
};
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::PlainHistoryCell::new(vec![
Line::from(format!(
"{prefix} Please open an issue using the following URL:"
)),
let issue_url = issue_url_for_category(self.category, &thread_id);
let mut lines = vec![Line::from(match issue_url.as_ref() {
Some(_) => format!("{prefix} Please open an issue using the following URL:"),
None => format!("{prefix} Thanks for the feedback!"),
})];
if let Some(url) = issue_url {
lines.extend([
"".into(),
Line::from(vec![" ".into(), issue_url.cyan().underlined()]),
Line::from(vec![" ".into(), url.cyan().underlined()]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
std::mem::take(&mut thread_id).bold(),
" in an existing issue.".into(),
]),
]),
]);
} else {
lines.extend([
"".into(),
Line::from(vec![
" Thread ID: ".into(),
std::mem::take(&mut thread_id).bold(),
]),
]);
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::PlainHistoryCell::new(lines),
)));
}
Err(e) => {
@@ -320,6 +333,15 @@ fn feedback_classification(category: FeedbackCategory) -> &'static str {
}
}
fn issue_url_for_category(category: FeedbackCategory, thread_id: &str) -> Option<String> {
match category {
FeedbackCategory::Bug | FeedbackCategory::BadResult | FeedbackCategory::Other => Some(
format!("{BASE_BUG_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}"),
),
FeedbackCategory::GoodResult => None,
}
}
// Build the selection popup params for feedback categories.
pub(crate) fn feedback_selection_params(
app_event_tx: AppEventSender,
@@ -514,4 +536,22 @@ mod tests {
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_other", rendered);
}
#[test]
fn issue_url_available_for_bug_bad_result_and_other() {
let bug_url = issue_url_for_category(FeedbackCategory::Bug, "thread-1");
assert!(
bug_url
.as_deref()
.is_some_and(|url| url.contains("template=2-bug-report"))
);
let bad_result_url = issue_url_for_category(FeedbackCategory::BadResult, "thread-2");
assert!(bad_result_url.is_some());
let other_url = issue_url_for_category(FeedbackCategory::Other, "thread-3");
assert!(other_url.is_some());
assert!(issue_url_for_category(FeedbackCategory::GoodResult, "t").is_none());
}
}

View File

@@ -52,6 +52,7 @@ pub(crate) struct SelectionViewParams {
pub is_searchable: bool,
pub search_placeholder: Option<String>,
pub header: Box<dyn Renderable>,
pub initial_selected_idx: Option<usize>,
}
impl Default for SelectionViewParams {
@@ -64,6 +65,7 @@ impl Default for SelectionViewParams {
is_searchable: false,
search_placeholder: None,
header: Box::new(()),
initial_selected_idx: None,
}
}
}
@@ -80,6 +82,7 @@ pub(crate) struct ListSelectionView {
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
header: Box<dyn Renderable>,
initial_selected_idx: Option<usize>,
}
impl ListSelectionView {
@@ -110,6 +113,7 @@ impl ListSelectionView {
filtered_indices: Vec::new(),
last_selected_actual_idx: None,
header,
initial_selected_idx: params.initial_selected_idx,
};
s.apply_filter();
s
@@ -132,7 +136,8 @@ impl ListSelectionView {
(!self.is_searchable)
.then(|| self.items.iter().position(|item| item.is_current))
.flatten()
});
})
.or_else(|| self.initial_selected_idx.take());
if self.is_searchable && !self.search_query.is_empty() {
let query_lower = self.search_query.to_lowercase();

View File

@@ -69,6 +69,7 @@ pub(crate) struct BottomPane {
is_task_running: bool,
ctrl_c_quit_hint: bool,
esc_backtrack_hint: bool,
animations_enabled: bool,
/// Inline status indicator shown above the composer while a task is running.
status: Option<StatusIndicatorWidget>,
@@ -84,28 +85,38 @@ pub(crate) struct BottomPaneParams {
pub(crate) enhanced_keys_supported: bool,
pub(crate) placeholder_text: String,
pub(crate) disable_paste_burst: bool,
pub(crate) animations_enabled: bool,
}
impl BottomPane {
pub fn new(params: BottomPaneParams) -> Self {
let enhanced_keys_supported = params.enhanced_keys_supported;
let BottomPaneParams {
app_event_tx,
frame_requester,
has_input_focus,
enhanced_keys_supported,
placeholder_text,
disable_paste_burst,
animations_enabled,
} = params;
Self {
composer: ChatComposer::new(
params.has_input_focus,
params.app_event_tx.clone(),
has_input_focus,
app_event_tx.clone(),
enhanced_keys_supported,
params.placeholder_text,
params.disable_paste_burst,
placeholder_text,
disable_paste_burst,
),
view_stack: Vec::new(),
app_event_tx: params.app_event_tx,
frame_requester: params.frame_requester,
has_input_focus: params.has_input_focus,
app_event_tx,
frame_requester,
has_input_focus,
is_task_running: false,
ctrl_c_quit_hint: false,
status: None,
queued_user_messages: QueuedUserMessages::new(),
esc_backtrack_hint: false,
animations_enabled,
context_window_percent: None,
}
}
@@ -294,6 +305,7 @@ impl BottomPane {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
}
if let Some(status) = self.status.as_mut() {
@@ -319,6 +331,7 @@ impl BottomPane {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
self.request_redraw();
}
@@ -540,7 +553,6 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
reason: None,
risk: None,
allow_prefix: None,
}
}
@@ -555,6 +567,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
pane.push_approval_request(exec_request());
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
@@ -575,6 +588,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Create an approval modal (active view).
@@ -606,6 +620,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Start a running task so the status indicator is active above the composer.
@@ -671,6 +686,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Begin a task: show initial status.
@@ -696,6 +712,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
// Activate spinner (status view replaces composer) with no live ring.
@@ -725,6 +742,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
pane.set_task_running(true);
@@ -751,6 +769,7 @@ mod tests {
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
});
pane.set_task_running(true);

View File

@@ -962,6 +962,7 @@ impl ChatWidget {
parsed,
source,
None,
self.config.animations,
)));
}
@@ -1012,7 +1013,6 @@ impl ChatWidget {
command: ev.command,
reason: ev.reason,
risk: ev.risk,
allow_prefix: ev.allow_prefix,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
@@ -1072,6 +1072,7 @@ impl ChatWidget {
ev.parsed_cmd,
ev.source,
interaction_input,
self.config.animations,
)));
}
@@ -1084,6 +1085,7 @@ impl ChatWidget {
self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call(
ev.call_id,
ev.invocation,
self.config.animations,
)));
self.request_redraw();
}
@@ -1105,7 +1107,11 @@ impl ChatWidget {
Some(cell) if cell.call_id() == call_id => cell.complete(duration, result),
_ => {
self.flush_active_cell();
let mut cell = history_cell::new_active_mcp_tool_call(call_id, invocation);
let mut cell = history_cell::new_active_mcp_tool_call(
call_id,
invocation,
self.config.animations,
);
let extra_cell = cell.complete(duration, result);
self.active_cell = Some(Box::new(cell));
extra_cell
@@ -1147,6 +1153,7 @@ impl ChatWidget {
enhanced_keys_supported,
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
}),
active_cell: None,
config: config.clone(),
@@ -1221,6 +1228,7 @@ impl ChatWidget {
enhanced_keys_supported,
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
}),
active_cell: None,
config: config.clone(),
@@ -1656,7 +1664,7 @@ impl ChatWidget {
self.on_rate_limit_snapshot(ev.rate_limits);
}
EventMsg::Warning(WarningEvent { message }) => self.on_warning(message),
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message),
EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev),
EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev),
EventMsg::TurnAborted(ev) => match ev.reason {
@@ -1699,7 +1707,9 @@ impl ChatWidget {
}
EventMsg::UndoStarted(ev) => self.on_undo_started(ev),
EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev),
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
EventMsg::StreamError(StreamErrorEvent { message, .. }) => {
self.on_stream_error(message)
}
EventMsg::UserMessage(ev) => {
if from_replay {
self.on_user_message_event(ev);
@@ -2110,6 +2120,14 @@ impl ChatWidget {
} else {
default_choice
};
let selection_choice = highlight_choice.or(default_choice);
let initial_selected_idx = choices
.iter()
.position(|choice| choice.stored == selection_choice)
.or_else(|| {
selection_choice
.and_then(|effort| choices.iter().position(|choice| choice.display == effort))
});
let mut items: Vec<SelectionItem> = Vec::new();
for choice in choices.iter() {
let effort = choice.display;
@@ -2186,6 +2204,7 @@ impl ChatWidget {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx,
..Default::default()
});
}

View File

@@ -4,7 +4,6 @@ use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
@@ -37,7 +36,7 @@ pub(crate) fn spawn_agent(
eprintln!("{message}");
app_event_tx_clone.send(AppEvent::CodexEvent(Event {
id: "".to_string(),
msg: EventMsg::Error(ErrorEvent { message }),
msg: EventMsg::Error(err.to_error_event(None)),
}));
app_event_tx_clone.send(AppEvent::ExitRequest);
tracing::error!("failed to initialize codex: {err}");

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
• You ran ls
└ file1
file2

View File

@@ -50,6 +50,7 @@ use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::CodexErrorInfo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -338,6 +339,7 @@ fn make_chatwidget_manual() -> (
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: cfg.animations,
});
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let widget = ChatWidget {
@@ -587,7 +589,6 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -632,7 +633,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -683,7 +683,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1793,6 +1792,28 @@ fn exec_history_extends_previous_when_consecutive() {
assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat));
}
#[test]
fn user_shell_command_renders_output_not_exploring() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
let begin_ls = begin_exec_with_source(
&mut chat,
"user-shell-ls",
"ls",
ExecCommandSource::UserShell,
);
end_exec(&mut chat, begin_ls, "file1\nfile2\n", "", 0);
let cells = drain_insert_history(&mut rx);
assert_eq!(
cells.len(),
1,
"expected a single history cell for the user command"
);
let blob = lines_to_single_string(cells.first().unwrap());
assert_snapshot!("user_shell_ls_output", blob);
}
#[test]
fn disabled_slash_command_while_task_running_snapshot() {
// Build a chat widget and simulate an active task
@@ -1833,7 +1854,6 @@ fn approval_modal_exec_snapshot() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1880,7 +1900,6 @@ fn approval_modal_exec_without_reason_snapshot() {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2094,7 +2113,6 @@ fn status_widget_and_approval_modal_snapshot() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: Some(vec!["echo".into(), "hello world".into()]),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2630,6 +2648,7 @@ fn stream_error_updates_status_indicator() {
id: "sub-1".into(),
msg: EventMsg::StreamError(StreamErrorEvent {
message: msg.to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
});

View File

@@ -28,11 +28,15 @@ pub(crate) struct ExecCall {
#[derive(Debug)]
pub(crate) struct ExecCell {
pub(crate) calls: Vec<ExecCall>,
animations_enabled: bool,
}
impl ExecCell {
pub(crate) fn new(call: ExecCall) -> Self {
Self { calls: vec![call] }
pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {
Self {
calls: vec![call],
animations_enabled,
}
}
pub(crate) fn with_added_call(
@@ -56,6 +60,7 @@ impl ExecCell {
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
animations_enabled: self.animations_enabled,
})
} else {
None
@@ -112,12 +117,17 @@ impl ExecCell {
.and_then(|c| c.start_time)
}
pub(crate) fn animations_enabled(&self) -> bool {
self.animations_enabled
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
!call.parsed.is_empty()
!matches!(call.source, ExecCommandSource::UserShell)
&& !call.parsed.is_empty()
&& call.parsed.iter().all(|p| {
matches!(
p,

View File

@@ -40,17 +40,21 @@ pub(crate) fn new_active_exec_command(
parsed: Vec<ParsedCommand>,
source: ExecCommandSource,
interaction_input: Option<String>,
animations_enabled: bool,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
output: None,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
})
ExecCell::new(
ExecCall {
call_id,
command,
parsed,
output: None,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
},
animations_enabled,
)
}
fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String {
@@ -168,7 +172,10 @@ pub(crate) fn output_lines(
}
}
pub(crate) fn spinner(start_time: Option<Instant>) -> Span<'static> {
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
if !animations_enabled {
return "".dim();
}
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
@@ -239,7 +246,7 @@ impl ExecCell {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time())
spinner(self.active_start_time(), self.animations_enabled())
} else {
"".dim()
},
@@ -347,7 +354,7 @@ impl ExecCell {
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time),
None => spinner(call.start_time, self.animations_enabled()),
};
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {

View File

@@ -31,7 +31,7 @@ use std::time::Duration;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(8).unwrap();
const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(20).unwrap();
const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap();
/// How long to wait after a keystroke before firing the first search when none

View File

@@ -806,16 +806,22 @@ pub(crate) struct McpToolCallCell {
start_time: Instant,
duration: Option<Duration>,
result: Option<Result<mcp_types::CallToolResult, String>>,
animations_enabled: bool,
}
impl McpToolCallCell {
pub(crate) fn new(call_id: String, invocation: McpInvocation) -> Self {
pub(crate) fn new(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
) -> Self {
Self {
call_id,
invocation,
start_time: Instant::now(),
duration: None,
result: None,
animations_enabled,
}
}
@@ -877,7 +883,7 @@ impl HistoryCell for McpToolCallCell {
let bullet = match status {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(Some(self.start_time)),
None => spinner(Some(self.start_time), self.animations_enabled),
};
let header_text = if status.is_some() {
"Called"
@@ -965,8 +971,9 @@ impl HistoryCell for McpToolCallCell {
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
) -> McpToolCallCell {
McpToolCallCell::new(call_id, invocation)
McpToolCallCell::new(call_id, invocation, animations_enabled)
}
pub(crate) fn new_web_search_call(query: String) -> PlainHistoryCell {
@@ -1631,7 +1638,7 @@ mod tests {
})),
};
let cell = new_active_mcp_tool_call("call-1".into(), invocation);
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
@@ -1658,7 +1665,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(1420), Ok(result))
.is_none()
@@ -1680,7 +1687,7 @@ mod tests {
})),
};
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
assert!(
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
.is_none()
@@ -1724,7 +1731,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(640), Ok(result))
.is_none()
@@ -1756,7 +1763,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(1280), Ok(result))
.is_none()
@@ -1795,7 +1802,7 @@ mod tests {
structured_content: None,
};
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation);
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(320), Ok(result))
.is_none()
@@ -1853,32 +1860,35 @@ mod tests {
fn coalesces_sequential_reads_within_one_call() {
// Build one exec cell with a Search followed by two Reads
let call_id = "c1".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
ParsedCommand::Read {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
ParsedCommand::Read {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Mark call complete so markers are ✓
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -1889,20 +1899,23 @@ mod tests {
#[test]
fn coalesces_reads_across_multiple_calls() {
let mut cell = ExecCell::new(ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
}],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
}],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Call 1: Search only
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
// Call 2: Read A
@@ -1943,32 +1956,35 @@ mod tests {
#[test]
fn coalesced_reads_dedupe_names() {
let mut cell = ExecCell::new(ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
@@ -1980,16 +1996,19 @@ mod tests {
// Create a completed exec cell with a multiline command
let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string();
let call_id = "c1".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Mark call complete so it renders as "Ran"
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
@@ -2003,16 +2022,19 @@ mod tests {
#[test]
fn single_line_command_compact_when_fits() {
let call_id = "c1".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Wide enough that it fits inline
let lines = cell.display_lines(80);
@@ -2024,16 +2046,19 @@ mod tests {
fn single_line_command_wraps_with_four_space_continuation() {
let call_id = "c1".to_string();
let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(24);
let rendered = render_lines(&lines).join("\n");
@@ -2044,16 +2069,19 @@ mod tests {
fn multiline_command_without_wrap_uses_branch_then_eight_spaces() {
let call_id = "c1".to_string();
let cmd = "echo one\necho two".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
@@ -2065,16 +2093,19 @@ mod tests {
let call_id = "c1".to_string();
let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap"
.to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(28);
let rendered = render_lines(&lines).join("\n");
@@ -2086,16 +2117,19 @@ mod tests {
// Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
// the head/tail rendering and gutter prefixes.
let call_id = "c_err".to_string();
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
let stderr: String = (1..=10)
.map(|n| n.to_string())
.collect::<Vec<_>>()
@@ -2133,16 +2167,19 @@ mod tests {
let call_id = "c_wrap_err".to_string();
let long_cmd =
"echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width";
let mut cell = ExecCell::new(ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
});
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
cell.complete_call(

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