Compare commits

..

2 Commits

Author SHA1 Message Date
Michael Bolin
cb6f67d284 Merge remote-tracking branch 'origin/main' into shell-process-group-timeout 2025-11-07 16:38:49 -08:00
luca
9238c58460 Kill shell tool process groups on timeout 2025-10-16 10:42:45 -05:00
101 changed files with 1115 additions and 3648 deletions

View File

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

View File

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

View File

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

View File

@@ -295,15 +295,6 @@ jobs:
# ${{ matrix.target }}
dest="dist/${{ matrix.target }}"
# We want to ship the raw Windows executables in the GitHub Release
# in addition to the compressed archives. Keep the originals for
# Windows targets; remove them elsewhere to limit the number of
# artifacts that end up in the GitHub Release.
keep_originals=false
if [[ "${{ matrix.runner }}" == windows* ]]; then
keep_originals=true
fi
# For compatibility with environments that lack the `zstd` tool we
# additionally create a `.tar.gz` for all platforms and `.zip` for
# Windows alongside every single binary that we publish. The end result is:
@@ -333,11 +324,7 @@ jobs:
# Also create .zst (existing behaviour) *and* remove the original
# uncompressed binary to keep the directory small.
zstd_args=(-T0 -19)
if [[ "${keep_originals}" == false ]]; then
zstd_args+=(--rm)
fi
zstd "${zstd_args[@]}" "$dest/$base"
zstd -T0 -19 --rm "$dest/$base"
done
- name: Remove signing keychain

195
codex-rs/Cargo.lock generated
View File

@@ -211,7 +211,6 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -238,44 +237,46 @@ dependencies = [
[[package]]
name = "askama"
version = "0.14.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
dependencies = [
"askama_derive",
"itoa",
"askama_escape",
"humansize",
"num-traits",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.14.0"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"mime",
"mime_guess",
"proc-macro2",
"quote",
"rustc-hash 2.1.1",
"serde",
"serde_derive",
"syn 2.0.104",
]
[[package]]
name = "askama_parser"
version = "0.14.0"
name = "askama_escape"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
[[package]]
name = "askama_parser"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow",
"nom",
]
[[package]]
@@ -980,23 +981,21 @@ dependencies = [
"codex-mcp-server",
"codex-process-hardening",
"codex-protocol",
"codex-protocol-ts",
"codex-responses-api-proxy",
"codex-rmcp-client",
"codex-stdio-to-uds",
"codex-tui",
"codex-windows-sandbox",
"ctor 0.5.0",
"libc",
"owo-colors",
"predicates",
"pretty_assertions",
"regex-lite",
"serde_json",
"supports-color",
"tempfile",
"tokio",
"toml",
"tracing",
]
[[package]]
@@ -1067,7 +1066,6 @@ dependencies = [
"chrono",
"codex-app-server-protocol",
"codex-apply-patch",
"codex-arg0",
"codex-async-utils",
"codex-file-search",
"codex-git",
@@ -1082,7 +1080,6 @@ dependencies = [
"codex-windows-sandbox",
"core-foundation 0.9.4",
"core_test_support",
"ctor 0.5.0",
"dirs",
"dunce",
"env-flags",
@@ -1368,6 +1365,16 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-protocol-ts"
version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"ts-rs",
]
[[package]]
name = "codex-responses-api-proxy"
version = "0.0.0"
@@ -1559,7 +1566,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"dirs-next",
"dunce",
"rand 0.8.5",
"serde",
"serde_json",
@@ -2884,6 +2890,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.7.0"
@@ -3527,6 +3542,12 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libm"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
[[package]]
name = "libredox"
version = "0.1.6"
@@ -4293,16 +4314,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "os_pipe"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "owo-colors"
version = "4.2.2"
@@ -4450,7 +4461,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
dependencies = [
"base64",
"indexmap 2.12.0",
"quick-xml 0.38.0",
"quick-xml",
"serde",
"time",
]
@@ -4679,15 +4690,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.0"
@@ -6725,18 +6727,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8"
[[package]]
name = "tree_magic_mini"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c"
dependencies = [
"memchr",
"nom",
"once_cell",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -7074,76 +7064,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.0.8",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [
"bitflags 2.10.0",
"rustix 1.0.8",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
dependencies = [
"proc-macro2",
"quick-xml 0.37.5",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.77"
@@ -7715,25 +7635,6 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "wl-clipboard-rs"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 0.38.44",
"tempfile",
"thiserror 2.0.17",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.2"
@@ -7902,9 +7803,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]

View File

@@ -25,6 +25,7 @@ members = [
"ollama",
"process-hardening",
"protocol",
"protocol-ts",
"rmcp-client",
"responses-api-proxy",
"stdio-to-uds",
@@ -74,6 +75,7 @@ codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-protocol-ts = { path = "protocol-ts" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-rmcp-client = { path = "rmcp-client" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
@@ -94,8 +96,8 @@ mcp_test_support = { path = "mcp-server/tests/common" }
allocative = "0.3.3"
ansi-to-tui = "7.0.0"
anyhow = "1"
arboard = { version = "3", features = ["wayland-data-control"] }
askama = "0.14"
arboard = "3"
askama = "0.12"
assert_cmd = "2"
assert_matches = "1.5.0"
async-channel = "2.3.1"
@@ -211,7 +213,7 @@ which = "6"
wildmatch = "2.5.0"
wiremock = "0.6"
zeroize = "1.8.2"
zeroize = "1.8.1"
[workspace.lints]
rust = {}

View File

@@ -58,7 +58,7 @@ To test to see what happens when a command is run under the sandbox provided by
```
# macOS
codex sandbox macos [--full-auto] [--log-denials] [COMMAND]...
codex sandbox macos [--full-auto] [COMMAND]...
# Linux
codex sandbox linux [--full-auto] [COMMAND]...
@@ -67,7 +67,7 @@ codex sandbox linux [--full-auto] [COMMAND]...
codex sandbox windows [--full-auto] [COMMAND]...
# Legacy aliases
codex debug seatbelt [--full-auto] [--log-denials] [COMMAND]...
codex debug seatbelt [--full-auto] [COMMAND]...
codex debug landlock [--full-auto] [COMMAND]...
```

View File

@@ -92,8 +92,6 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
{
let status = Command::new(prettier_bin)
.arg("--write")
.arg("--log-level")
.arg("warn")
.args(ts_files.iter().map(|p| p.as_os_str()))
.status()
.with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?;
@@ -668,8 +666,6 @@ fn ts_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
Ok(files)
}
/// Generate an index.ts file that re-exports all generated types.
/// This allows consumers to import all types from a single file.
fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
let mut entries: Vec<String> = Vec::new();
let mut stems: Vec<String> = ts_files_in(out_dir)?

View File

@@ -46,7 +46,7 @@ macro_rules! client_request_definitions {
(
$(
$(#[$variant_meta:meta])*
$variant:ident $(=> $wire:literal)? {
$variant:ident {
params: $(#[$params_meta:meta])* $params:ty,
response: $response:ty,
}
@@ -58,7 +58,6 @@ macro_rules! client_request_definitions {
pub enum ClientRequest {
$(
$(#[$variant_meta])*
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
$variant {
#[serde(rename = "id")]
request_id: RequestId,
@@ -102,78 +101,105 @@ macro_rules! client_request_definitions {
}
client_request_definitions! {
Initialize {
params: v1::InitializeParams,
response: v1::InitializeResponse,
},
/// NEW APIs
// Thread lifecycle
ThreadStart => "thread/start" {
#[serde(rename = "thread/start")]
#[ts(rename = "thread/start")]
ThreadStart {
params: v2::ThreadStartParams,
response: v2::ThreadStartResponse,
},
ThreadResume => "thread/resume" {
#[serde(rename = "thread/resume")]
#[ts(rename = "thread/resume")]
ThreadResume {
params: v2::ThreadResumeParams,
response: v2::ThreadResumeResponse,
},
ThreadArchive => "thread/archive" {
#[serde(rename = "thread/archive")]
#[ts(rename = "thread/archive")]
ThreadArchive {
params: v2::ThreadArchiveParams,
response: v2::ThreadArchiveResponse,
},
ThreadList => "thread/list" {
#[serde(rename = "thread/list")]
#[ts(rename = "thread/list")]
ThreadList {
params: v2::ThreadListParams,
response: v2::ThreadListResponse,
},
ThreadCompact => "thread/compact" {
#[serde(rename = "thread/compact")]
#[ts(rename = "thread/compact")]
ThreadCompact {
params: v2::ThreadCompactParams,
response: v2::ThreadCompactResponse,
},
TurnStart => "turn/start" {
#[serde(rename = "turn/start")]
#[ts(rename = "turn/start")]
TurnStart {
params: v2::TurnStartParams,
response: v2::TurnStartResponse,
},
TurnInterrupt => "turn/interrupt" {
#[serde(rename = "turn/interrupt")]
#[ts(rename = "turn/interrupt")]
TurnInterrupt {
params: v2::TurnInterruptParams,
response: v2::TurnInterruptResponse,
},
ModelList => "model/list" {
#[serde(rename = "model/list")]
#[ts(rename = "model/list")]
ModelList {
params: v2::ModelListParams,
response: v2::ModelListResponse,
},
LoginAccount => "account/login/start" {
#[serde(rename = "account/login/start")]
#[ts(rename = "account/login/start")]
LoginAccount {
params: v2::LoginAccountParams,
response: v2::LoginAccountResponse,
},
CancelLoginAccount => "account/login/cancel" {
#[serde(rename = "account/login/cancel")]
#[ts(rename = "account/login/cancel")]
CancelLoginAccount {
params: v2::CancelLoginAccountParams,
response: v2::CancelLoginAccountResponse,
},
LogoutAccount => "account/logout" {
#[serde(rename = "account/logout")]
#[ts(rename = "account/logout")]
LogoutAccount {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
response: v2::LogoutAccountResponse,
},
GetAccountRateLimits => "account/rateLimits/read" {
#[serde(rename = "account/rateLimits/read")]
#[ts(rename = "account/rateLimits/read")]
GetAccountRateLimits {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
response: v2::GetAccountRateLimitsResponse,
},
FeedbackUpload => "feedback/upload" {
#[serde(rename = "feedback/upload")]
#[ts(rename = "feedback/upload")]
FeedbackUpload {
params: v2::FeedbackUploadParams,
response: v2::FeedbackUploadResponse,
},
GetAccount => "account/read" {
#[serde(rename = "account/read")]
#[ts(rename = "account/read")]
GetAccount {
params: v2::GetAccountParams,
response: v2::GetAccountResponse,
},
/// DEPRECATED APIs below
Initialize {
params: v1::InitializeParams,
response: v1::InitializeResponse,
},
NewConversation {
params: v1::NewConversationParams,
response: v1::NewConversationResponse,

View File

@@ -6,8 +6,6 @@ use codex_protocol::ConversationId;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::user_input::UserInput as CoreUserInput;
@@ -459,17 +457,6 @@ impl UserInput {
}
}
impl From<CoreUserInput> for UserInput {
fn from(value: CoreUserInput) -> Self {
match value {
CoreUserInput::Text { text } => UserInput::Text { text },
CoreUserInput::Image { image_url } => UserInput::Image { url: image_url },
CoreUserInput::LocalImage { path } => UserInput::LocalImage { path },
_ => unreachable!("unsupported user input variant"),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
@@ -527,42 +514,6 @@ pub enum ThreadItem {
},
}
impl From<CoreTurnItem> for ThreadItem {
fn from(value: CoreTurnItem) -> Self {
match value {
CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage {
id: user.id,
content: user.content.into_iter().map(UserInput::from).collect(),
},
CoreTurnItem::AgentMessage(agent) => {
let text = agent
.content
.into_iter()
.map(|entry| match entry {
CoreAgentMessageContent::Text { text } => text,
})
.collect::<String>();
ThreadItem::AgentMessage { id: agent.id, text }
}
CoreTurnItem::Reasoning(reasoning) => {
let text = if !reasoning.summary_text.is_empty() {
reasoning.summary_text.join("\n")
} else {
reasoning.raw_content.join("\n")
};
ThreadItem::Reasoning {
id: reasoning.id,
text,
}
}
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch {
id: search.id,
query: search.query,
},
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -757,100 +708,3 @@ pub struct AccountLoginCompletedNotification {
pub success: bool,
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::user_input::UserInput as CoreUserInput;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn core_turn_item_into_thread_item_converts_supported_variants() {
let user_item = TurnItem::UserMessage(UserMessageItem {
id: "user-1".to_string(),
content: vec![
CoreUserInput::Text {
text: "hello".to_string(),
},
CoreUserInput::Image {
image_url: "https://example.com/image.png".to_string(),
},
CoreUserInput::LocalImage {
path: PathBuf::from("local/image.png"),
},
],
});
assert_eq!(
ThreadItem::from(user_item),
ThreadItem::UserMessage {
id: "user-1".to_string(),
content: vec![
UserInput::Text {
text: "hello".to_string(),
},
UserInput::Image {
url: "https://example.com/image.png".to_string(),
},
UserInput::LocalImage {
path: PathBuf::from("local/image.png"),
},
],
}
);
let agent_item = TurnItem::AgentMessage(AgentMessageItem {
id: "agent-1".to_string(),
content: vec![
AgentMessageContent::Text {
text: "Hello ".to_string(),
},
AgentMessageContent::Text {
text: "world".to_string(),
},
],
});
assert_eq!(
ThreadItem::from(agent_item),
ThreadItem::AgentMessage {
id: "agent-1".to_string(),
text: "Hello world".to_string(),
}
);
let reasoning_item = TurnItem::Reasoning(ReasoningItem {
id: "reasoning-1".to_string(),
summary_text: vec!["line one".to_string(), "line two".to_string()],
raw_content: vec![],
});
assert_eq!(
ThreadItem::from(reasoning_item),
ThreadItem::Reasoning {
id: "reasoning-1".to_string(),
text: "line one\nline two".to_string(),
}
);
let search_item = TurnItem::WebSearch(WebSearchItem {
id: "search-1".to_string(),
query: "docs".to_string(),
});
assert_eq!(
ThreadItem::from(search_item),
ThreadItem::WebSearch {
id: "search-1".to_string(),
query: "docs".to_string(),
}
);
}
}

View File

@@ -8,11 +8,10 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
## Message Schema
Currently, you can dump a TypeScript version of the schema using `codex app-server generate-ts`, or a JSON Schema bundle via `codex app-server generate-json-schema`. Each output is specific to the version of Codex you used to run the command, so the generated artifacts are guaranteed to match that version.
Currently, you can dump a TypeScript version of the schema using `codex generate-ts`. It is specific to the version of Codex you used to run `generate-ts`, so the two are guaranteed to be compatible.
```
codex app-server generate-ts --out DIR
codex app-server generate-json-schema --out DIR
codex generate-ts --out DIR
```
## Initialization
@@ -50,16 +49,15 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr
### 1) Start or resume a thread
Start a fresh thread when you need a new Codex conversation.
Start a fresh thread when you need a new Codex conversation. Optional fields mirror CLI defaults: set `model`, `modelProvider`, `cwd`, `approvalPolicy`, `sandbox`, or custom `config` values. Instructions can be set via `baseInstructions` and `developerInstructions`:
```json
{ "method": "thread/start", "id": 10, "params": {
// Optionally set config settings. If not specified, will use the user's
// current config settings.
"model": "gpt-5-codex",
"cwd": "/Users/me/project",
"approvalPolicy": "never",
"sandbox": "workspaceWrite",
"sandbox": "workspace-write",
"baseInstructions": "You're helping with refactors."
} }
{ "id": 10, "result": {
"thread": {
@@ -92,6 +90,7 @@ Example:
{ "method": "thread/list", "id": 20, "params": {
"cursor": null,
"limit": 25,
"modelProviders": ["openai"]
} }
{ "id": 20, "result": {
"data": [
@@ -123,23 +122,18 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
- `{"type":"image","url":"https://…png"}`
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread.
Override knobs apply to the new turn and become the defaults for subsequent turns on the same thread:
```json
{ "method": "turn/start", "id": 30, "params": {
"threadId": "thr_123",
"input": [ { "type": "text", "text": "Run tests" } ],
// Below are optional config overrides
"cwd": "/Users/me/project",
"approvalPolicy": "unlessTrusted",
"sandboxPolicy": {
"mode": "workspaceWrite",
"writableRoots": ["/Users/me/project"],
"networkAccess": true
},
"approvalPolicy": "untrusted",
"sandboxPolicy": "workspace-write",
"model": "gpt-5-codex",
"effort": "medium",
"summary": "concise"
"summary": "focus-on-test-failures"
} }
{ "id": 30, "result": { "turn": {
"id": "turn_456",
@@ -165,7 +159,7 @@ The server requests cancellations for running subprocesses, then emits a `turn/c
## Auth endpoints
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
The v2 JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
### Quick reference
- `account/read` — fetch current account info; optionally refresh tokens.
@@ -255,6 +249,5 @@ Field notes:
### Dev notes
- `codex app-server generate-ts --out <dir>` emits v2 types under `v2/`.
- `codex app-server generate-json-schema --out <dir>` outputs `codex_app_server_protocol.schemas.json`.
- `codex generate-ts --out <dir>` emits v2 types under `v2/`.
- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs.

View File

@@ -46,8 +46,6 @@ use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::InputItem as WireInputItem;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::InterruptConversationResponse;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::ListConversationsResponse;
@@ -200,30 +198,6 @@ enum ApiVersion {
}
impl CodexMessageProcessor {
async fn conversation_from_thread_id(
&self,
thread_id: &str,
) -> Result<(ConversationId, Arc<CodexConversation>), JSONRPCErrorError> {
// Resolve conversation id from v2 thread id string.
let conversation_id =
ConversationId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let conversation = self
.conversation_manager
.get_conversation(conversation_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
})?;
Ok((conversation_id, conversation))
}
pub fn new(
auth_manager: Arc<AuthManager>,
conversation_manager: Arc<ConversationManager>,
@@ -2171,14 +2145,34 @@ impl CodexMessageProcessor {
}
async fn turn_start(&self, request_id: RequestId, params: TurnStartParams) {
let (_, conversation) = match self.conversation_from_thread_id(&params.thread_id).await {
Ok(v) => v,
Err(error) => {
// Resolve conversation id from v2 thread id string.
let conversation_id = match ConversationId::from_string(&params.thread_id) {
Ok(id) => id,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
// Keep a copy of v2 inputs for the notification payload.
let v2_inputs_for_notif = params.input.clone();
@@ -2252,14 +2246,33 @@ impl CodexMessageProcessor {
async fn turn_interrupt(&mut self, request_id: RequestId, params: TurnInterruptParams) {
let TurnInterruptParams { thread_id, .. } = params;
let (conversation_id, conversation) =
match self.conversation_from_thread_id(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
// Resolve conversation id from v2 thread id string.
let conversation_id = match ConversationId::from_string(&thread_id) {
Ok(id) => id,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
.await
else {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
};
// Record the pending interrupt so we can reply when TurnAborted arrives.
{
@@ -2611,20 +2624,6 @@ async fn apply_bespoke_event_handling(
.await;
}
}
EventMsg::ItemStarted(item_started_event) => {
let item: ThreadItem = item_started_event.item.clone().into();
let notification = ItemStartedNotification { item };
outgoing
.send_server_notification(ServerNotification::ItemStarted(notification))
.await;
}
EventMsg::ItemCompleted(item_completed_event) => {
let item: ThreadItem = item_completed_event.item.clone().into();
let notification = ItemCompletedNotification { item };
outgoing
.send_server_notification(ServerNotification::ItemCompleted(notification))
.await;
}
// If this is a TurnAborted, reply to any pending interrupt requests.
EventMsg::TurnAborted(turn_aborted_event) => {
let pending = {

View File

@@ -11,7 +11,32 @@ const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
const APPLY_PATCH_ARG0: &str = "apply_patch";
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
pub fn arg0_dispatch() -> Option<TempDir> {
/// While we want to deploy the Codex CLI as a single executable for simplicity,
/// we also want to expose some of its functionality as distinct CLIs, so we use
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
/// us to simulate deploying multiple executables as a single binary on Mac and
/// Linux (but not Windows).
///
/// When the current executable is invoked through the hard-link or alias named
/// `codex-linux-sandbox` we *directly* execute
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
/// error. Note that `main_fn` receives `codex_linux_sandbox_exe:
/// Option<PathBuf>`, as an argument, which is generally needed as part of
/// constructing [`codex_core::config::Config`].
///
/// This function should be used to wrap any `main()` function in binary crates
/// in this workspace that depends on these helper CLIs.
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
// Determine if we were invoked via the special alias.
let mut args = std::env::args_os();
let argv0 = args.next().unwrap_or_default();
@@ -51,7 +76,10 @@ pub fn arg0_dispatch() -> Option<TempDir> {
// before creating any threads/the Tokio runtime.
load_dotenv();
match prepend_path_entry_for_codex_aliases() {
// Retain the TempDir so it exists for the lifetime of the invocation of
// this executable. Admittedly, we could invoke `keep()` on it, but it
// would be nice to avoid leaving temporary directories behind, if possible.
let _path_entry = match prepend_path_entry_for_apply_patch() {
Ok(path_entry) => Some(path_entry),
Err(err) => {
// It is possible that Codex will proceed successfully even if
@@ -59,39 +87,7 @@ pub fn arg0_dispatch() -> Option<TempDir> {
eprintln!("WARNING: proceeding, even though we could not update PATH: {err}");
None
}
}
}
/// While we want to deploy the Codex CLI as a single executable for simplicity,
/// we also want to expose some of its functionality as distinct CLIs, so we use
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
/// us to simulate deploying multiple executables as a single binary on Mac and
/// Linux (but not Windows).
///
/// When the current executable is invoked through the hard-link or alias named
/// `codex-linux-sandbox` we *directly* execute
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
/// error. Note that `main_fn` receives `codex_linux_sandbox_exe:
/// Option<PathBuf>`, as an argument, which is generally needed as part of
/// constructing [`codex_core::config::Config`].
///
/// This function should be used to wrap any `main()` function in binary crates
/// in this workspace that depends on these helper CLIs.
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
// Retain the TempDir so it exists for the lifetime of the invocation of
// this executable. Admittedly, we could invoke `keep()` on it, but it
// would be nice to avoid leaving temporary directories behind, if possible.
let _path_entry = arg0_dispatch();
};
// Regular invocation create a Tokio runtime and execute the provided
// async entry-point.
@@ -148,16 +144,11 @@ where
///
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
/// be called before multiple threads are spawned.
pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<TempDir> {
fn prepend_path_entry_for_apply_patch() -> std::io::Result<TempDir> {
let temp_dir = TempDir::new()?;
let path = temp_dir.path();
for filename in &[
APPLY_PATCH_ARG0,
MISSPELLED_APPLY_PATCH_ARG0,
#[cfg(target_os = "linux")]
LINUX_SANDBOX_ARG0,
] {
for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] {
let exe = std::env::current_exe()?;
#[cfg(unix)]

View File

@@ -30,14 +30,13 @@ codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-stdio-to-uds = { workspace = true }
codex-tui = { workspace = true }
ctor = { workspace = true }
libc = { workspace = true }
owo-colors = { workspace = true }
regex-lite = { workspace = true}
serde_json = { workspace = true }
supports-color = { workspace = true }
toml = { workspace = true }
@@ -48,7 +47,6 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tracing = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }

View File

@@ -1,8 +1,3 @@
#[cfg(target_os = "macos")]
mod pid_tracker;
#[cfg(target_os = "macos")]
mod seatbelt;
use std::path::PathBuf;
use codex_common::CliConfigOverrides;
@@ -20,9 +15,6 @@ use crate::SeatbeltCommand;
use crate::WindowsCommand;
use crate::exit_status::handle_exit_status;
#[cfg(target_os = "macos")]
use seatbelt::DenialLogger;
#[cfg(target_os = "macos")]
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
@@ -30,7 +22,6 @@ pub async fn run_command_under_seatbelt(
) -> anyhow::Result<()> {
let SeatbeltCommand {
full_auto,
log_denials,
config_overrides,
command,
} = command;
@@ -40,7 +31,6 @@ pub async fn run_command_under_seatbelt(
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Seatbelt,
log_denials,
)
.await
}
@@ -68,7 +58,6 @@ pub async fn run_command_under_landlock(
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Landlock,
false,
)
.await
}
@@ -88,7 +77,6 @@ pub async fn run_command_under_windows(
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Windows,
false,
)
.await
}
@@ -106,7 +94,6 @@ async fn run_command_under_sandbox(
config_overrides: CliConfigOverrides,
codex_linux_sandbox_exe: Option<PathBuf>,
sandbox_type: SandboxType,
log_denials: bool,
) -> anyhow::Result<()> {
let sandbox_mode = create_sandbox_mode(full_auto);
let config = Config::load_with_cli_overrides(
@@ -149,8 +136,6 @@ async fn run_command_under_sandbox(
let env_map = env.clone();
let command_vec = command.clone();
let base_dir = config.codex_home.clone();
// Preflight audit is invoked elsewhere at the appropriate times.
let res = tokio::task::spawn_blocking(move || {
run_windows_sandbox_capture(
policy_str,
@@ -193,11 +178,6 @@ async fn run_command_under_sandbox(
}
}
#[cfg(target_os = "macos")]
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
#[cfg(not(target_os = "macos"))]
let _ = log_denials;
let mut child = match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => {
@@ -231,27 +211,8 @@ async fn run_command_under_sandbox(
unreachable!("Windows sandbox should have been handled above");
}
};
#[cfg(target_os = "macos")]
if let Some(denial_logger) = &mut denial_logger {
denial_logger.on_child_spawn(&child);
}
let status = child.wait().await?;
#[cfg(target_os = "macos")]
if let Some(denial_logger) = denial_logger {
let denials = denial_logger.finish().await;
eprintln!("\n=== Sandbox denials ===");
if denials.is_empty() {
eprintln!("None found.");
} else {
for seatbelt::SandboxDenial { name, capability } in denials {
eprintln!("({name}) {capability}");
}
}
}
handle_exit_status(status);
}

View File

@@ -1,372 +0,0 @@
use std::collections::HashSet;
use tokio::task::JoinHandle;
use tracing::warn;
/// Tracks the (recursive) descendants of a process by using `kqueue` to watch for fork events, and
/// `proc_listchildpids` to list the children of a process.
pub(crate) struct PidTracker {
kq: libc::c_int,
handle: JoinHandle<HashSet<i32>>,
}
impl PidTracker {
pub(crate) fn new(root_pid: i32) -> Option<Self> {
if root_pid <= 0 {
return None;
}
let kq = unsafe { libc::kqueue() };
let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid));
Some(Self { kq, handle })
}
pub(crate) async fn stop(self) -> HashSet<i32> {
trigger_stop_event(self.kq);
self.handle.await.unwrap_or_default()
}
}
unsafe extern "C" {
fn proc_listchildpids(
ppid: libc::c_int,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}
/// Wrap proc_listchildpids.
fn list_child_pids(parent: i32) -> Vec<i32> {
unsafe {
let mut capacity: usize = 16;
loop {
let mut buf: Vec<i32> = vec![0; capacity];
let count = proc_listchildpids(
parent as libc::c_int,
buf.as_mut_ptr() as *mut libc::c_void,
(buf.len() * std::mem::size_of::<i32>()) as libc::c_int,
);
if count <= 0 {
return Vec::new();
}
let returned = count as usize;
if returned < capacity {
buf.truncate(returned);
return buf;
}
capacity = capacity.saturating_mul(2).max(returned + 16);
}
}
}
fn pid_is_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
let res = unsafe { libc::kill(pid as libc::pid_t, 0) };
if res == 0 {
true
} else {
matches!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::EPERM)
)
}
}
enum WatchPidError {
ProcessGone,
Other(std::io::Error),
}
/// Add `pid` to the watch list in `kq`.
fn watch_pid(kq: libc::c_int, pid: i32) -> Result<(), WatchPidError> {
if pid <= 0 {
return Err(WatchPidError::ProcessGone);
}
let kev = libc::kevent {
ident: pid as libc::uintptr_t,
filter: libc::EVFILT_PROC,
flags: libc::EV_ADD | libc::EV_CLEAR,
fflags: libc::NOTE_FORK | libc::NOTE_EXEC | libc::NOTE_EXIT,
data: 0,
udata: std::ptr::null_mut(),
};
let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
if res < 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
Err(WatchPidError::ProcessGone)
} else {
Err(WatchPidError::Other(err))
}
} else {
Ok(())
}
}
fn watch_children(
kq: libc::c_int,
parent: i32,
seen: &mut HashSet<i32>,
active: &mut HashSet<i32>,
) {
for child_pid in list_child_pids(parent) {
add_pid_watch(kq, child_pid, seen, active);
}
}
/// Watch `pid` and its children, updating `seen` and `active` sets.
fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet<i32>, active: &mut HashSet<i32>) {
if pid <= 0 {
return;
}
let newly_seen = seen.insert(pid);
let mut should_recurse = newly_seen;
if active.insert(pid) {
match watch_pid(kq, pid) {
Ok(()) => {
should_recurse = true;
}
Err(WatchPidError::ProcessGone) => {
active.remove(&pid);
return;
}
Err(WatchPidError::Other(err)) => {
warn!("failed to watch pid {pid}: {err}");
active.remove(&pid);
return;
}
}
}
if should_recurse {
watch_children(kq, pid, seen, active);
}
}
const STOP_IDENT: libc::uintptr_t = 1;
fn register_stop_event(kq: libc::c_int) -> bool {
let kev = libc::kevent {
ident: STOP_IDENT,
filter: libc::EVFILT_USER,
flags: libc::EV_ADD | libc::EV_CLEAR,
fflags: 0,
data: 0,
udata: std::ptr::null_mut(),
};
let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
res >= 0
}
fn trigger_stop_event(kq: libc::c_int) {
if kq < 0 {
return;
}
let kev = libc::kevent {
ident: STOP_IDENT,
filter: libc::EVFILT_USER,
flags: 0,
fflags: libc::NOTE_TRIGGER,
data: 0,
udata: std::ptr::null_mut(),
};
let _ = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
}
/// Put all of the above together to track all the descendants of `root_pid`.
fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
if kq < 0 {
let mut seen = HashSet::new();
seen.insert(root_pid);
return seen;
}
if !register_stop_event(kq) {
let mut seen = HashSet::new();
seen.insert(root_pid);
let _ = unsafe { libc::close(kq) };
return seen;
}
let mut seen: HashSet<i32> = HashSet::new();
let mut active: HashSet<i32> = HashSet::new();
add_pid_watch(kq, root_pid, &mut seen, &mut active);
const EVENTS_CAP: usize = 32;
let mut events: [libc::kevent; EVENTS_CAP] =
unsafe { std::mem::MaybeUninit::zeroed().assume_init() };
let mut stop_requested = false;
loop {
if active.is_empty() {
if !pid_is_alive(root_pid) {
break;
}
add_pid_watch(kq, root_pid, &mut seen, &mut active);
if active.is_empty() {
continue;
}
}
let nev = unsafe {
libc::kevent(
kq,
std::ptr::null::<libc::kevent>(),
0,
events.as_mut_ptr(),
EVENTS_CAP as libc::c_int,
std::ptr::null(),
)
};
if nev < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
break;
}
if nev == 0 {
continue;
}
for ev in events.iter().take(nev as usize) {
let pid = ev.ident as i32;
if ev.filter == libc::EVFILT_USER && ev.ident == STOP_IDENT {
stop_requested = true;
break;
}
if (ev.flags & libc::EV_ERROR) != 0 {
if ev.data == libc::ESRCH as isize {
active.remove(&pid);
}
continue;
}
if (ev.fflags & libc::NOTE_FORK) != 0 {
watch_children(kq, pid, &mut seen, &mut active);
}
if (ev.fflags & libc::NOTE_EXIT) != 0 {
active.remove(&pid);
}
}
if stop_requested {
break;
}
}
let _ = unsafe { libc::close(kq) };
seen
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
#[test]
fn pid_is_alive_detects_current_process() {
let pid = std::process::id() as i32;
assert!(pid_is_alive(pid));
}
#[cfg(target_os = "macos")]
#[test]
fn list_child_pids_includes_spawned_child() {
let mut child = Command::new("/bin/sleep")
.arg("5")
.stdin(Stdio::null())
.spawn()
.expect("failed to spawn child process");
let child_pid = child.id() as i32;
let parent_pid = std::process::id() as i32;
let mut found = false;
for _ in 0..100 {
if list_child_pids(parent_pid).contains(&child_pid) {
found = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let _ = child.kill();
let _ = child.wait();
assert!(found, "expected to find child pid {child_pid} in list");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn pid_tracker_collects_spawned_children() {
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
let mut child = Command::new("/bin/sleep")
.arg("0.1")
.stdin(Stdio::null())
.spawn()
.expect("failed to spawn child process");
let child_pid = child.id() as i32;
let parent_pid = std::process::id() as i32;
let _ = child.wait();
let seen = tracker.stop().await;
assert!(
seen.contains(&parent_pid),
"expected tracker to include parent pid {parent_pid}"
);
assert!(
seen.contains(&child_pid),
"expected tracker to include child pid {child_pid}"
);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn pid_tracker_collects_bash_subshell_descendants() {
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
let child = Command::new("/bin/bash")
.arg("-c")
.arg("(sleep 0.1 & echo $!; wait)")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn bash");
let output = child.wait_with_output().unwrap().stdout;
let subshell_pid = String::from_utf8_lossy(&output)
.trim()
.parse::<i32>()
.expect("failed to parse subshell pid");
let seen = tracker.stop().await;
assert!(
seen.contains(&subshell_pid),
"expected tracker to include subshell pid {subshell_pid}"
);
}
}

View File

@@ -1,114 +0,0 @@
use std::collections::HashSet;
use tokio::io::AsyncBufReadExt;
use tokio::process::Child;
use tokio::task::JoinHandle;
use super::pid_tracker::PidTracker;
pub struct SandboxDenial {
pub name: String,
pub capability: String,
}
pub struct DenialLogger {
log_stream: Child,
pid_tracker: Option<PidTracker>,
log_reader: Option<JoinHandle<Vec<u8>>>,
}
impl DenialLogger {
pub(crate) fn new() -> Option<Self> {
let mut log_stream = start_log_stream()?;
let stdout = log_stream.stdout.take()?;
let log_reader = tokio::spawn(async move {
let mut reader = tokio::io::BufReader::new(stdout);
let mut logs = Vec::new();
let mut chunk = Vec::new();
loop {
match reader.read_until(b'\n', &mut chunk).await {
Ok(0) | Err(_) => break,
Ok(_) => {
logs.extend_from_slice(&chunk);
chunk.clear();
}
}
}
logs
});
Some(Self {
log_stream,
pid_tracker: None,
log_reader: Some(log_reader),
})
}
pub(crate) fn on_child_spawn(&mut self, child: &Child) {
if let Some(root_pid) = child.id() {
self.pid_tracker = PidTracker::new(root_pid as i32);
}
}
pub(crate) async fn finish(mut self) -> Vec<SandboxDenial> {
let pid_set = match self.pid_tracker {
Some(tracker) => tracker.stop().await,
None => Default::default(),
};
if pid_set.is_empty() {
return Vec::new();
}
let _ = self.log_stream.kill().await;
let _ = self.log_stream.wait().await;
let logs_bytes = match self.log_reader.take() {
Some(handle) => handle.await.unwrap_or_default(),
None => Vec::new(),
};
let logs = String::from_utf8_lossy(&logs_bytes);
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut denials: Vec<SandboxDenial> = Vec::new();
for line in logs.lines() {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
&& let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str())
&& let Some((pid, name, capability)) = parse_message(msg)
&& pid_set.contains(&pid)
&& seen.insert((name.clone(), capability.clone()))
{
denials.push(SandboxDenial { name, capability });
}
}
denials
}
}
fn start_log_stream() -> Option<Child> {
use std::process::Stdio;
const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#;
tokio::process::Command::new("log")
.args(["stream", "--style", "ndjson", "--predicate", PREDICATE])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.ok()
}
fn parse_message(msg: &str) -> Option<(i32, String, String)> {
// Example message:
// Sandbox: processname(1234) deny(1) capability-name args...
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| {
#[expect(clippy::unwrap_used)]
regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap()
});
let (_, [name, pid_str, capability]) = re.captures(msg)?.extract();
let pid = pid_str.trim().parse::<i32>().ok()?;
Some((pid, name.to_string(), capability.to_string()))
}

View File

@@ -11,10 +11,6 @@ pub struct SeatbeltCommand {
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit
#[arg(long = "log-denials", default_value_t = false)]
pub log_denials: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,

View File

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

View File

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

View File

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

View File

@@ -19,8 +19,8 @@ use toml::Value;
pub struct CliConfigOverrides {
/// Override a configuration value that would otherwise be loaded from
/// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
/// nested values. The `value` portion is parsed as TOML. If it fails to
/// parse as TOML, the raw string is used as a literal.
/// nested values. The `value` portion is parsed as JSON. If it fails to
/// parse as JSON, the raw string is used as a literal.
///
/// Examples:
/// - `-c model="o3"`
@@ -59,7 +59,7 @@ impl CliConfigOverrides {
return Err(format!("Empty key in override: {s}"));
}
// Attempt to parse as TOML. If that fails, treat it as a raw
// Attempt to parse as JSON. If that fails, treat it as a raw
// string. This allows convenient usage such as
// `-c model=o3` without the quotes.
let value: Value = match parse_toml_value(value_str) {
@@ -151,15 +151,6 @@ mod tests {
assert_eq!(v.as_integer(), Some(42));
}
#[test]
fn parses_bool() {
let true_literal = parse_toml_value("true").expect("parse");
assert_eq!(true_literal.as_bool(), Some(true));
let false_literal = parse_toml_value("false").expect("parse");
assert_eq!(false_literal.as_bool(), Some(false));
}
#[test]
fn fails_on_unquoted_string() {
assert!(parse_toml_value("hello").is_err());

View File

@@ -32,7 +32,6 @@ 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 }
@@ -84,6 +83,7 @@ tree-sitter-bash = { workspace = true }
uuid = { workspace = true, features = ["serde", "v4", "v5"] }
which = { workspace = true }
wildmatch = { workspace = true }
codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
[target.'cfg(target_os = "linux")'.dependencies]
@@ -104,9 +104,7 @@ openssl-sys = { workspace = true, features = ["vendored"] }
[dev-dependencies]
assert_cmd = { workspace = true }
assert_matches = { workspace = true }
codex-arg0 = { workspace = true }
core_test_support = { workspace = true }
ctor = { workspace = true }
escargot = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
maplit = { workspace = true }

View File

@@ -88,33 +88,17 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<V
Some(commands)
}
pub fn is_well_known_sh_shell(shell: &str) -> bool {
if shell == "bash" || shell == "zsh" {
return true;
}
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(shell);
matches!(shell_name, "bash" | "zsh")
}
pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
let [shell, flag, script] = command else {
return None;
};
if !matches!(flag.as_str(), "-lc" | "-c") || !is_well_known_sh_shell(shell) {
return None;
}
Some((shell, script))
}
/// Returns the sequence of plain commands within a `bash -lc "..."` or
/// `zsh -lc "..."` invocation when the script only contains word-only commands
/// joined by safe operators.
pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
let (_, script) = extract_bash_command(command)?;
let [shell, flag, script] = command else {
return None;
};
if flag != "-lc" || !(shell == "bash" || shell == "zsh") {
return None;
}
let tree = try_parse_shell(script)?;
try_parse_word_only_commands_sequence(&tree, script)

View File

@@ -6,7 +6,6 @@ use std::sync::atomic::AtomicU64;
use crate::AuthManager;
use crate::client_common::REVIEW_PROMPT;
use crate::compact;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::mcp::auth::McpAuthStatusEntry;
@@ -67,8 +66,6 @@ use crate::error::Result as CodexResult;
use crate::exec::StreamOutput;
// Removed: legacy executor wiring replaced by ToolOrchestrator flows.
// legacy normalize_exec_result no longer used after orchestrator migration
use crate::compact::build_compacted_history;
use crate::compact::collect_user_messages;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_family::find_family_for_model;
@@ -97,7 +94,6 @@ use crate::protocol::Submission;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WarningEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
use crate::shell;
@@ -133,6 +129,10 @@ use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness;
use codex_utils_readiness::ReadinessFlag;
pub mod compact;
use self::compact::build_compacted_history;
use self::compact::collect_user_messages;
/// The high-level interface to the Codex system.
/// It operates as a queue pair where you send submissions and receive events.
pub struct Codex {
@@ -675,34 +675,6 @@ impl Session {
let rollout_items = conversation_history.get_rollout_items();
let persist = matches!(conversation_history, InitialHistory::Forked(_));
// If resuming, warn when the last recorded model differs from the current one.
if let InitialHistory::Resumed(_) = conversation_history
&& let Some(prev) = rollout_items.iter().rev().find_map(|it| {
if let RolloutItem::TurnContext(ctx) = it {
Some(ctx.model.as_str())
} else {
None
}
})
{
let curr = turn_context.client.get_model();
if prev != curr {
warn!(
"resuming session with different model: previous={prev}, current={curr}"
);
self.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: format!(
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
Consider switching back to `{prev}` as it may affect Codex performance."
),
}),
)
.await;
}
}
// Always add response items to conversation history
let reconstructed_history =
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);
@@ -996,7 +968,7 @@ impl Session {
}
/// Append ResponseItems to the in-memory conversation history only.
pub(crate) async fn record_into_history(&self, items: &[ResponseItem]) {
async fn record_into_history(&self, items: &[ResponseItem]) {
let mut state = self.state.lock().await;
state.record_items(items.iter());
}
@@ -1048,7 +1020,7 @@ impl Session {
items
}
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) {
async fn persist_rollout_items(&self, items: &[RolloutItem]) {
let recorder = {
let guard = self.services.rollout.lock().await;
guard.clone()
@@ -1065,7 +1037,7 @@ impl Session {
state.clone_history()
}
pub(crate) async fn update_token_usage_info(
async fn update_token_usage_info(
&self,
turn_context: &TurnContext,
token_usage: Option<&TokenUsage>,
@@ -1082,7 +1054,7 @@ impl Session {
self.send_token_count_event(turn_context).await;
}
pub(crate) async fn update_rate_limits(
async fn update_rate_limits(
&self,
turn_context: &TurnContext,
new_rate_limits: RateLimitSnapshot,
@@ -1103,7 +1075,7 @@ impl Session {
self.send_event(turn_context, event).await;
}
pub(crate) async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
let context_window = turn_context.client.get_model_context_window();
if let Some(context_window) = context_window {
{
@@ -1146,11 +1118,7 @@ impl Session {
self.send_event(turn_context, event).await;
}
pub(crate) async fn notify_stream_error(
&self,
turn_context: &TurnContext,
message: impl Into<String>,
) {
async fn notify_stream_error(&self, turn_context: &TurnContext, message: impl Into<String>) {
let event = EventMsg::StreamError(StreamErrorEvent {
message: message.into(),
});
@@ -2352,7 +2320,6 @@ mod tests {
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_app_server_protocol::AuthMode;
@@ -3092,48 +3059,6 @@ mod tests {
assert!(exec_output.output.contains("hi"));
}
#[tokio::test]
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
use crate::protocol::AskForApproval;
use crate::turn_diff_tracker::TurnDiffTracker;
let (session, mut turn_context_raw) = make_session_and_context();
turn_context_raw.approval_policy = AskForApproval::OnFailure;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let handler = UnifiedExecHandler;
let resp = handler
.handle(ToolInvocation {
session: Arc::clone(&session),
turn: Arc::clone(&turn_context),
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",
"with_escalated_permissions": true,
"justification": "need unsandboxed execution",
})
.to_string(),
},
})
.await;
let Err(FunctionCallError::RespondToModel(output)) = resp else {
panic!("expected error result");
};
let expected = format!(
"approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}",
policy = turn_context.approval_policy
);
pretty_assertions::assert_eq!(output, expected);
}
#[test]
fn mcp_init_error_display_prompts_for_github_pat() {
let server_name = "github";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -727,51 +727,4 @@ mod tests {
let output = make_exec_output(exit_code, "", "", "");
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
}
#[cfg(unix)]
#[tokio::test]
async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> {
let command = vec![
"/bin/bash".to_string(),
"-c".to_string(),
"sleep 60 & echo $!; sleep 60".to_string(),
];
let env: HashMap<String, String> = std::env::vars().collect();
let params = ExecParams {
command,
cwd: std::env::current_dir()?,
timeout_ms: Some(500),
env,
with_escalated_permissions: None,
justification: None,
arg0: None,
};
let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?;
assert!(output.timed_out);
let stdout = output.stdout.from_utf8_lossy().text;
let pid_line = stdout.lines().next().unwrap_or("").trim();
let pid: i32 = pid_line.parse().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to parse pid from stdout '{pid_line}': {error}"),
)
})?;
let mut killed = false;
for _ in 0..20 {
// Use kill(pid, 0) to check if the process is alive.
if unsafe { libc::kill(pid, 0) } == -1
&& let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error()
{
killed = true;
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(killed, "grandchild process with pid {pid} is still alive");
Ok(())
}
}

View File

@@ -29,9 +29,6 @@ pub enum Stage {
pub enum Feature {
/// 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.
@@ -253,12 +250,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",
@@ -293,7 +284,7 @@ pub const FEATURES: &[FeatureSpec] = &[
id: Feature::GhostCommit,
key: "ghost_commit",
stage: Stage::Experimental,
default_enabled: true,
default_enabled: false,
},
FeatureSpec {
id: Feature::WindowsSandbox,

View File

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

View File

@@ -1,4 +1,3 @@
use crate::bash::extract_bash_command;
use crate::bash::try_parse_shell;
use crate::bash::try_parse_word_only_commands_sequence;
use codex_protocol::parse_command::ParsedCommand;
@@ -854,29 +853,6 @@ mod tests {
}],
);
}
#[test]
fn bin_bash_lc_sed() {
assert_parsed(
&shlex_split_safe("/bin/bash -lc 'sed -n '1,10p' Cargo.toml'"),
vec![ParsedCommand::Read {
cmd: "sed -n '1,10p' Cargo.toml".to_string(),
name: "Cargo.toml".to_string(),
path: PathBuf::from("Cargo.toml"),
}],
);
}
#[test]
fn bin_zsh_lc_sed() {
assert_parsed(
&shlex_split_safe("/bin/zsh -lc 'sed -n '1,10p' Cargo.toml'"),
vec![ParsedCommand::Read {
cmd: "sed -n '1,10p' Cargo.toml".to_string(),
name: "Cargo.toml".to_string(),
path: PathBuf::from("Cargo.toml"),
}],
);
}
}
pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
@@ -1190,13 +1166,18 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
}
fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
let (_, script) = extract_bash_command(original)?;
let [shell, flag, script] = original else {
return None;
};
if flag != "-lc" || !(shell == "bash" || shell == "zsh") {
return None;
}
if let Some(tree) = try_parse_shell(script)
&& let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script)
&& !all_commands.is_empty()
{
let script_tokens = shlex_split(script).unwrap_or_else(|| vec![script.to_string()]);
let script_tokens = shlex_split(script)
.unwrap_or_else(|| vec![shell.clone(), flag.clone(), script.clone()]);
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
// bias toward the primary command when pipelines are present.
// First, drop obvious small formatting helpers (e.g., wc/awk/etc).
@@ -1205,7 +1186,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
let filtered_commands = drop_small_formatting_commands(all_commands);
if filtered_commands.is_empty() {
return Some(vec![ParsedCommand::Unknown {
cmd: script.to_string(),
cmd: script.clone(),
}]);
}
// Build parsed commands, tracking `cd` segments to compute effective file paths.
@@ -1269,7 +1250,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
});
if has_pipe && has_sed_n {
ParsedCommand::Read {
cmd: script.to_string(),
cmd: script.clone(),
name,
path,
}
@@ -1314,7 +1295,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
return Some(commands);
}
Some(vec![ParsedCommand::Unknown {
cmd: script.to_string(),
cmd: script.clone(),
}])
}

View File

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

View File

@@ -31,37 +31,16 @@ pub enum Shell {
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::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::PowerShell(ps) => Some(ps.exe.clone()),
Shell::Unknown => None,
}
}
/// 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, .. }) => {
let arg = if use_login_shell { "-lc" } else { "-c" };
vec![shell_path.clone(), arg.to_string(), command.to_string()]
}
Shell::PowerShell(ps) => {
let mut args = vec![ps.exe.clone(), "-NoLogo".to_string()];
if !use_login_shell {
args.push("-NoProfile".to_string());
}
args.push("-Command".to_string());
args.push(command.to_string());
args
}
Shell::Unknown => shlex::split(command).unwrap_or_else(|| vec![command.to_string()]),
}
}
}
#[cfg(unix)]

View File

@@ -66,9 +66,8 @@ pub(crate) async fn spawn_child_async(
#[cfg(unix)]
unsafe {
#[cfg(target_os = "linux")]
let parent_pid = libc::getpid();
cmd.pre_exec(move || {
cmd.pre_exec(|| {
if libc::setpgid(0, 0) == -1 {
return Err(std::io::Error::last_os_error());
}

View File

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

View File

@@ -1,35 +1,28 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::user_input::UserInput;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tracing::error;
use uuid::Uuid;
use crate::codex::TurnContext;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::execute_exec_env;
use crate::exec_env::create_env;
use crate::parse_command::parse_command;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::SandboxPolicy;
use crate::protocol::TaskStartedEvent;
use crate::sandboxing::ExecEnv;
use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
use crate::user_shell_command::user_shell_command_record_item;
use crate::tools::context::ToolPayload;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolRouter;
use crate::turn_diff_tracker::TurnDiffTracker;
use super::SessionTask;
use super::SessionTaskContext;
const USER_SHELL_TOOL_NAME: &str = "local_shell";
#[derive(Clone)]
pub(crate) struct UserShellCommandTask {
command: String,
@@ -63,131 +56,56 @@ impl SessionTask for UserShellCommandTask {
// Execute the user's script under their default shell when known; this
// allows commands that use shell features (pipes, &&, redirects, etc.).
// We do not source rc files or otherwise reformat the script.
let use_login_shell = true;
let shell_invocation = session
.user_shell()
.derive_exec_args(&self.command, use_login_shell);
let call_id = Uuid::new_v4().to_string();
let raw_command = self.command.clone();
let parsed_cmd = parse_command(&shell_invocation);
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: call_id.clone(),
command: shell_invocation.clone(),
cwd: turn_context.cwd.clone(),
parsed_cmd,
is_user_shell_command: true,
}),
)
.await;
let exec_env = ExecEnv {
command: shell_invocation,
cwd: turn_context.cwd.clone(),
env: create_env(&turn_context.shell_environment_policy),
timeout_ms: None,
sandbox: SandboxType::None,
with_escalated_permissions: None,
justification: None,
arg0: None,
let shell_invocation = match session.user_shell() {
crate::shell::Shell::Zsh(zsh) => vec![
zsh.shell_path.clone(),
"-lc".to_string(),
self.command.clone(),
],
crate::shell::Shell::Bash(bash) => vec![
bash.shell_path.clone(),
"-lc".to_string(),
self.command.clone(),
],
crate::shell::Shell::PowerShell(ps) => vec![
ps.exe.clone(),
"-NoProfile".to_string(),
"-Command".to_string(),
self.command.clone(),
],
crate::shell::Shell::Unknown => {
shlex::split(&self.command).unwrap_or_else(|| vec![self.command.clone()])
}
};
let stdout_stream = Some(StdoutStream {
sub_id: turn_context.sub_id.clone(),
call_id: call_id.clone(),
tx_event: session.get_tx_event(),
});
let params = ShellToolCallParams {
command: shell_invocation,
workdir: None,
timeout_ms: None,
with_escalated_permissions: None,
justification: None,
};
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
.or_cancel(&cancellation_token)
.await;
let tool_call = ToolCall {
tool_name: USER_SHELL_TOOL_NAME.to_string(),
call_id: Uuid::new_v4().to_string(),
payload: ToolPayload::LocalShell { params },
};
match exec_result {
Err(CancelErr::Cancelled) => {
let aborted_message = "command aborted by user".to_string();
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(aborted_message.clone()),
aggregated_output: StreamOutput::new(aborted_message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
stdout: String::new(),
stderr: aborted_message.clone(),
aggregated_output: aborted_message.clone(),
exit_code: -1,
duration: Duration::ZERO,
formatted_output: aborted_message,
}),
)
.await;
}
Ok(Ok(output)) => {
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: call_id.clone(),
stdout: output.stdout.text.clone(),
stderr: output.stderr.text.clone(),
aggregated_output: output.aggregated_output.text.clone(),
exit_code: output.exit_code,
duration: output.duration,
formatted_output: format_exec_output_str(&output),
}),
)
.await;
let router = Arc::new(ToolRouter::from_config(&turn_context.tools_config, None));
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
let runtime = ToolCallRuntime::new(
Arc::clone(&router),
Arc::clone(&session),
Arc::clone(&turn_context),
Arc::clone(&tracker),
);
let output_items = [user_shell_command_record_item(&raw_command, &output)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
}
Ok(Err(err)) => {
error!("user shell command failed: {err:?}");
let message = format!("execution error: {err:?}");
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(message.clone()),
aggregated_output: StreamOutput::new(message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
stdout: exec_output.stdout.text.clone(),
stderr: exec_output.stderr.text.clone(),
aggregated_output: exec_output.aggregated_output.text.clone(),
exit_code: exec_output.exit_code,
duration: exec_output.duration,
formatted_output: format_exec_output_str(&exec_output),
}),
)
.await;
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
}
if let Err(err) = runtime
.handle_tool_call(tool_call, cancellation_token)
.await
{
error!("user shell command failed: {err:?}");
}
None
}

View File

@@ -19,7 +19,6 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;
pub use read_file::ReadFileHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use unified_exec::UnifiedExecHandler;

View File

@@ -1,5 +1,4 @@
use async_trait::async_trait;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use std::sync::Arc;
@@ -26,8 +25,6 @@ use crate::tools::sandboxing::ToolCtx;
pub struct ShellHandler;
pub struct ShellCommandHandler;
impl ShellHandler {
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
ExecParams {
@@ -42,28 +39,6 @@ impl ShellHandler {
}
}
impl ShellCommandHandler {
fn to_exec_params(
params: ShellCommandToolCallParams,
session: &crate::codex::Session,
turn_context: &TurnContext,
) -> ExecParams {
let shell = session.user_shell();
let use_login_shell = true;
let command = shell.derive_exec_args(&params.command, use_login_shell);
ExecParams {
command,
cwd: turn_context.resolve_path(params.workdir.clone()),
timeout_ms: params.timeout_ms,
env: create_env(&turn_context.shell_environment_policy),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification,
arg0: None,
}
}
}
#[async_trait]
impl ToolHandler for ShellHandler {
fn kind(&self) -> ToolKind {
@@ -127,49 +102,6 @@ impl ToolHandler for ShellHandler {
}
}
#[async_trait]
impl ToolHandler for ShellCommandHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
tool_name,
payload,
} = invocation;
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {tool_name}"
)));
};
let params: ShellCommandToolCallParams = serde_json::from_str(&arguments).map_err(|e| {
FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}"))
})?;
let exec_params = Self::to_exec_params(params, session.as_ref(), turn.as_ref());
ShellHandler::run_exec_like(
tool_name.as_str(),
exec_params,
session,
turn,
tracker,
call_id,
false,
)
.await
}
}
impl ShellHandler {
async fn run_exec_like(
tool_name: &str,
@@ -308,49 +240,3 @@ impl ShellHandler {
})
}
}
#[cfg(test)]
mod tests {
use crate::is_safe_command::is_known_safe_command;
use crate::shell::BashShell;
use crate::shell::Shell;
use crate::shell::ZshShell;
/// 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 {
shell_path: "/bin/bash".to_string(),
bashrc_path: "/home/user/.bashrc".to_string(),
});
assert_safe(&bash_shell, "ls -la");
let zsh_shell = Shell::Zsh(ZshShell {
shell_path: "/bin/zsh".to_string(),
zshrc_path: "/home/user/.zshrc".to_string(),
});
assert_safe(&zsh_shell, "ls -la");
#[cfg(target_os = "windows")]
{
use crate::shell::PowerShellConfig;
let powershell = Shell::PowerShell(PowerShellConfig {
exe: "pwsh.exe".to_string(),
bash_exe_fallback: None,
});
assert_safe(&powershell, "ls -Name");
}
}
fn assert_safe(shell: &Shell, command: &str) {
assert!(is_known_safe_command(
&shell.derive_exec_args(command, /* use_login_shell */ true)
));
assert!(is_known_safe_command(
&shell.derive_exec_args(command, /* use_login_shell */ false)
));
}
}

View File

@@ -1,5 +1,3 @@
use std::path::PathBuf;
use async_trait::async_trait;
use serde::Deserialize;
@@ -26,8 +24,6 @@ pub struct UnifiedExecHandler;
#[derive(Debug, Deserialize)]
struct ExecCommandArgs {
cmd: String,
#[serde(default)]
workdir: Option<String>,
#[serde(default = "default_shell")]
shell: String,
#[serde(default = "default_login")]
@@ -36,10 +32,6 @@ struct ExecCommandArgs {
yield_time_ms: Option<u64>,
#[serde(default)]
max_output_tokens: Option<usize>,
#[serde(default)]
with_escalated_permissions: Option<bool>,
#[serde(default)]
justification: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -104,34 +96,6 @@ impl ToolHandler for UnifiedExecHandler {
"failed to parse exec_command arguments: {err:?}"
))
})?;
let ExecCommandArgs {
cmd,
workdir,
shell,
login,
yield_time_ms,
max_output_tokens,
with_escalated_permissions,
justification,
} = args;
if with_escalated_permissions.unwrap_or(false)
&& !matches!(
context.turn.approval_policy,
codex_protocol::protocol::AskForApproval::OnRequest
)
{
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}",
policy = context.turn.approval_policy
)));
}
let workdir = workdir
.as_deref()
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
let event_ctx = ToolEventCtx::new(
context.session.as_ref(),
@@ -139,20 +103,18 @@ impl ToolHandler for UnifiedExecHandler {
&context.call_id,
None,
);
let emitter = ToolEmitter::unified_exec(cmd.clone(), cwd.clone(), true);
let emitter =
ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true);
emitter.emit(event_ctx, ToolEventStage::Begin).await;
manager
.exec_command(
ExecCommandRequest {
command: &cmd,
shell: &shell,
login,
yield_time_ms,
max_output_tokens,
workdir,
with_escalated_permissions,
justification,
command: &args.cmd,
shell: &args.shell,
login: args.login,
yield_time_ms: args.yield_time_ms,
max_output_tokens: args.max_output_tokens,
},
&context,
)

View File

@@ -65,9 +65,9 @@ impl ToolCallRuntime {
Ok(Self::aborted_response(&call, secs))
},
res = async {
tracing::trace!("waiting for tool gate");
tracing::info!("waiting for tool gate");
readiness.wait_ready().await;
tracing::trace!("tool gate released");
tracing::info!("tool gate released");
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {

View File

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

View File

@@ -1,4 +1,3 @@
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
/*
Runtime: unified exec
@@ -22,9 +21,7 @@ use crate::tools::sandboxing::with_cached_approval;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecSession;
use crate::unified_exec::UnifiedExecSessionManager;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use futures::future::BoxFuture;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -34,8 +31,6 @@ pub struct UnifiedExecRequest {
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
}
impl ProvidesSandboxRetryData for UnifiedExecRequest {
@@ -51,7 +46,6 @@ impl ProvidesSandboxRetryData for UnifiedExecRequest {
pub struct UnifiedExecApprovalKey {
pub command: Vec<String>,
pub cwd: PathBuf,
pub escalated: bool,
}
pub struct UnifiedExecRuntime<'a> {
@@ -59,20 +53,8 @@ pub struct UnifiedExecRuntime<'a> {
}
impl UnifiedExecRequest {
pub fn new(
command: Vec<String>,
cwd: PathBuf,
env: HashMap<String, String>,
with_escalated_permissions: Option<bool>,
justification: Option<String>,
) -> Self {
Self {
command,
cwd,
env,
with_escalated_permissions,
justification,
}
pub fn new(command: Vec<String>, cwd: PathBuf, env: HashMap<String, String>) -> Self {
Self { command, cwd, env }
}
}
@@ -99,7 +81,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
UnifiedExecApprovalKey {
command: req.command.clone(),
cwd: req.cwd.clone(),
escalated: req.with_escalated_permissions.unwrap_or(false),
}
}
@@ -114,10 +95,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let call_id = ctx.call_id.to_string();
let command = req.command.clone();
let cwd = req.cwd.clone();
let reason = ctx
.retry_reason
.clone()
.or_else(|| req.justification.clone());
let reason = ctx.retry_reason.clone();
let risk = ctx.risk.clone();
Box::pin(async move {
with_cached_approval(&session.services, key, || async move {
@@ -128,24 +106,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
.await
})
}
fn wants_initial_approval(
&self,
req: &UnifiedExecRequest,
policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> bool {
requires_initial_appoval(
policy,
sandbox_policy,
&req.command,
req.with_escalated_permissions.unwrap_or(false),
)
}
fn wants_escalated_first_attempt(&self, req: &UnifiedExecRequest) -> bool {
req.with_escalated_permissions.unwrap_or(false)
}
}
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRuntime<'a> {
@@ -155,15 +115,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRunt
attempt: &SandboxAttempt<'_>,
_ctx: &ToolCtx<'_>,
) -> Result<UnifiedExecSession, ToolError> {
let spec = build_command_spec(
&req.command,
&req.cwd,
&req.env,
None,
req.with_escalated_permissions,
req.justification.clone(),
)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let spec = build_command_spec(&req.command, &req.cwd, &req.env, None, None, None)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let exec_env = attempt
.env_for(&spec)
.map_err(|err| ToolError::Codex(err.into()))?;

View File

@@ -20,8 +20,6 @@ pub enum ConfigShellToolType {
Default,
Local,
UnifiedExec,
/// Takes a command as a single string to be run in the user's default shell.
ShellCommand,
}
#[derive(Debug, Clone)]
@@ -50,8 +48,6 @@ impl ToolsConfig {
let shell_type = if features.enabled(Feature::UnifiedExec) {
ConfigShellToolType::UnifiedExec
} else if features.enabled(Feature::ShellCommandTool) {
ConfigShellToolType::ShellCommand
} else {
model_family.shell_type.clone()
};
@@ -142,15 +138,6 @@ fn create_exec_command_tool() -> ToolSpec {
description: Some("Shell command to execute.".to_string()),
},
);
properties.insert(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
);
properties.insert(
"shell".to_string(),
JsonSchema::String {
@@ -169,7 +156,8 @@ fn create_exec_command_tool() -> ToolSpec {
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
"Maximum time in milliseconds to wait for output after writing the input (default: 1000)."
.to_string(),
),
},
);
@@ -181,24 +169,6 @@ fn create_exec_command_tool() -> ToolSpec {
),
},
);
properties.insert(
"with_escalated_permissions".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions"
.to_string(),
),
},
);
properties.insert(
"justification".to_string(),
JsonSchema::String {
description: Some(
"Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command."
.to_string(),
),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "exec_command".to_string(),
@@ -277,7 +247,9 @@ fn create_shell_tool() -> ToolSpec {
properties.insert(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
description: Some(
"The timeout for the command in milliseconds (default: 1000).".to_string(),
),
},
);
@@ -306,53 +278,6 @@ fn create_shell_tool() -> ToolSpec {
})
}
fn create_shell_command_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
);
properties.insert(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
);
properties.insert(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
);
properties.insert(
"with_escalated_permissions".to_string(),
JsonSchema::Boolean {
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
},
);
properties.insert(
"justification".to_string(),
JsonSchema::String {
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description: "Runs a shell command string and returns its output.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_view_image_tool() -> ToolSpec {
// Support only local filesystem path.
let mut properties = BTreeMap::new();
@@ -942,7 +867,6 @@ pub(crate) fn build_specs(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::UnifiedExecHandler;
@@ -958,7 +882,6 @@ pub(crate) fn build_specs(
let view_image_handler = Arc::new(ViewImageHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler);
match &config.shell_type {
ConfigShellToolType::Default => {
@@ -973,16 +896,12 @@ pub(crate) fn build_specs(
builder.register_handler("exec_command", unified_exec_handler.clone());
builder.register_handler("write_stdin", unified_exec_handler);
}
ConfigShellToolType::ShellCommand => {
builder.push_spec(create_shell_command_tool());
}
}
// Always register shell aliases so older prompts remain compatible.
builder.register_handler("shell", shell_handler.clone());
builder.register_handler("container.exec", shell_handler.clone());
builder.register_handler("local_shell", shell_handler);
builder.register_handler("shell_command", shell_command_handler);
builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true);
builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true);
@@ -1118,7 +1037,6 @@ mod tests {
ConfigShellToolType::Default => Some("shell"),
ConfigShellToolType::Local => Some("local_shell"),
ConfigShellToolType::UnifiedExec => None,
ConfigShellToolType::ShellCommand => Some("shell_command"),
}
}
@@ -1351,22 +1269,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() {
@@ -1822,21 +1724,6 @@ mod tests {
assert_eq!(description, expected);
}
#[test]
fn test_shell_command_tool() {
let tool = super::create_shell_command_tool();
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell_command");
let expected = "Runs a shell command string and returns its output.";
assert_eq!(description, expected);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let model_family = find_family_for_model("gpt-5-codex")

View File

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

View File

@@ -1,4 +1,3 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
@@ -39,10 +38,6 @@ impl UnifiedExecSessionManager {
request: ExecCommandRequest<'_>,
context: &UnifiedExecContext,
) -> Result<UnifiedExecResponse, UnifiedExecError> {
let cwd = request
.workdir
.clone()
.unwrap_or_else(|| context.turn.cwd.clone());
let shell_flag = if request.login { "-lc" } else { "-c" };
let command = vec![
request.shell.to_string(),
@@ -50,15 +45,7 @@ impl UnifiedExecSessionManager {
request.command.to_string(),
];
let session = self
.open_session_with_sandbox(
command,
cwd.clone(),
request.with_escalated_permissions,
request.justification,
context,
)
.await?;
let session = self.open_session_with_sandbox(command, context).await?;
let max_tokens = resolve_max_tokens(request.max_output_tokens);
let yield_time_ms =
@@ -79,7 +66,7 @@ impl UnifiedExecSessionManager {
None
} else {
Some(
self.store_session(session, context, request.command, cwd.clone(), start)
self.store_session(session, context, request.command, start)
.await,
)
};
@@ -100,7 +87,6 @@ impl UnifiedExecSessionManager {
Self::emit_exec_end_from_context(
context,
request.command.to_string(),
cwd,
response.output.clone(),
exit,
response.wall_time,
@@ -225,7 +211,6 @@ impl UnifiedExecSessionManager {
session: UnifiedExecSession,
context: &UnifiedExecContext,
command: &str,
cwd: PathBuf,
started_at: Instant,
) -> i32 {
let session_id = self
@@ -237,7 +222,7 @@ impl UnifiedExecSessionManager {
turn_ref: Arc::clone(&context.turn),
call_id: context.call_id.clone(),
command: command.to_string(),
cwd,
cwd: context.turn.cwd.clone(),
started_at,
};
self.sessions.lock().await.insert(session_id, entry);
@@ -273,7 +258,6 @@ impl UnifiedExecSessionManager {
async fn emit_exec_end_from_context(
context: &UnifiedExecContext,
command: String,
cwd: PathBuf,
aggregated_output: String,
exit_code: i32,
duration: Duration,
@@ -292,7 +276,7 @@ impl UnifiedExecSessionManager {
&context.call_id,
None,
);
let emitter = ToolEmitter::unified_exec(command, cwd, true);
let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true);
emitter
.emit(event_ctx, ToolEventStage::Success(output))
.await;
@@ -306,35 +290,24 @@ impl UnifiedExecSessionManager {
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let spawned = codex_utils_pty::spawn_pty_process(
program,
args,
env.cwd.as_path(),
&env.env,
&env.arg0,
)
.await
.map_err(|err| UnifiedExecError::create_session(err.to_string()))?;
let spawned =
codex_utils_pty::spawn_pty_process(program, args, env.cwd.as_path(), &env.env)
.await
.map_err(|err| UnifiedExecError::create_session(err.to_string()))?;
UnifiedExecSession::from_spawned(spawned, env.sandbox).await
}
pub(super) async fn open_session_with_sandbox(
&self,
command: Vec<String>,
cwd: PathBuf,
with_escalated_permissions: Option<bool>,
justification: Option<String>,
context: &UnifiedExecContext,
) -> Result<UnifiedExecSession, UnifiedExecError> {
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let req = UnifiedExecToolRequest::new(
command,
cwd,
context.turn.cwd.clone(),
create_env(&context.turn.shell_environment_policy),
with_escalated_permissions,
justification,
);
let tool_ctx = ToolCtx {
session: context.session.as_ref(),

View File

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

View File

@@ -61,18 +61,6 @@ impl ResponsesRequest {
self.0.body_json().unwrap()
}
/// Returns all `input_text` spans from `message` inputs for the provided role.
pub fn message_input_texts(&self, role: &str) -> Vec<String> {
self.inputs_of_type("message")
.into_iter()
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array).cloned())
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
.collect()
}
pub fn input(&self) -> Vec<Value> {
self.0.body_json::<Value>().unwrap()["input"]
.as_array()
@@ -446,6 +434,12 @@ pub async fn mount_sse_once(server: &MockServer, body: String) -> ResponseMock {
response_mock
}
pub async fn mount_sse(server: &MockServer, body: String) -> ResponseMock {
let (mock, response_mock) = base_mock();
mock.respond_with(sse_response(body)).mount(server).await;
response_mock
}
pub async fn start_mock_server() -> MockServer {
MockServer::builder()
.body_print_limit(BodyPrintLimit::Limited(80_000))

View File

@@ -1,7 +1,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Result;
use codex_core::features::Feature;
use codex_core::model_family::find_family_for_model;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskForApproval;
@@ -25,7 +24,6 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use std::env;
@@ -73,10 +71,6 @@ enum ActionKind {
RunCommand {
command: &'static [&'static str],
},
RunUnifiedExecCommand {
command: &'static str,
justification: Option<&'static str>,
},
ApplyPatchFunction {
target: TargetPath,
content: &'static str,
@@ -87,9 +81,6 @@ enum ActionKind {
},
}
const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
"Requires escalated permissions to bypass the sandbox in tests.";
impl ActionKind {
async fn prepare(
&self,
@@ -143,26 +134,6 @@ impl ActionKind {
let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?;
Ok((event, Some(command)))
}
ActionKind::RunUnifiedExecCommand {
command,
justification,
} => {
let event = exec_command_event(
call_id,
command,
Some(1000),
with_escalated_permissions,
*justification,
)?;
Ok((
event,
Some(vec![
"/bin/bash".to_string(),
"-lc".to_string(),
command.to_string(),
]),
))
}
ActionKind::ApplyPatchFunction { target, content } => {
let (path, patch_path) = target.resolve_for_patch(test);
let _ = fs::remove_file(&path);
@@ -212,28 +183,6 @@ fn shell_event(
Ok(ev_function_call(call_id, "shell", &args_str))
}
fn exec_command_event(
call_id: &str,
cmd: &str,
yield_time_ms: Option<u64>,
with_escalated_permissions: bool,
justification: Option<&str>,
) -> Result<Value> {
let mut args = json!({
"cmd": cmd.to_string(),
});
if let Some(yield_time_ms) = yield_time_ms {
args["yield_time_ms"] = json!(yield_time_ms);
}
if with_escalated_permissions {
args["with_escalated_permissions"] = json!(true);
let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
args["justification"] = json!(reason);
}
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
#[derive(Clone)]
enum Expectation {
FileCreated {
@@ -257,9 +206,6 @@ enum Expectation {
CommandSuccess {
stdout_contains: &'static str,
},
CommandFailure {
output_contains: &'static str,
},
}
impl Expectation {
@@ -391,19 +337,6 @@ impl Expectation {
result.stdout
);
}
Expectation::CommandFailure { output_contains } => {
assert_ne!(
result.exit_code,
Some(0),
"expected non-zero exit for command failure: {}",
result.stdout
);
assert!(
result.stdout.contains(output_contains),
"command failure stderr missing {output_contains:?}: {}",
result.stdout
);
}
}
Ok(())
}
@@ -429,7 +362,7 @@ struct ScenarioSpec {
sandbox_policy: SandboxPolicy,
action: ActionKind,
with_escalated_permissions: bool,
features: Vec<Feature>,
requires_apply_patch_tool: bool,
model_override: Option<&'static str>,
outcome: Outcome,
expectation: Expectation,
@@ -477,24 +410,10 @@ fn parse_result(item: &Value) -> CommandResult {
let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
CommandResult { exit_code, stdout }
}
Err(_) => {
let regex =
Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
// parse freeform output
if let Some(captures) = regex.captures(output_str) {
let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
let output = captures.get(2).unwrap().as_str();
CommandResult {
exit_code: Some(exit_code),
stdout: output.to_string(),
}
} else {
CommandResult {
exit_code: None,
stdout: output_str.to_string(),
}
}
}
Err(_) => CommandResult {
exit_code: None,
stdout: output_str.to_string(),
},
}
}
@@ -587,7 +506,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-on-request",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -604,7 +523,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "danger-network-ok",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccess {
@@ -619,7 +538,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
command: &["echo", "trusted-unless"],
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
@@ -635,7 +554,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-on-failure",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -652,7 +571,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -672,7 +591,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "danger-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -689,7 +608,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-approval",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -708,7 +627,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
command: &["echo", "trusted-read-only"],
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
@@ -724,7 +643,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "should-not-see",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
@@ -738,7 +657,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "should-not-write",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Denied,
@@ -759,7 +678,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-on-failure",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -779,7 +698,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "read-only-network-ok",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -798,7 +717,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "shell-apply-patch",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: None,
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -818,7 +737,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-apply-patch",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::Auto,
expectation: Expectation::PatchApplied {
@@ -835,7 +754,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-danger",
},
with_escalated_permissions: false,
features: vec![Feature::ApplyPatchFreeform],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::Auto,
expectation: Expectation::PatchApplied {
@@ -852,7 +771,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-outside",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -872,7 +791,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-outside-denied",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Denied,
@@ -892,7 +811,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "shell-patch-outside",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: None,
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -912,7 +831,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::PatchApproval {
decision: ReviewDecision::Approved,
@@ -932,7 +851,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "function-patch-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: true,
model_override: Some("gpt-5-codex"),
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
@@ -951,7 +870,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -971,7 +890,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "read-only-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
@@ -991,7 +910,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
command: &["echo", "trusted-never"],
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
@@ -1007,7 +926,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-on-request",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileCreated {
@@ -1024,7 +943,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "workspace-network-blocked",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
@@ -1038,7 +957,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-on-request-outside",
},
with_escalated_permissions: true,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -1058,7 +977,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
response_body: "workspace-network-ok",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::NetworkSuccess {
@@ -1075,7 +994,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-on-failure",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -1095,7 +1014,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-unless-trusted",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
@@ -1115,7 +1034,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
content: "workspace-never",
},
with_escalated_permissions: false,
features: vec![],
requires_apply_patch_tool: false,
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::FileNotCreated {
@@ -1127,62 +1046,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
},
},
},
ScenarioSpec {
name: "unified exec on request no approval for safe command",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunUnifiedExecCommand {
command: "echo \"hello unified exec\"",
justification: None,
},
with_escalated_permissions: false,
features: vec![Feature::UnifiedExec],
model_override: None,
outcome: Outcome::Auto,
expectation: Expectation::CommandSuccess {
stdout_contains: "hello unified exec",
},
},
#[cfg(not(all(target_os = "linux", target_arch = "aarch64")))]
// Linux sandbox arg0 test workaround doesn't work on ARM
ScenarioSpec {
name: "unified exec on request escalated requires approval",
approval_policy: OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly,
action: ActionKind::RunUnifiedExecCommand {
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
},
with_escalated_permissions: true,
features: vec![Feature::UnifiedExec],
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
},
expectation: Expectation::CommandSuccess {
stdout_contains: "escalated unified exec",
},
},
ScenarioSpec {
name: "unified exec on request requires approval unless trusted",
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::RunUnifiedExecCommand {
command: "git reset --hard",
justification: None,
},
with_escalated_permissions: false,
features: vec![Feature::UnifiedExec],
model_override: None,
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Denied,
expected_reason: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
},
},
]
}
@@ -1202,7 +1065,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
let server = start_mock_server().await;
let approval_policy = scenario.approval_policy;
let sandbox_policy = scenario.sandbox_policy.clone();
let features = scenario.features.clone();
let requires_apply_patch_tool = scenario.requires_apply_patch_tool;
let model_override = scenario.model_override;
let mut builder = test_codex().with_config(move |config| {
@@ -1212,8 +1075,8 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
config.model = model.to_string();
config.model_family =
find_family_for_model(model).expect("model should map to a known family");
for feature in features {
config.features.enable(feature);
if requires_apply_patch_tool {
config.include_apply_patch_tool = true;
}
});
let test = builder.build(&server).await?;

View File

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

View File

@@ -1,17 +1,4 @@
// Aggregates all former standalone integration tests as modules.
use codex_arg0::arg0_dispatch;
use ctor::ctor;
use tempfile::TempDir;
// This code runs before any other tests are run.
// It allows the test binary to behave like codex and dispatch to apply_patch and codex-linux-sandbox
// based on the arg0.
// NOTE: this doesn't work on ARM
#[ctor]
pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe {
#[allow(clippy::unwrap_used)]
arg0_dispatch().unwrap()
};
#[cfg(not(target_os = "windows"))]
mod abort_tasks;

View File

@@ -1,4 +1,3 @@
use codex_core::features::Feature;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
@@ -9,6 +8,7 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::mount_sse;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -101,7 +101,8 @@ async fn process_sse_emits_failed_event_on_parse_error() {
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -140,7 +141,8 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -188,18 +190,11 @@ async fn process_sse_failed_event_records_response_error_message() {
})]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -245,18 +240,11 @@ async fn process_sse_failed_event_logs_parse_error() {
})]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -300,7 +288,8 @@ async fn process_sse_failed_event_logs_missing_error() {
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -342,18 +331,10 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -442,7 +423,7 @@ async fn process_sse_emits_completed_telemetry() {
async fn handle_response_item_records_tool_result_for_custom_tool_call() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_custom_tool_call(
@@ -454,18 +435,11 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -512,7 +486,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
async fn handle_response_item_records_tool_result_for_function_call() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_function_call("function-call", "nonexistent", "{\"value\":1}"),
@@ -521,18 +495,10 @@ async fn handle_response_item_records_tool_result_for_function_call() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -579,7 +545,7 @@ async fn handle_response_item_records_tool_result_for_function_call() {
async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
serde_json::json!({
@@ -598,18 +564,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -650,7 +608,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
async fn handle_response_item_records_tool_result_for_local_shell_call() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]),
@@ -659,18 +617,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -749,23 +699,10 @@ fn tool_decision_assertion<'a>(
#[traced_test]
async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call(
"auto_config_call",
"completed",
vec!["/bin/echo", "local shell"],
),
ev_completed("done"),
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_local_shell_call("auto_config_call", "completed", vec!["/bin/echo", "hello"]),
ev_completed("done"),
]),
)
@@ -775,6 +712,8 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
.with_config(|config| {
config.approval_policy = AskForApproval::OnRequest;
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -789,7 +728,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
logs_assert(tool_decision_assertion(
"auto_config_call",
@@ -802,7 +741,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
#[traced_test]
async fn handle_container_exec_user_approved_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]),
@@ -811,18 +750,11 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -861,7 +793,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
async fn handle_container_exec_user_approved_for_session_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]),
@@ -869,18 +801,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -919,7 +845,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]),
@@ -927,18 +853,12 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -977,7 +897,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
async fn handle_container_exec_user_denies_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]),
@@ -986,17 +906,11 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -1035,7 +949,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]),
@@ -1043,18 +957,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -1093,7 +1001,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
async fn handle_sandbox_error_user_denies_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
mount_sse(
&server,
sse(vec![
ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]),
@@ -1102,18 +1010,11 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await

View File

@@ -1,70 +0,0 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InitialHistory;
use codex_core::protocol::ResumedHistory;
use codex_core::protocol::RolloutItem;
use codex_core::protocol::TurnContextItem;
use codex_core::protocol::WarningEvent;
use codex_protocol::ConversationId;
use core::time::Duration;
use core_test_support::load_default_config_for_test;
use core_test_support::wait_for_event;
use tempfile::TempDir;
fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory {
let turn_ctx = TurnContextItem {
cwd: config.cwd.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
model: previous_model.to_string(),
effort: config.model_reasoning_effort,
summary: config.model_reasoning_summary,
};
InitialHistory::Resumed(ResumedHistory {
conversation_id: ConversationId::default(),
history: vec![RolloutItem::TurnContext(turn_ctx)],
rollout_path: rollout_path.to_path_buf(),
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_warning_when_resumed_model_differs() {
// Arrange a config with a current model and a prior rollout recorded under a different model.
let home = TempDir::new().expect("tempdir");
let mut config = load_default_config_for_test(&home);
config.model = "current-model".to_string();
// Ensure cwd is absolute (the helper sets it to the temp dir already).
assert!(config.cwd.is_absolute());
let rollout_path = home.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "").expect("create rollout placeholder");
let initial_history = resume_history(&config, "previous-model", &rollout_path);
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test"));
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
// Act: resume the conversation.
let NewConversation { conversation, .. } = conversation_manager
.resume_conversation_with_history(config, initial_history, auth_manager)
.await
.expect("resume conversation");
// Assert: a Warning event is emitted describing the model mismatch.
let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await;
let EventMsg::Warning(WarningEvent { message }) = warning else {
panic!("expected warning event");
};
assert!(message.contains("previous-model"));
assert!(message.contains("current-model"));
// Drain the TaskComplete/Shutdown window to avoid leaking tasks between tests.
// The warning is emitted during initialization, so a short sleep is sufficient.
tokio::time::sleep(Duration::from_millis(50)).await;
}

View File

@@ -66,7 +66,7 @@ fn parse_unified_exec_output(raw: &str) -> Result<ParsedUnifiedExecOutput> {
let cleaned = raw.trim_matches('\r');
let captures = regex
.captures(cleaned)
.ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output {raw}"))?;
.ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output"))?;
let chunk_id = captures
.name("chunk_id")
@@ -223,90 +223,6 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_respects_workdir_override() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config.features.enable(Feature::UnifiedExec);
});
let TestCodex {
codex,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let workdir = cwd.path().join("uexec_workdir_test");
std::fs::create_dir_all(&workdir)?;
let call_id = "uexec-workdir";
let args = json!({
"cmd": "pwd",
"yield_time_ms": 250,
"workdir": workdir.to_string_lossy().to_string(),
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "finished"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "run workdir test".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
let requests = server.received_requests().await.expect("recorded requests");
assert!(!requests.is_empty(), "expected at least one POST request");
let bodies = requests
.iter()
.map(|req| req.body_json::<Value>().expect("request json"))
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let output = outputs
.get(call_id)
.expect("missing exec_command workdir output");
let output_text = output.output.trim();
let output_canonical = std::fs::canonicalize(output_text)?;
let expected_canonical = std::fs::canonicalize(&workdir)?;
assert_eq!(
output_canonical, expected_canonical,
"pwd should reflect the requested workdir override"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1368,8 +1284,6 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Skipped on arm because the ctor logic to handle arg0 doesn't work on ARM
#[cfg(not(target_arch = "arm"))]
async fn unified_exec_formats_large_output_summary() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -1453,75 +1367,3 @@ PY
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_runs_under_sandbox() -> 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";
let args = serde_json::json!({
"cmd": "echo 'hello'",
"yield_time_ms": 500,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "summarize large output".into(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
// Important!
sandbox_policy: SandboxPolicy::ReadOnly,
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 output");
assert_regex_match("hello[\r\n]+", &output.output);
Ok(())
}

View File

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

View File

@@ -30,7 +30,7 @@ pub struct Cli {
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<String>,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write).
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write).
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -292,29 +292,13 @@ impl From<Vec<UserInput>> for ResponseInputItem {
}
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or `shell`, the `arguments` field should deserialize to this struct.
/// or shell`, the `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct ShellToolCallParams {
pub command: Vec<String>,
pub workdir: Option<String>,
/// This is the maximum time in milliseconds that the command is allowed to run.
#[serde(alias = "timeout")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub with_escalated_permissions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
/// `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct ShellCommandToolCallParams {
pub command: String,
pub workdir: Option<String>,
/// This is the maximum time in milliseconds that the command is allowed to run.
/// Maximum time in milliseconds that the command is allowed to run (defaults to 1_000 ms when omitted).
#[serde(alias = "timeout")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]

View File

@@ -13,7 +13,7 @@ use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::updates::UpdateAction;
use codex_ansi_escape::ansi_escape_line;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -174,14 +174,13 @@ impl App {
skip_world_writable_scan_once: false,
};
// On startup, if Auto mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
// On startup, if Auto mode (workspace-write) is active, warn about world-writable dirs on Windows.
#[cfg(target_os = "windows")]
{
let should_check = codex_core::get_platform_sandbox().is_some()
&& matches!(
app.config.sandbox_policy,
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_core::protocol::SandboxPolicy::ReadOnly
)
&& !app
.config
@@ -192,8 +191,7 @@ impl App {
let cwd = app.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let tx = app.app_event_tx.clone();
let logs_base_dir = app.config.codex_home.clone();
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
Self::spawn_world_writable_scan(cwd, env_map, tx, false);
}
}
@@ -203,7 +201,7 @@ impl App {
tui,
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
latest_version,
crate::update_action::get_update_action(),
crate::updates::get_update_action(),
))),
)
.await?;
@@ -387,18 +385,9 @@ impl App {
AppEvent::OpenFullAccessConfirmation { preset } => {
self.chat_widget.open_full_access_confirmation(preset);
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
sample_paths,
extra_count,
failed_scan,
} => {
self.chat_widget.open_world_writable_warning_confirmation(
preset,
sample_paths,
extra_count,
failed_scan,
);
AppEvent::OpenWorldWritableWarningConfirmation { preset } => {
self.chat_widget
.open_world_writable_warning_confirmation(preset);
}
AppEvent::OpenFeedbackNote {
category,
@@ -459,15 +448,14 @@ impl App {
}
AppEvent::UpdateSandboxPolicy(policy) => {
#[cfg(target_os = "windows")]
let policy_is_workspace_write_or_ro = matches!(
let policy_is_workspace_write = matches!(
policy,
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
| codex_core::protocol::SandboxPolicy::ReadOnly
);
self.chat_widget.set_sandbox_policy(policy);
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
// If sandbox policy becomes workspace-write, run the Windows world-writable scan.
#[cfg(target_os = "windows")]
{
// One-shot suppression if the user just confirmed continue.
@@ -477,15 +465,14 @@ impl App {
}
let should_check = codex_core::get_platform_sandbox().is_some()
&& policy_is_workspace_write_or_ro
&& policy_is_workspace_write
&& !self.chat_widget.world_writable_warning_hidden();
if should_check {
let cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
let tx = self.app_event_tx.clone();
let logs_base_dir = self.config.codex_home.clone();
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
Self::spawn_world_writable_scan(cwd, env_map, tx, false);
}
}
}
@@ -499,9 +486,6 @@ impl App {
self.chat_widget
.set_world_writable_warning_acknowledged(ack);
}
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_full_access_warning(true)
@@ -532,21 +516,6 @@ impl App {
));
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
.set_hide_rate_limit_model_nudge(true)
.apply()
.await
{
tracing::error!(
error = %err,
"failed to persist rate limit switch prompt preference"
);
self.chat_widget.add_error_message(format!(
"Failed to save rate limit reminder preference: {err}"
));
}
}
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
@@ -655,50 +624,23 @@ impl App {
fn spawn_world_writable_scan(
cwd: PathBuf,
env_map: std::collections::HashMap<String, String>,
logs_base_dir: PathBuf,
tx: AppEventSender,
apply_preset_on_continue: bool,
) {
#[inline]
fn normalize_windows_path_for_display(p: &std::path::Path) -> String {
let canon = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canon.display().to_string().replace('/', "\\")
}
tokio::task::spawn_blocking(move || {
let result = codex_windows_sandbox::preflight_audit_everyone_writable(
&cwd,
&env_map,
Some(logs_base_dir.as_path()),
);
if let Ok(ref paths) = result
&& !paths.is_empty()
{
let as_strings: Vec<String> = paths
.iter()
.map(|p| normalize_windows_path_for_display(p))
.collect();
let sample_paths: Vec<String> = as_strings.iter().take(3).cloned().collect();
let extra_count = if as_strings.len() > sample_paths.len() {
as_strings.len() - sample_paths.len()
if codex_windows_sandbox::preflight_audit_everyone_writable(&cwd, &env_map).is_err() {
if apply_preset_on_continue {
if let Some(preset) = codex_common::approval_presets::builtin_approval_presets()
.into_iter()
.find(|p| p.id == "auto")
{
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset),
});
}
} else {
0
};
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
sample_paths,
extra_count,
failed_scan: false,
});
} else if result.is_err() {
// Scan failed: still warn, but with no examples and mark as failed.
let sample_paths: Vec<String> = Vec::new();
let extra_count = 0usize;
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
sample_paths,
extra_count,
failed_scan: true,
});
tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None });
}
}
});
}

View File

@@ -79,12 +79,6 @@ pub(crate) enum AppEvent {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWorldWritableWarningConfirmation {
preset: Option<ApprovalPreset>,
/// Up to 3 sample world-writable directories to display in the warning.
sample_paths: Vec<String>,
/// If there are more than `sample_paths`, this carries the remaining count.
extra_count: usize,
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
failed_scan: bool,
},
/// Show Windows Subsystem for Linux setup instructions for auto mode.
@@ -104,9 +98,6 @@ pub(crate) enum AppEvent {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
UpdateWorldWritableWarningAcknowledged(bool),
/// Update whether the rate limit switch prompt has been acknowledged for the session.
UpdateRateLimitSwitchPromptHidden(bool),
/// Persist the acknowledgement flag for the full access warning prompt.
PersistFullAccessWarningAcknowledged,
@@ -114,9 +105,6 @@ pub(crate) enum AppEvent {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
PersistWorldWritableWarningAcknowledged,
/// Persist the acknowledgement flag for the rate limit switch prompt.
PersistRateLimitSwitchPromptHidden,
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
SkipNextWorldWritableScan,

View File

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

View File

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

View File

@@ -522,7 +522,6 @@ impl ChatWidget {
.unwrap_or(false);
if high_usage
&& !self.rate_limit_switch_prompt_hidden()
&& self.config.model != NUDGE_MODEL_SLUG
&& !matches!(
self.rate_limit_switch_prompt,
@@ -1143,21 +1142,8 @@ impl ChatWidget {
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'v') => {
match paste_image_to_temp_png() {
Ok((path, info)) => {
self.attach_image(
path,
info.width,
info.height,
info.encoded_format.label(),
);
}
Err(err) => {
tracing::warn!("failed to paste image: {err}");
self.add_to_history(history_cell::new_error_event(format!(
"Failed to paste image: {err}",
)));
}
if let Ok((path, info)) = paste_image_to_temp_png() {
self.attach_image(path, info.width, info.height, info.encoded_format.label());
}
return;
}
@@ -1724,18 +1710,7 @@ impl ChatWidget {
.find(|preset| preset.model == NUDGE_MODEL_SLUG)
}
fn rate_limit_switch_prompt_hidden(&self) -> bool {
self.config
.notices
.hide_rate_limit_model_nudge
.unwrap_or(false)
}
fn maybe_show_pending_rate_limit_prompt(&mut self) {
if self.rate_limit_switch_prompt_hidden() {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
return;
}
if !matches!(
self.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
@@ -1769,10 +1744,6 @@ impl ChatWidget {
})];
let keep_actions: Vec<SelectionAction> = Vec::new();
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
})];
let description = if preset.description.is_empty() {
Some("Uses fewer credits for upcoming turns.".to_string())
} else {
@@ -1798,17 +1769,6 @@ impl ChatWidget {
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Keep current model (never show again)".to_string(),
description: Some(
"Hide future rate limit reminders about switching models.".to_string(),
),
selected_description: None,
is_current: false,
actions: never_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
@@ -2085,48 +2045,9 @@ impl ChatWidget {
&& self.windows_world_writable_flagged()
{
let preset_clone = preset.clone();
// Compute sample paths for the warning popup.
let mut env_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (k, v) in std::env::vars() {
env_map.insert(k, v);
}
let (sample_paths, extra_count, failed_scan) =
match codex_windows_sandbox::preflight_audit_everyone_writable(
&self.config.cwd,
&env_map,
Some(self.config.codex_home.as_path()),
) {
Ok(paths) if !paths.is_empty() => {
fn normalize_windows_path_for_display(
p: &std::path::Path,
) -> String {
let canon = dunce::canonicalize(p)
.unwrap_or_else(|_| p.to_path_buf());
canon.display().to_string().replace('/', "\\")
}
let as_strings: Vec<String> = paths
.iter()
.map(|p| normalize_windows_path_for_display(p))
.collect();
let samples: Vec<String> =
as_strings.iter().take(3).cloned().collect();
let extra = if as_strings.len() > samples.len() {
as_strings.len() - samples.len()
} else {
0
};
(samples, extra, false)
}
Err(_) => (Vec::new(), 0, true),
_ => (Vec::new(), 0, false),
};
vec![Box::new(move |tx| {
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset_clone.clone()),
sample_paths: sample_paths.clone(),
extra_count,
failed_scan,
});
})]
} else {
@@ -2185,12 +2106,8 @@ impl ChatWidget {
for (k, v) in std::env::vars() {
env_map.insert(k, v);
}
match codex_windows_sandbox::preflight_audit_everyone_writable(
&self.config.cwd,
&env_map,
Some(self.config.codex_home.as_path()),
) {
Ok(paths) => !paths.is_empty(),
match codex_windows_sandbox::preflight_audit_everyone_writable(&self.config.cwd, &env_map) {
Ok(()) => false,
Err(_) => true,
}
}
@@ -2263,66 +2180,31 @@ impl ChatWidget {
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
preset: Option<ApprovalPreset>,
sample_paths: Vec<String>,
extra_count: usize,
failed_scan: bool,
) {
let (approval, sandbox) = match &preset {
Some(p) => (Some(p.approval), Some(p.sandbox.clone())),
None => (None, None),
};
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let mode_label = match self.config.sandbox_policy {
SandboxPolicy::WorkspaceWrite { .. } => "Auto mode",
SandboxPolicy::ReadOnly => "Read-Only mode",
_ => "Auto mode",
};
let title_line = Line::from("Unprotected directories found").bold();
let info_line = if failed_scan {
Line::from(vec![
"We couldn't complete the world-writable scan, so protections cannot be verified. "
.into(),
format!("The Windows sandbox cannot guarantee protection in {mode_label}.")
.fg(Color::Red),
])
} else {
Line::from(vec![
"Some important directories on this system are world-writable. ".into(),
format!(
"The Windows sandbox cannot protect writes to these locations in {mode_label}."
)
let title_line = Line::from("Auto mode has unprotected directories").bold();
let info_line = Line::from(vec![
"Some important directories on this system are world-writable. ".into(),
"The Windows sandbox cannot protect writes to these locations in Auto mode."
.fg(Color::Red),
])
};
]);
header_children.push(Box::new(title_line));
header_children.push(Box::new(
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
));
if !sample_paths.is_empty() {
// Show up to three examples and optionally an "and X more" line.
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from("Examples:").bold());
for p in &sample_paths {
lines.push(Line::from(format!(" - {p}")));
}
if extra_count > 0 {
lines.push(Line::from(format!("and {extra_count} more")));
}
header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
}
let header = ColumnRenderable::with(header_children);
// Build actions ensuring acknowledgement happens before applying the new sandbox policy,
// so downstream policy-change hooks don't re-trigger the warning.
let mut accept_actions: Vec<SelectionAction> = Vec::new();
// Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals),
// to avoid duplicate warnings from the ensuing policy change.
if preset.is_some() {
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::SkipNextWorldWritableScan);
}));
}
// Suppress the immediate re-scan once after user confirms continue.
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::SkipNextWorldWritableScan);
}));
if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) {
accept_actions.extend(Self::approval_preset_actions(approval, sandbox));
}
@@ -2336,21 +2218,36 @@ impl ChatWidget {
accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox));
}
let deny_actions: Vec<SelectionAction> = if preset.is_some() {
vec![Box::new(|tx| {
tx.send(AppEvent::OpenApprovalsPopup);
})]
} else {
Vec::new()
};
let items = vec![
SelectionItem {
name: "Continue".to_string(),
description: Some(format!("Apply {mode_label} for this session")),
description: Some("Apply Auto mode for this session".to_string()),
actions: accept_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Continue and don't warn again".to_string(),
description: Some(format!("Enable {mode_label} and remember this choice")),
description: Some("Enable Auto mode and remember this choice".to_string()),
actions: accept_and_remember_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Cancel".to_string(),
description: Some("Go back without enabling Auto mode".to_string()),
actions: deny_actions,
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
@@ -2365,9 +2262,6 @@ impl ChatWidget {
pub(crate) fn open_world_writable_warning_confirmation(
&mut self,
_preset: Option<ApprovalPreset>,
_sample_paths: Vec<String>,
_extra_count: usize,
_failed_scan: bool,
) {
}
@@ -2426,13 +2320,6 @@ impl ChatWidget {
self.config.notices.hide_world_writable_warning = Some(acknowledged);
}
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
if hidden {
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) fn world_writable_warning_hidden(&self) -> bool {
self.config

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ use tempfile::tempdir;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::unbounded_channel;
const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.";
const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.";
fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
@@ -448,22 +448,6 @@ fn rate_limit_switch_prompt_shows_once_per_session() {
));
}
#[test]
fn rate_limit_switch_prompt_respects_hidden_notice() {
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let (mut chat, _, _) = make_chatwidget_manual();
chat.config.model = "gpt-5".to_string();
chat.auth_manager = AuthManager::from_auth_for_testing(auth);
chat.config.notices.hide_rate_limit_model_nudge = Some(true);
chat.on_rate_limit_snapshot(Some(snapshot(95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[test]
fn rate_limit_switch_prompt_defers_until_task_complete() {
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();

View File

@@ -51,7 +51,7 @@ pub struct Cli {
#[arg(long = "ask-for-approval", short = 'a')]
pub approval_policy: Option<ApprovalModeCliArg>,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write).
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write).
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,

View File

@@ -317,13 +317,7 @@ impl ExecCell {
Some(false) => "".red().bold(),
None => spinner(call.start_time),
};
let title = if self.is_active() {
"Running"
} else if call.is_user_shell_command {
"You ran"
} else {
"Ran"
};
let title = if self.is_active() { "Running" } else { "Ran" };
let mut header_line =
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]);

View File

@@ -1,7 +1,6 @@
use std::path::Path;
use std::path::PathBuf;
use codex_core::bash::extract_bash_command;
use dirs::home_dir;
use shlex::try_join;
@@ -9,11 +8,19 @@ pub(crate) fn escape_command(command: &[String]) -> String {
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "))
}
fn is_login_shell_with_lc(shell: &str) -> bool {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(shell);
matches!(shell_name, "bash" | "zsh")
}
pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
if let Some((_, script)) = extract_bash_command(command) {
return script.to_string();
match command {
[first, second, third] if is_login_shell_with_lc(first) && second == "-lc" => third.clone(),
_ => escape_command(command),
}
escape_command(command)
}
/// If `path` is absolute and inside $HOME, return the part *after* the home

View File

@@ -16,7 +16,7 @@ use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::update_action::UpdateAction;
use crate::updates::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;

View File

@@ -71,9 +71,8 @@ mod terminal_palette;
mod text_formatting;
mod tui;
mod ui_consts;
pub mod update_action;
mod update_prompt;
mod updates;
pub mod updates;
mod version;
mod wrapping;
@@ -354,16 +353,6 @@ async fn run_ratatui_app(
&mut tui,
)
.await?;
if onboarding_result.should_exit {
restore();
session_log::log_session_end();
let _ = tui.terminal.clear();
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
});
}
if onboarding_result.windows_install_selected {
restore();
session_log::log_session_end();

View File

@@ -57,7 +57,6 @@ pub(crate) struct OnboardingScreen {
steps: Vec<Step>,
is_done: bool,
windows_install_selected: bool,
should_exit: bool,
}
pub(crate) struct OnboardingScreenArgs {
@@ -72,7 +71,6 @@ pub(crate) struct OnboardingScreenArgs {
pub(crate) struct OnboardingResult {
pub directory_trust_decision: Option<TrustDirectorySelection>,
pub windows_install_selected: bool,
pub should_exit: bool,
}
impl OnboardingScreen {
@@ -139,7 +137,6 @@ impl OnboardingScreen {
steps,
is_done: false,
windows_install_selected: false,
should_exit: false,
}
}
@@ -203,10 +200,6 @@ impl OnboardingScreen {
pub fn windows_install_selected(&self) -> bool {
self.windows_install_selected
}
pub fn should_exit(&self) -> bool {
self.should_exit
}
}
impl KeyboardHandler for OnboardingScreen {
@@ -229,12 +222,9 @@ impl KeyboardHandler for OnboardingScreen {
kind: KeyEventKind::Press,
..
} => {
if self.is_auth_in_progress() {
// If the user cancels the auth menu, exit the app rather than
// leave the user at a prompt in an unauthed state.
self.should_exit = true;
if !self.is_auth_in_progress() {
self.is_done = true;
}
self.is_done = true;
}
_ => {
if let Some(Step::Welcome(widget)) = self
@@ -452,6 +442,5 @@ pub(crate) async fn run_onboarding_app(
Ok(OnboardingResult {
directory_trust_decision: onboarding_screen.directory_trust_decision(),
windows_install_selected: onboarding_screen.windows_install_selected(),
should_exit: onboarding_screen.should_exit(),
})
}

View File

@@ -211,11 +211,10 @@ impl StatusHistoryCell {
let mut lines = Vec::with_capacity(rows.len().saturating_mul(2));
for row in rows {
let percent_remaining = (100.0 - row.percent_used).clamp(0.0, 100.0);
let value_spans = vec![
Span::from(render_status_limit_progress_bar(percent_remaining)),
Span::from(render_status_limit_progress_bar(row.percent_used)),
Span::from(" "),
Span::from(format_status_limit_summary(percent_remaining)),
Span::from(format_status_limit_summary(row.percent_used)),
];
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
let base_line = Line::from(base_spans.clone());

View File

@@ -124,8 +124,8 @@ pub(crate) fn compose_rate_limit_data(
}
}
pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String {
let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0);
pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String {
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
@@ -136,8 +136,8 @@ pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String
)
}
pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String {
format!("{percent_remaining:.0}% left")
pub(crate) fn format_status_limit_summary(percent_used: f64) -> String {
format!("{percent_used:.0}% used")
}
fn capitalize_first(label: &str) -> String {

View File

@@ -18,5 +18,5 @@ expression: sanitized
│ │
│ Token usage: 1.2K total (800 input + 400 output) │
│ Context window: 100% left (1.2K used / 272K) │
│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │
╰────────────────────────────────────────────────────────────────────────────╯

View File

@@ -18,6 +18,6 @@ expression: sanitized
│ │
│ Token usage: 1.9K total (1K input + 900 output) │
│ Context window: 100% left (2.25K used / 272K) │
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │
│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
╰─────────────────────────────────────────────────────────────────────╯

View File

@@ -18,7 +18,7 @@ expression: sanitized
│ │
│ Token usage: 1.9K total (1K input + 900 output) │
│ Context window: 100% left (2.25K used / 272K) │
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │
│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │
│ Warning: limits may be stale - start new turn to refresh. │
╰─────────────────────────────────────────────────────────────────────╯

View File

@@ -1,5 +1,6 @@
---
source: tui/src/status/tests.rs
assertion_line: 257
expression: sanitized
---
/status
@@ -19,6 +20,6 @@ expression: sanitized
│ │
│ Token usage: 1.9K total (1K input + │
│ Context window: 100% left (2.25K used / │
│ 5h limit: [██████░░░░░░░░░░░░░░] │
│ 5h limit: [███████████████░░░░░] │
│ (resets 03:14) │
╰────────────────────────────────────────────╯

View File

@@ -1,23 +1,29 @@
use std::fmt;
use std::io::IsTerminal;
use std::io::Result;
use std::io::Stdout;
use std::io::stdout;
use std::panic;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
#[cfg(unix)]
use std::sync::atomic::AtomicU8;
#[cfg(unix)]
use std::sync::atomic::AtomicU16;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use crossterm::Command;
use crossterm::SynchronizedUpdate;
#[cfg(unix)]
use crossterm::cursor::MoveTo;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableFocusChange;
use crossterm::event::EnableBracketedPaste;
use crossterm::event::EnableFocusChange;
use crossterm::event::Event;
#[cfg(unix)]
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyboardEnhancementFlags;
use crossterm::event::PopKeyboardEnhancementFlags;
@@ -32,22 +38,20 @@ use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_raw_mode;
use ratatui::layout::Offset;
use ratatui::text::Line;
use tokio::select;
use tokio_stream::Stream;
use crate::custom_terminal;
use crate::custom_terminal::Terminal as CustomTerminal;
#[cfg(unix)]
use crate::tui::job_control::SUSPEND_KEY;
#[cfg(unix)]
use crate::tui::job_control::SuspendContext;
#[cfg(unix)]
mod job_control;
use crate::key_hint;
use tokio::select;
use tokio_stream::Stream;
/// A type alias for the terminal type used in this application
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
#[cfg(unix)]
const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z'));
pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
@@ -75,12 +79,12 @@ pub fn set_modes() -> Result<()> {
struct EnableAlternateScroll;
impl Command for EnableAlternateScroll {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
write!(f, "\x1b[?1007h")
}
#[cfg(windows)]
fn execute_winapi(&self) -> Result<()> {
fn execute_winapi(&self) -> std::io::Result<()> {
Err(std::io::Error::other(
"tried to execute EnableAlternateScroll using WinAPI; use ANSI instead",
))
@@ -96,12 +100,12 @@ impl Command for EnableAlternateScroll {
struct DisableAlternateScroll;
impl Command for DisableAlternateScroll {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
write!(f, "\x1b[?1007l")
}
#[cfg(windows)]
fn execute_winapi(&self) -> Result<()> {
fn execute_winapi(&self) -> std::io::Result<()> {
Err(std::io::Error::other(
"tried to execute DisableAlternateScroll using WinAPI; use ANSI instead",
))
@@ -140,8 +144,8 @@ pub fn init() -> Result<Terminal> {
}
fn set_panic_hook() {
let hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = restore(); // ignore any errors as we are already failing
hook(panic_info);
}));
@@ -161,7 +165,9 @@ pub struct Tui {
pending_history_lines: Vec<Line<'static>>,
alt_saved_viewport: Option<ratatui::layout::Rect>,
#[cfg(unix)]
suspend_context: SuspendContext,
resume_pending: Arc<AtomicU8>, // Stores a ResumeAction
#[cfg(unix)]
suspend_cursor_y: Arc<AtomicU16>, // Bottom line of inline viewport
// True when overlay alt-screen UI is active
alt_screen_active: Arc<AtomicBool>,
// True when terminal/tab is focused; updated internally from crossterm events
@@ -169,6 +175,30 @@ pub struct Tui {
enhanced_keys_supported: bool,
}
#[cfg(unix)]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u8)]
enum ResumeAction {
None = 0,
RealignInline = 1,
RestoreAlt = 2,
}
#[cfg(unix)]
enum PreparedResumeAction {
RestoreAltScreen,
RealignViewport(ratatui::layout::Rect),
}
#[cfg(unix)]
fn take_resume_action(pending: &AtomicU8) -> ResumeAction {
match pending.swap(ResumeAction::None as u8, Ordering::Relaxed) {
1 => ResumeAction::RealignInline,
2 => ResumeAction::RestoreAlt,
_ => ResumeAction::None,
}
}
#[derive(Clone, Debug)]
pub struct FrameRequester {
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
@@ -214,7 +244,9 @@ impl Tui {
pending_history_lines: vec![],
alt_saved_viewport: None,
#[cfg(unix)]
suspend_context: SuspendContext::new(),
resume_pending: Arc::new(AtomicU8::new(0)),
#[cfg(unix)]
suspend_cursor_y: Arc::new(AtomicU16::new(0)),
alt_screen_active: Arc::new(AtomicBool::new(false)),
terminal_focused: Arc::new(AtomicBool::new(true)),
enhanced_keys_supported,
@@ -250,9 +282,26 @@ impl Tui {
// State for tracking how we should resume from ^Z suspend.
#[cfg(unix)]
let suspend_context = self.suspend_context.clone();
let resume_pending = self.resume_pending.clone();
#[cfg(unix)]
let alt_screen_active = self.alt_screen_active.clone();
#[cfg(unix)]
let suspend_cursor_y = self.suspend_cursor_y.clone();
#[cfg(unix)]
let suspend = move || {
if alt_screen_active.load(Ordering::Relaxed) {
// Disable alternate scroll when suspending from alt-screen
let _ = execute!(stdout(), DisableAlternateScroll);
let _ = execute!(stdout(), LeaveAlternateScreen);
resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed);
} else {
resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed);
}
let y = suspend_cursor_y.load(Ordering::Relaxed);
let _ = execute!(stdout(), MoveTo(0, y), crossterm::cursor::Show);
let _ = Tui::suspend();
};
let terminal_focused = self.terminal_focused.clone();
let event_stream = async_stream::stream! {
@@ -260,10 +309,10 @@ impl Tui {
select! {
Some(Ok(event)) = crossterm_events.next() => {
match event {
Event::Key(key_event) => {
crossterm::event::Event::Key(key_event) => {
#[cfg(unix)]
if SUSPEND_KEY.is_press(key_event) {
let _ = suspend_context.suspend(&alt_screen_active);
suspend();
// We continue here after resume.
yield TuiEvent::Draw;
continue;
@@ -307,6 +356,67 @@ impl Tui {
Box::pin(event_stream)
}
#[cfg(unix)]
fn suspend() -> Result<()> {
restore()?;
unsafe { libc::kill(0, libc::SIGTSTP) };
set_modes()?;
Ok(())
}
/// When resuming from ^Z suspend, we want to put things back the way they were before suspend.
/// We capture the action in an object so we can pass it into the event stream, since the relevant
#[cfg(unix)]
fn prepare_resume_action(
&mut self,
action: ResumeAction,
) -> Result<Option<PreparedResumeAction>> {
match action {
ResumeAction::RealignInline => {
let cursor_pos = self
.terminal
.get_cursor_position()
.unwrap_or(self.terminal.last_known_cursor_pos);
Ok(Some(PreparedResumeAction::RealignViewport(
ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0),
)))
}
ResumeAction::RestoreAlt => {
if let Ok(ratatui::layout::Position { y, .. }) = self.terminal.get_cursor_position()
&& let Some(saved) = self.alt_saved_viewport.as_mut()
{
saved.y = y;
}
Ok(Some(PreparedResumeAction::RestoreAltScreen))
}
ResumeAction::None => Ok(None),
}
}
#[cfg(unix)]
fn apply_prepared_resume_action(&mut self, prepared: PreparedResumeAction) -> Result<()> {
match prepared {
PreparedResumeAction::RealignViewport(area) => {
self.terminal.set_viewport_area(area);
}
PreparedResumeAction::RestoreAltScreen => {
execute!(self.terminal.backend_mut(), EnterAlternateScreen)?;
// Enable "alternate scroll" so terminals may translate wheel to arrows
execute!(self.terminal.backend_mut(), EnableAlternateScroll)?;
if let Ok(size) = self.terminal.size() {
self.terminal.set_viewport_area(ratatui::layout::Rect::new(
0,
0,
size.width,
size.height,
));
self.terminal.clear()?;
}
}
}
Ok(())
}
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
/// inline viewport for restoration when leaving.
pub fn enter_alt_screen(&mut self) -> Result<()> {
@@ -352,9 +462,8 @@ impl Tui {
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
// in the synchronized update.
#[cfg(unix)]
let mut prepared_resume = self
.suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
let mut prepared_resume =
self.prepare_resume_action(take_resume_action(&self.resume_pending))?;
// Precompute any viewport updates that need a cursor-position query before entering
// the synchronized update, to avoid racing with the event reader.
@@ -381,10 +490,12 @@ impl Tui {
}
}
stdout().sync_update(|_| {
std::io::stdout().sync_update(|_| {
#[cfg(unix)]
if let Some(prepared) = prepared_resume.take() {
prepared.apply(&mut self.terminal)?;
{
if let Some(prepared) = prepared_resume.take() {
self.apply_prepared_resume_action(prepared)?;
}
}
let terminal = &mut self.terminal;
if let Some(new_area) = pending_viewport_area.take() {
@@ -428,7 +539,8 @@ impl Tui {
} else {
area.bottom().saturating_sub(1)
};
self.suspend_context.set_cursor_y(inline_area_bottom);
self.suspend_cursor_y
.store(inline_area_bottom, Ordering::Relaxed);
}
terminal.draw(|frame| {
@@ -488,12 +600,12 @@ fn spawn_frame_scheduler(
pub struct PostNotification(pub String);
impl Command for PostNotification {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
write!(f, "\x1b]9;{}\x07", self.0)
}
#[cfg(windows)]
fn execute_winapi(&self) -> Result<()> {
fn execute_winapi(&self) -> std::io::Result<()> {
Err(std::io::Error::other(
"tried to execute PostNotification using WinAPI; use ANSI instead",
))

View File

@@ -1,182 +0,0 @@
use std::io::Result;
use std::io::stdout;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU16;
use std::sync::atomic::Ordering;
use crossterm::cursor::MoveTo;
use crossterm::cursor::Show;
use crossterm::event::KeyCode;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use ratatui::crossterm::execute;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use crate::key_hint;
use super::DisableAlternateScroll;
use super::EnableAlternateScroll;
use super::Terminal;
pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z'));
/// Coordinates suspend/resume handling so the TUI can restore terminal context after SIGTSTP.
///
/// On suspend, it records which resume path to take (realign inline viewport vs. restore alt
/// screen) and caches the inline cursor row so the cursor can be placed meaningfully before
/// yielding.
///
/// After resume, `prepare_resume_action` consumes the pending intent and returns a
/// `PreparedResumeAction` describing any viewport adjustments to apply inside the synchronized
/// draw.
///
/// Callers keep `suspend_cursor_y` up to date during normal drawing so the suspend step always
/// has the latest cursor position.
///
/// The type is `Clone`, using Arc/atomic internals so bookkeeping can be shared across tasks
/// and moved into the boxed `'static` event stream without borrowing `self`.
#[derive(Clone)]
pub struct SuspendContext {
/// Resume intent captured at suspend time; cleared once applied after resume.
resume_pending: Arc<Mutex<Option<ResumeAction>>>,
/// Inline viewport cursor row used to place the cursor before yielding during suspend.
suspend_cursor_y: Arc<AtomicU16>,
}
impl SuspendContext {
pub(crate) fn new() -> Self {
Self {
resume_pending: Arc::new(Mutex::new(None)),
suspend_cursor_y: Arc::new(AtomicU16::new(0)),
}
}
/// Capture how to resume, stash cursor position, and temporarily yield during SIGTSTP.
///
/// - If the alt screen is active, exit alt-scroll/alt-screen and record `RestoreAlt`;
/// otherwise record `RealignInline`.
/// - Update the cached inline cursor row so suspend can place the cursor meaningfully.
/// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state.
pub(crate) fn suspend(&self, alt_screen_active: &Arc<AtomicBool>) -> Result<()> {
if alt_screen_active.load(Ordering::Relaxed) {
// Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll.
let _ = execute!(stdout(), DisableAlternateScroll);
let _ = execute!(stdout(), LeaveAlternateScreen);
self.set_resume_action(ResumeAction::RestoreAlt);
} else {
self.set_resume_action(ResumeAction::RealignInline);
}
let y = self.suspend_cursor_y.load(Ordering::Relaxed);
let _ = execute!(stdout(), MoveTo(0, y), Show);
suspend_process()
}
/// Consume the pending resume intent and precompute any viewport changes needed post-resume.
///
/// Returns a `PreparedResumeAction` describing how to realign the viewport once drawing
/// resumes; returns `None` when there was no pending suspend intent.
pub(crate) fn prepare_resume_action(
&self,
terminal: &mut Terminal,
alt_saved_viewport: &mut Option<Rect>,
) -> Option<PreparedResumeAction> {
let action = self.take_resume_action()?;
match action {
ResumeAction::RealignInline => {
let cursor_pos = terminal
.get_cursor_position()
.unwrap_or(terminal.last_known_cursor_pos);
let viewport = Rect::new(0, cursor_pos.y, 0, 0);
Some(PreparedResumeAction::RealignViewport(viewport))
}
ResumeAction::RestoreAlt => {
if let Ok(Position { y, .. }) = terminal.get_cursor_position()
&& let Some(saved) = alt_saved_viewport.as_mut()
{
saved.y = y;
}
Some(PreparedResumeAction::RestoreAltScreen)
}
}
}
/// Set the cached inline cursor row so suspend can place the cursor meaningfully.
///
/// Call during normal drawing when the inline viewport moves so suspend has a fresh cursor
/// position to restore before yielding.
pub(crate) fn set_cursor_y(&self, value: u16) {
self.suspend_cursor_y.store(value, Ordering::Relaxed);
}
/// Record a pending resume action to apply after SIGTSTP returns control.
fn set_resume_action(&self, value: ResumeAction) {
*self
.resume_pending
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(value);
}
/// Take and clear any pending resume action captured at suspend time.
fn take_resume_action(&self) -> Option<ResumeAction> {
self.resume_pending
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
}
}
/// Captures what should happen when returning from suspend.
///
/// Either realign the inline viewport to keep the cursor position, or re-enter the alt screen
/// to restore the overlay UI.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub(crate) enum ResumeAction {
/// Shift the inline viewport to keep the cursor anchored after resume.
RealignInline,
/// Re-enter the alt screen and restore the overlay UI.
RestoreAlt,
}
/// Describes the viewport change to apply when resuming from suspend during the synchronized draw.
///
/// Either restore the alt screen (with viewport reset) or realign the inline viewport.
#[derive(Clone, Debug)]
pub(crate) enum PreparedResumeAction {
/// Re-enter the alt screen and reset the viewport to the terminal dimensions.
RestoreAltScreen,
/// Apply a viewport shift to keep the inline cursor position stable.
RealignViewport(Rect),
}
impl PreparedResumeAction {
pub(crate) fn apply(self, terminal: &mut Terminal) -> Result<()> {
match self {
PreparedResumeAction::RealignViewport(area) => {
terminal.set_viewport_area(area);
}
PreparedResumeAction::RestoreAltScreen => {
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
// Enable "alternate scroll" so terminals may translate wheel to arrows
execute!(terminal.backend_mut(), EnableAlternateScroll)?;
if let Ok(size) = terminal.size() {
terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height));
terminal.clear()?;
}
}
}
Ok(())
}
}
/// Deliver SIGTSTP after restoring terminal state, then re-applies terminal modes once resumed.
fn suspend_process() -> Result<()> {
super::restore()?;
unsafe { libc::kill(0, libc::SIGTSTP) };
// After the process resumes, reapply terminal modes so drawing can continue.
super::set_modes()?;
Ok(())
}

View File

@@ -1,101 +0,0 @@
/// Update action the CLI should perform after the TUI exits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// Update via `npm install -g @openai/codex@latest`.
NpmGlobalLatest,
/// Update via `bun install -g @openai/codex@latest`.
BunGlobalLatest,
/// Update via `brew upgrade codex`.
BrewUpgrade,
}
impl UpdateAction {
/// Returns the list of command-line arguments for invoking the update.
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
match self {
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]),
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]),
}
}
/// Returns string representation of the command-line arguments for invoking the update.
pub fn command_str(self) -> String {
let (command, args) = self.command_args();
shlex::try_join(std::iter::once(command).chain(args.iter().copied()))
.unwrap_or_else(|_| format!("{command} {}", args.join(" ")))
}
}
#[cfg(not(debug_assertions))]
pub(crate) fn get_update_action() -> Option<UpdateAction> {
let exe = std::env::current_exe().unwrap_or_default();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
detect_update_action(
cfg!(target_os = "macos"),
&exe,
managed_by_npm,
managed_by_bun,
)
}
#[cfg(any(not(debug_assertions), test))]
fn detect_update_action(
is_macos: bool,
current_exe: &std::path::Path,
managed_by_npm: bool,
managed_by_bun: bool,
) -> Option<UpdateAction> {
if managed_by_npm {
Some(UpdateAction::NpmGlobalLatest)
} else if managed_by_bun {
Some(UpdateAction::BunGlobalLatest)
} else if is_macos
&& (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local"))
{
Some(UpdateAction::BrewUpgrade)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_update_action_without_env_mutation() {
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, false),
None
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), true, false),
Some(UpdateAction::NpmGlobalLatest)
);
assert_eq!(
detect_update_action(false, std::path::Path::new("/any/path"), false, true),
Some(UpdateAction::BunGlobalLatest)
);
assert_eq!(
detect_update_action(
true,
std::path::Path::new("/opt/homebrew/bin/codex"),
false,
false
),
Some(UpdateAction::BrewUpgrade)
);
assert_eq!(
detect_update_action(
true,
std::path::Path::new("/usr/local/bin/codex"),
false,
false
),
Some(UpdateAction::BrewUpgrade)
);
}
}

View File

@@ -10,8 +10,8 @@ use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::updates;
use crate::updates::UpdateAction;
use codex_core::config::Config;
use color_eyre::Result;
use crossterm::event::KeyCode;
@@ -39,7 +39,7 @@ pub(crate) async fn run_update_prompt_if_needed(
let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else {
return Ok(UpdatePromptOutcome::Continue);
};
let Some(update_action) = crate::update_action::get_update_action() else {
let Some(update_action) = crate::updates::get_update_action() else {
return Ok(UpdatePromptOutcome::Continue);
};

View File

@@ -1,17 +1,14 @@
#![cfg(not(debug_assertions))]
use crate::update_action;
use crate::update_action::UpdateAction;
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use codex_core::config::Config;
use codex_core::default_client::create_client;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use codex_core::config::Config;
use codex_core::default_client::create_client;
use crate::version::CODEX_CLI_VERSION;
pub fn get_upgrade_version(config: &Config) -> Option<String> {
@@ -50,17 +47,14 @@ struct VersionInfo {
dismissed_version: Option<String>,
}
const VERSION_FILENAME: &str = "version.json";
// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
const HOMEBREW_CASK_URL: &str =
"https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb";
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
#[derive(Deserialize, Debug, Clone)]
struct ReleaseInfo {
tag_name: String,
}
const VERSION_FILENAME: &str = "version.json";
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
fn version_filepath(config: &Config) -> PathBuf {
config.codex_home.join(VERSION_FILENAME)
}
@@ -71,35 +65,23 @@ fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
}
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
let latest_version = match update_action::get_update_action() {
Some(UpdateAction::BrewUpgrade) => {
let cask_contents = create_client()
.get(HOMEBREW_CASK_URL)
.send()
.await?
.error_for_status()?
.text()
.await?;
extract_version_from_cask(&cask_contents)?
}
_ => {
let ReleaseInfo {
tag_name: latest_tag_name,
} = create_client()
.get(LATEST_RELEASE_URL)
.send()
.await?
.error_for_status()?
.json::<ReleaseInfo>()
.await?;
extract_version_from_latest_tag(&latest_tag_name)?
}
};
let ReleaseInfo {
tag_name: latest_tag_name,
} = create_client()
.get(LATEST_RELEASE_URL)
.send()
.await?
.error_for_status()?
.json::<ReleaseInfo>()
.await?;
// Preserve any previously dismissed version if present.
let prev_info = read_version_info(version_file).ok();
let info = VersionInfo {
latest_version,
latest_version: latest_tag_name
.strip_prefix("rust-v")
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))?
.into(),
last_checked_at: Utc::now(),
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
};
@@ -119,25 +101,6 @@ fn is_newer(latest: &str, current: &str) -> Option<bool> {
}
}
fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
cask_contents
.lines()
.find_map(|line| {
let line = line.trim();
line.strip_prefix("version \"")
.and_then(|rest| rest.strip_suffix('"'))
.map(ToString::to_string)
})
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
}
fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
latest_tag_name
.strip_prefix("rust-v")
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
}
/// Returns the latest version to show in a popup, if it should be shown.
/// This respects the user's dismissal choice for the current latest version.
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
@@ -177,36 +140,57 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
Some((maj, min, pat))
}
/// Update action the CLI should perform after the TUI exits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// Update via `npm install -g @openai/codex@latest`.
NpmGlobalLatest,
/// Update via `bun install -g @openai/codex@latest`.
BunGlobalLatest,
/// Update via `brew upgrade codex`.
BrewUpgrade,
}
#[cfg(any(not(debug_assertions), test))]
pub(crate) fn get_update_action() -> Option<UpdateAction> {
let exe = std::env::current_exe().unwrap_or_default();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
if managed_by_npm {
Some(UpdateAction::NpmGlobalLatest)
} else if managed_by_bun {
Some(UpdateAction::BunGlobalLatest)
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
{
Some(UpdateAction::BrewUpgrade)
} else {
None
}
}
impl UpdateAction {
/// Returns the list of command-line arguments for invoking the update.
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
match self {
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]),
}
}
/// Returns string representation of the command-line arguments for invoking the update.
pub fn command_str(self) -> String {
let (command, args) = self.command_args();
let args_str = args.join(" ");
format!("{command} {args_str}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_version_from_cask_contents() {
let cask = r#"
cask "codex" do
version "0.55.0"
end
"#;
assert_eq!(
extract_version_from_cask(cask).expect("failed to parse version"),
"0.55.0"
);
}
#[test]
fn extracts_version_from_latest_tag() {
assert_eq!(
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
"1.5.0"
);
}
#[test]
fn latest_tag_without_prefix_is_invalid() {
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
}
#[test]
fn prerelease_version_is_not_considered_newer() {
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
@@ -226,4 +210,24 @@ mod tests {
assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
}
#[test]
fn test_get_update_action() {
let prev = std::env::var_os("CODEX_MANAGED_BY_NPM");
// First: no npm var -> expect None (we do not run from brew in CI)
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
assert_eq!(get_update_action(), None);
// Then: with npm var -> expect NpmGlobalLatest
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") };
assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest));
// Restore prior value to avoid leaking state
if let Some(v) = prev {
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) };
} else {
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
}
}
}

View File

@@ -111,7 +111,6 @@ pub async fn spawn_pty_process(
args: &[String],
cwd: &Path,
env: &HashMap<String, String>,
arg0: &Option<String>,
) -> Result<SpawnedPty> {
if program.is_empty() {
anyhow::bail!("missing program for PTY spawn");
@@ -125,7 +124,7 @@ pub async fn spawn_pty_process(
pixel_height: 0,
})?;
let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string()));
let mut command_builder = CommandBuilder::new(program);
command_builder.cwd(cwd);
command_builder.env_clear();
for arg in args {

View File

@@ -11,7 +11,6 @@ path = "src/lib.rs"
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dunce = "1.0"
[dependencies.rand]
version = "0.8"
default-features = false

View File

@@ -1,5 +1,6 @@
use crate::token::world_sid;
use crate::winutil::to_wide;
use anyhow::anyhow;
use anyhow::Result;
use std::collections::HashSet;
use std::ffi::c_void;
@@ -37,22 +38,6 @@ use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE;
use windows_sys::Win32::Security::ACE_HEADER;
use windows_sys::Win32::Security::EqualSid;
// Preflight scan limits
const MAX_ITEMS_PER_DIR: i32 = 1000;
const AUDIT_TIME_LIMIT_SECS: i64 = 2;
const MAX_CHECKED_LIMIT: i32 = 50000;
// Case-insensitive suffixes (normalized to forward slashes) to skip during one-level child scan
const SKIP_DIR_SUFFIXES: &[&str] = &[
"/windows/installer",
"/windows/registration",
"/programdata",
];
fn normalize_path_key(p: &Path) -> String {
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
}
fn unique_push(set: &mut HashSet<PathBuf>, out: &mut Vec<PathBuf>, p: PathBuf) {
if let Ok(abs) = p.canonicalize() {
if set.insert(abs.clone()) {
@@ -92,7 +77,11 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap<String, String>
}
}
// 5) Core system roots last
for p in [PathBuf::from("C:/"), PathBuf::from("C:/Windows")] {
for p in [
PathBuf::from("C:/"),
PathBuf::from("C:/Windows"),
PathBuf::from("C:/ProgramData"),
] {
unique_push(&mut set, &mut out, p);
}
out
@@ -175,18 +164,14 @@ unsafe fn path_has_world_write_allow(path: &Path) -> Result<bool> {
pub fn audit_everyone_writable(
cwd: &Path,
env: &std::collections::HashMap<String, String>,
logs_base_dir: Option<&Path>,
) -> Result<Vec<PathBuf>> {
) -> Result<()> {
let start = Instant::now();
let mut flagged: Vec<PathBuf> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut checked = 0usize;
// Fast path: check CWD immediate children first so workspace issues are caught early.
if let Ok(read) = std::fs::read_dir(cwd) {
for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) {
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|| checked > MAX_CHECKED_LIMIT as usize
{
for ent in read.flatten().take(250) {
if start.elapsed() > Duration::from_secs(5) || checked > 5000 {
break;
}
let ft = match ent.file_type() {
@@ -200,32 +185,26 @@ pub fn audit_everyone_writable(
checked += 1;
let has = unsafe { path_has_world_write_allow(&p)? };
if has {
let key = normalize_path_key(&p);
if seen.insert(key) { flagged.push(p); }
flagged.push(p);
}
}
}
// Continue with broader candidate sweep
let candidates = gather_candidates(cwd, env);
for root in candidates {
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|| checked > MAX_CHECKED_LIMIT as usize
{
if start.elapsed() > Duration::from_secs(5) || checked > 5000 {
break;
}
checked += 1;
let has_root = unsafe { path_has_world_write_allow(&root)? };
if has_root {
let key = normalize_path_key(&root);
if seen.insert(key) { flagged.push(root.clone()); }
flagged.push(root.clone());
}
// one level down best-effort
if let Ok(read) = std::fs::read_dir(&root) {
for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) {
for ent in read.flatten().take(250) {
let p = ent.path();
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|| checked > MAX_CHECKED_LIMIT as usize
{
if start.elapsed() > Duration::from_secs(5) || checked > 5000 {
break;
}
// Skip reparse points (symlinks/junctions) to avoid auditing link ACLs
@@ -236,16 +215,11 @@ pub fn audit_everyone_writable(
if ft.is_symlink() {
continue;
}
// Skip noisy/irrelevant Windows system subdirectories
let pl = p.to_string_lossy().to_ascii_lowercase();
let norm = pl.replace('\\', "/");
if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) { continue; }
if ft.is_dir() {
checked += 1;
let has_child = unsafe { path_has_world_write_allow(&p)? };
if has_child {
let key = normalize_path_key(&p);
if seen.insert(key) { flagged.push(p); }
flagged.push(p);
}
}
}
@@ -262,18 +236,25 @@ pub fn audit_everyone_writable(
"AUDIT: world-writable scan FAILED; checked={checked}; duration_ms={elapsed_ms}; flagged:{}",
list
),
logs_base_dir,
Some(cwd),
);
return Ok(flagged);
let mut list_err = String::new();
for p in flagged {
list_err.push_str(&format!("\n - {}", p.display()));
}
return Err(anyhow!(
"Refusing to run: found directories writable by Everyone: {}",
list_err
));
}
// Log success once if nothing flagged
crate::logging::log_note(
&format!(
"AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}"
),
logs_base_dir,
Some(cwd),
);
Ok(Vec::new())
Ok(())
}
// Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for
// Everyone that includes generic or specific write bits? Skips inherit-only

View File

@@ -171,9 +171,8 @@ mod windows_impl {
pub fn preflight_audit_everyone_writable(
cwd: &Path,
env_map: &HashMap<String, String>,
logs_base_dir: Option<&Path>,
) -> Result<Vec<PathBuf>> {
audit::audit_everyone_writable(cwd, env_map, logs_base_dir)
) -> Result<()> {
audit::audit_everyone_writable(cwd, env_map)
}
pub fn run_windows_sandbox_capture(
@@ -437,8 +436,7 @@ mod stub {
pub fn preflight_audit_everyone_writable(
_cwd: &Path,
_env_map: &HashMap<String, String>,
_logs_base_dir: Option<&Path>,
) -> Result<Vec<std::path::PathBuf>> {
) -> Result<()> {
bail!("Windows sandbox is only available on Windows")
}

View File

@@ -347,13 +347,11 @@ Use the optional `[tools]` table to toggle built-in tools that the agent may cal
```toml
[tools]
web_search = true # allow Codex to issue first-party web searches without prompting you (deprecated)
web_search = true # allow Codex to issue first-party web searches without prompting you
view_image = false # disable image uploads (they're enabled by default)
```
`web_search` is deprecated; use the `web_search_request` feature flag instead.
The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
`web_search` is also recognized under the legacy name `web_search_request`. The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
### approval_presets
@@ -919,7 +917,6 @@ Valid values:
| `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). |
| `notify` | array<string> | External program for notifications. |
| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. |
| `features.<feature-flag>` | boolean | See [feature flags](#feature-flags) for details |
| `mcp_servers.<id>.command` | string | MCP server launcher command (stdio servers only). |
| `mcp_servers.<id>.args` | array<string> | MCP server args (stdio servers only). |
| `mcp_servers.<id>.env` | map<string,string> | MCP server env vars (stdio servers only). |
@@ -959,7 +956,7 @@ Valid values:
| `experimental_instructions_file` | string (path) | Replace builtin instructions (experimental). |
| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
| `tools.web_search` | boolean | Enable web search tool (deprecated) (default: false). |
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
| `tools.view_image` | boolean | Enable or disable the `view_image` tool so Codex can attach local image files from the workspace (default: true). |
| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. |
| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. |

View File

@@ -159,7 +159,6 @@ windows_wsl_setup_acknowledged = false
# In-product notices (mostly set automatically by Codex).
[notice]
# hide_full_access_warning = true
# hide_rate_limit_model_nudge = true
################################################################################
# Authentication & Login

View File

@@ -42,55 +42,16 @@ Custom prompts turn your repeatable instructions into reusable slash commands, s
### Examples
### Example 1: Basic named arguments
**Draft PR helper**
**File**: `~/.codex/prompts/ticket.md`
`~/.codex/prompts/draftpr.md`
```markdown
---
description: Generate a commit message for a ticket
argument-hint: TICKET_ID=<id> TICKET_TITLE=<title>
description: Create feature branch, commit and open draft PR.
---
Please write a concise commit message for ticket $TICKET_ID: $TICKET_TITLE
Create a branch named `tibo/<feature_name>`, commit the changes, and open a draft PR.
```
**Usage**:
```
/prompts:ticket TICKET_ID=JIRA-1234 TICKET_TITLE="Fix login bug"
```
**Expanded prompt sent to Codex**:
```
Please write a concise commit message for ticket JIRA-1234: Fix login bug
```
**Note**: Both `TICKET_ID` and `TICKET_TITLE` are required. If either is missing, Codex will show a validation error. Values with spaces must be double-quoted.
### Example 2: Mixed positional and named arguments
**File**: `~/.codex/prompts/review.md`
```markdown
---
description: Review code in a specific file with focus area
argument-hint: FILE=<path> [FOCUS=<section>]
---
Review the code in $FILE. Pay special attention to $FOCUS.
```
**Usage**:
```
/prompts:review FILE=src/auth.js FOCUS="error handling"
```
**Expanded prompt**:
```
Review the code in src/auth.js. Pay special attention to error handling.
```
Usage: type `/prompts:draftpr` to have codex perform the work.

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