From 893f5261eb620b9fd36ec61cfcae929ceb11b1cd Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 17:43:53 -0800 Subject: [PATCH 01/19] feat: support mcp in-session login (#7751) ### Summary * Added `mcpServer/oauthLogin` in app server for supporting in session MCP server login * Added `McpServerOauthLoginParams` and `McpServerOauthLoginResponse` to support above method with response returning the auth URL for consumer to open browser or display accordingly. * Added `McpServerOauthLoginCompletedNotification` which the app server would emit on MCP server login success or failure (i.e. timeout). * Refactored rmcp-client oath_login to have the ability on starting a auth server which the codex_message_processor uses for in-session auth. --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 6 + .../app-server-protocol/src/protocol/v2.rs | 31 ++ codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 126 ++++++++ codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/rmcp-client/src/lib.rs | 2 + .../rmcp-client/src/perform_oauth_login.rs | 283 ++++++++++++++---- 8 files changed, 392 insertions(+), 59 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 58ba4f2a96..9a3cd95dfa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "codex-file-search", "codex-login", "codex-protocol", + "codex-rmcp-client", "codex-utils-json-to-toml", "core_test_support", "mcp-types", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2858366739..c62acc8832 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -139,6 +139,11 @@ client_request_definitions! { response: v2::ModelListResponse, }, + McpServerOauthLogin => "mcpServer/oauth/login" { + params: v2::McpServerOauthLoginParams, + response: v2::McpServerOauthLoginResponse, + }, + McpServersList => "mcpServers/list" { params: v2::ListMcpServersParams, response: v2::ListMcpServersResponse, @@ -524,6 +529,7 @@ server_notification_definitions! { CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), + McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ea70b805b0..dbef55ed15 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -688,6 +688,26 @@ pub struct ListMcpServersResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1467,6 +1487,17 @@ pub struct McpToolCallProgressNotification { pub message: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 99d5a7a141..e4a326a2c3 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -26,6 +26,7 @@ codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } +codex-rmcp-client = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 65721a698e..0a8445055d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -55,6 +55,9 @@ use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::McpServer; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginParams; +use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -115,6 +118,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::load_config_as_toml; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; @@ -147,6 +151,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::perform_oauth_login_return_url; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::collections::HashSet; @@ -161,6 +166,7 @@ use std::time::Duration; use tokio::select; use tokio::sync::Mutex; use tokio::sync::oneshot; +use toml::Value as TomlValue; use tracing::error; use tracing::info; use tracing::warn; @@ -198,6 +204,7 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. @@ -244,6 +251,7 @@ impl CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, feedback: CodexFeedback, ) -> Self { Self { @@ -252,6 +260,7 @@ impl CodexMessageProcessor { outgoing, codex_linux_sandbox_exe, config, + cli_overrides, conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -261,6 +270,16 @@ impl CodexMessageProcessor { } } + async fn load_latest_config(&self) -> Result { + Config::load_with_cli_overrides(self.cli_overrides.clone(), ConfigOverrides::default()) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to reload config: {err}"), + data: None, + }) + } + fn review_request_from_target( target: ApiReviewTarget, ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { @@ -369,6 +388,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServerOauthLogin { request_id, params } => { + self.mcp_server_oauth_login(request_id, params).await; + } ClientRequest::McpServersList { request_id, params } => { self.list_mcp_servers(request_id, params).await; } @@ -1916,6 +1938,110 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn mcp_server_oauth_login( + &self, + request_id: RequestId, + params: McpServerOauthLoginParams, + ) { + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if !config.features.enabled(Feature::RmcpClient) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported when [features].rmcp_client is true in config.toml".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let McpServerOauthLoginParams { + name, + scopes, + timeout_secs, + } = params; + + let Some(server) = config.mcp_servers.get(&name) else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("No MCP server named '{name}' found."), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "OAuth login is only supported for streamable HTTP servers." + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match perform_oauth_login_return_url( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + scopes.as_deref().unwrap_or_default(), + timeout_secs, + ) + .await + { + Ok(handle) => { + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); + + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + + let response = McpServerOauthLoginResponse { authorization_url }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to login to MCP server '{name}': {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 90560e9b3c..6a6cf5edb2 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -59,6 +59,7 @@ impl MessageProcessor { outgoing.clone(), codex_linux_sandbox_exe, Arc::clone(&config), + cli_overrides.clone(), feedback, ); let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides); diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index ac617f3d29..954898cea4 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -16,7 +16,9 @@ pub use oauth::WrappedOAuthTokenResponse; pub use oauth::delete_oauth_tokens; pub(crate) use oauth::load_oauth_tokens; pub use oauth::save_oauth_tokens; +pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; +pub use perform_oauth_login::perform_oauth_login_return_url; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index d8ffdd3949..9815a3a22d 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -22,6 +22,11 @@ use crate::save_oauth_tokens; use crate::utils::apply_default_headers; use crate::utils::build_default_headers; +struct OauthHeaders { + http_headers: Option>, + env_http_headers: Option>, +} + struct CallbackServerGuard { server: Arc, } @@ -40,70 +45,52 @@ pub async fn perform_oauth_login( env_http_headers: Option>, scopes: &[String], ) -> Result<()> { - let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); - let guard = CallbackServerGuard { - server: Arc::clone(&server), + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + true, + None, + ) + .await? + .finish() + .await +} - let redirect_uri = match server.server_addr() { - tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { - format!("http://{}:{}/callback", addr.ip(), addr.port()) - } - tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { - format!("http://[{}]:{}/callback", addr.ip(), addr.port()) - } - #[cfg(not(target_os = "windows"))] - _ => return Err(anyhow!("unable to determine callback address")), +pub async fn perform_oauth_login_return_url( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + timeout_secs: Option, +) -> Result { + let headers = OauthHeaders { + http_headers, + env_http_headers, }; + let flow = OauthLoginFlow::new( + server_name, + server_url, + store_mode, + headers, + scopes, + false, + timeout_secs, + ) + .await?; - let (tx, rx) = oneshot::channel(); - spawn_callback_server(server, tx); + let authorization_url = flow.authorization_url(); + let completion = flow.spawn(); - let default_headers = build_default_headers(http_headers, env_http_headers)?; - let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; - - let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; - let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); - oauth_state - .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) - .await?; - let auth_url = oauth_state.get_authorization_url().await?; - - println!("Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n"); - - if webbrowser::open(&auth_url).is_err() { - println!("(Browser launch failed; please copy the URL above manually.)"); - } - - let (code, csrf_state) = timeout(Duration::from_secs(300), rx) - .await - .context("timed out waiting for OAuth callback")? - .context("OAuth callback was cancelled")?; - - oauth_state - .handle_callback(&code, &csrf_state) - .await - .context("failed to handle OAuth callback")?; - - let (client_id, credentials_opt) = oauth_state - .get_credentials() - .await - .context("failed to retrieve OAuth credentials")?; - let credentials = - credentials_opt.ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; - - let expires_at = compute_expires_at_millis(&credentials); - let stored = StoredOAuthTokens { - server_name: server_name.to_string(), - url: server_url.to_string(), - client_id, - token_response: WrappedOAuthTokenResponse(credentials), - expires_at, - }; - save_oauth_tokens(server_name, &stored, store_mode)?; - - drop(guard); - Ok(()) + Ok(OauthLoginHandle::new(authorization_url, completion)) } fn spawn_callback_server(server: Arc, tx: oneshot::Sender<(String, String)>) { @@ -160,3 +147,181 @@ fn parse_oauth_callback(path: &str) -> Option { state: state?, }) } + +pub struct OauthLoginHandle { + authorization_url: String, + completion: oneshot::Receiver>, +} + +impl OauthLoginHandle { + fn new(authorization_url: String, completion: oneshot::Receiver>) -> Self { + Self { + authorization_url, + completion, + } + } + + pub fn authorization_url(&self) -> &str { + &self.authorization_url + } + + pub fn into_parts(self) -> (String, oneshot::Receiver>) { + (self.authorization_url, self.completion) + } + + pub async fn wait(self) -> Result<()> { + self.completion + .await + .map_err(|err| anyhow!("OAuth login task was cancelled: {err}"))? + } +} + +struct OauthLoginFlow { + auth_url: String, + oauth_state: OAuthState, + rx: oneshot::Receiver<(String, String)>, + guard: CallbackServerGuard, + server_name: String, + server_url: String, + store_mode: OAuthCredentialsStoreMode, + launch_browser: bool, + timeout: Duration, +} + +impl OauthLoginFlow { + async fn new( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + headers: OauthHeaders, + scopes: &[String], + launch_browser: bool, + timeout_secs: Option, + ) -> Result { + const DEFAULT_OAUTH_TIMEOUT_SECS: i64 = 300; + + let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); + let guard = CallbackServerGuard { + server: Arc::clone(&server), + }; + + let redirect_uri = match server.server_addr() { + tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://{ip}:{port}/callback") + } + tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { + let ip = addr.ip(); + let port = addr.port(); + format!("http://[{ip}]:{port}/callback") + } + #[cfg(not(target_os = "windows"))] + _ => return Err(anyhow!("unable to determine callback address")), + }; + + let (tx, rx) = oneshot::channel(); + spawn_callback_server(server, tx); + + let OauthHeaders { + http_headers, + env_http_headers, + } = headers; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; + let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); + oauth_state + .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) + .await?; + let auth_url = oauth_state.get_authorization_url().await?; + let timeout_secs = timeout_secs.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS).max(1); + let timeout = Duration::from_secs(timeout_secs as u64); + + Ok(Self { + auth_url, + oauth_state, + rx, + guard, + server_name: server_name.to_string(), + server_url: server_url.to_string(), + store_mode, + launch_browser, + timeout, + }) + } + + fn authorization_url(&self) -> String { + self.auth_url.clone() + } + + async fn finish(mut self) -> Result<()> { + if self.launch_browser { + let server_name = &self.server_name; + let auth_url = &self.auth_url; + println!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + + if webbrowser::open(auth_url).is_err() { + println!("(Browser launch failed; please copy the URL above manually.)"); + } + } + + let result = async { + let (code, csrf_state) = timeout(self.timeout, &mut self.rx) + .await + .context("timed out waiting for OAuth callback")? + .context("OAuth callback was cancelled")?; + + self.oauth_state + .handle_callback(&code, &csrf_state) + .await + .context("failed to handle OAuth callback")?; + + let (client_id, credentials_opt) = self + .oauth_state + .get_credentials() + .await + .context("failed to retrieve OAuth credentials")?; + let credentials = credentials_opt + .ok_or_else(|| anyhow!("OAuth provider did not return credentials"))?; + + let expires_at = compute_expires_at_millis(&credentials); + let stored = StoredOAuthTokens { + server_name: self.server_name.clone(), + url: self.server_url.clone(), + client_id, + token_response: WrappedOAuthTokenResponse(credentials), + expires_at, + }; + save_oauth_tokens(&self.server_name, &stored, self.store_mode)?; + + Ok(()) + } + .await; + + drop(self.guard); + result + } + + fn spawn(self) -> oneshot::Receiver> { + let server_name_for_logging = self.server_name.clone(); + let (tx, rx) = oneshot::channel(); + + tokio::spawn(async move { + let result = self.finish().await; + + if let Err(err) = &result { + eprintln!( + "Failed to complete OAuth login for '{server_name_for_logging}': {err:#}" + ); + } + + let _ = tx.send(result); + }); + + rx + } +} From 967d063f4bd50c6c7b8402c504e5c2045f3d2165 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 9 Dec 2025 18:30:16 -0800 Subject: [PATCH 02/19] parse rg | head a search (#7797) --- codex-rs/core/src/parse_command.rs | 187 +++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 52 deletions(-) diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index f335347042..399513f5ae 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -117,9 +117,6 @@ mod tests { query: None, path: None, }, - ParsedCommand::Unknown { - cmd: "head -n 40".to_string(), - }, ], ); } @@ -143,16 +140,11 @@ mod tests { let inner = "rg -n \"BUG|FIXME|TODO|XXX|HACK\" -S | head -n 200"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), - query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 200".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg -n 'BUG|FIXME|TODO|XXX|HACK' -S".to_string(), + query: Some("BUG|FIXME|TODO|XXX|HACK".to_string()), + path: None, + }], ); } @@ -174,16 +166,11 @@ mod tests { let inner = "rg --files | head -n 50"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 50".to_string(), - }, - ], + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -273,6 +260,19 @@ mod tests { ); } + #[test] + fn supports_head_file_only() { + let inner = "head Cargo.toml"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), + }], + ); + } + #[test] fn supports_cat_sed_n() { let inner = "cat tui/Cargo.toml | sed -n '1,200p'"; @@ -313,6 +313,19 @@ mod tests { ); } + #[test] + fn supports_tail_file_only() { + let inner = "tail README.md"; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Read { + cmd: inner.to_string(), + name: "README.md".to_string(), + path: PathBuf::from("README.md"), + }], + ); + } + #[test] fn supports_npm_run_build_is_unknown() { assert_parsed( @@ -391,6 +404,19 @@ mod tests { ); } + #[test] + fn supports_single_string_script_with_cd_and_pipe() { + let inner = r#"cd /Users/pakrym/code/codex && rg -n "codex_api" codex-rs -S | head -n 50"#; + assert_parsed( + &vec_str(&["bash", "-lc", inner]), + vec![ParsedCommand::Search { + cmd: "rg -n codex_api codex-rs -S".to_string(), + query: Some("codex_api".to_string()), + path: Some("codex-rs".to_string()), + }], + ); + } + // ---- is_small_formatting_command unit tests ---- #[test] fn small_formatting_always_true_commands() { @@ -408,38 +434,43 @@ mod tests { fn head_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["head"]))); - // Numeric count only -> not considered small formatting by implementation - assert!(!is_small_formatting_command(&shlex_split_safe( - "head -n 40" - ))); + // Numeric count only -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("head -n 40"))); // With explicit file -> not small formatting assert!(!is_small_formatting_command(&shlex_split_safe( "head -n 40 file.txt" ))); - // File only (no count) -> treated as small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["head", "file.txt"]))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "head", "file.txt" + ]))); } #[test] fn tail_behavior() { // No args -> small formatting assert!(is_small_formatting_command(&vec_str(&["tail"]))); - // Numeric with plus offset -> not small formatting - assert!(!is_small_formatting_command(&shlex_split_safe( + // Numeric with plus offset -> formatting + assert!(is_small_formatting_command(&shlex_split_safe( "tail -n +10" ))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n +10 file.txt" ))); - // Numeric count - assert!(!is_small_formatting_command(&shlex_split_safe( - "tail -n 30" - ))); + // Numeric count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -n 30"))); assert!(!is_small_formatting_command(&shlex_split_safe( "tail -n 30 file.txt" ))); - // File only -> small formatting by implementation - assert!(is_small_formatting_command(&vec_str(&["tail", "file.txt"]))); + // Byte count -> formatting + assert!(is_small_formatting_command(&shlex_split_safe("tail -c 30"))); + assert!(is_small_formatting_command(&shlex_split_safe( + "tail -c +10" + ))); + // File only (no count) -> not formatting + assert!(!is_small_formatting_command(&vec_str(&[ + "tail", "file.txt" + ]))); } #[test] @@ -714,20 +745,15 @@ mod tests { #[test] fn bash_dash_c_pipeline_parsing() { - // Ensure -c is handled similarly to -lc by normalization + // Ensure -c is handled similarly to -lc by shell parsing let inner = "rg --files | head -n 1"; assert_parsed( - &shlex_split_safe(inner), - vec![ - ParsedCommand::Search { - cmd: "rg --files".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "head -n 1".to_string(), - }, - ], + &vec_str(&["bash", "-c", inner]), + vec![ParsedCommand::Search { + cmd: "rg --files".to_string(), + query: None, + path: None, + }], ); } @@ -1384,13 +1410,50 @@ fn is_small_formatting_command(tokens: &[String]) -> bool { // Treat as formatting when no explicit file operand is present. // Common forms: `head -n 40`, `head -c 100`. // Keep cases like `head -n 40 file`. - tokens.len() < 3 + match tokens { + // `head` + [_] => true, + // `head ` or `head -n50`/`head -c100` + [_, arg] => arg.starts_with('-'), + // `head -n 40` / `head -c 100` (no file operand) + [_, flag, count] + if (flag == "-n" || flag == "-c") + && count.chars().all(|c| c.is_ascii_digit()) => + { + true + } + _ => false, + } } "tail" => { // Treat as formatting when no explicit file operand is present. - // Common forms: `tail -n +10`, `tail -n 30`. + // Common forms: `tail -n +10`, `tail -n 30`, `tail -c 100`. // Keep cases like `tail -n 30 file`. - tokens.len() < 3 + match tokens { + // `tail` + [_] => true, + // `tail ` or `tail -n30`/`tail -n+10` + [_, arg] => arg.starts_with('-'), + // `tail -n 30` / `tail -n +10` (no file operand) + [_, flag, count] + if flag == "-n" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + // `tail -c 100` / `tail -c +10` (no file operand) + [_, flag, count] + if flag == "-c" + && (count.chars().all(|c| c.is_ascii_digit()) + || (count.starts_with('+') + && count[1..].chars().all(|c| c.is_ascii_digit()))) => + { + true + } + _ => false, + } } "sed" => { // Keep `sed -n file` (treated as a file read elsewhere); @@ -1543,6 +1606,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } @@ -1587,6 +1660,16 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }; } } + if let [path] = tail + && !path.starts_with('-') + { + let name = short_display_path(path); + return ParsedCommand::Read { + cmd: shlex_join(main_cmd), + name, + path: PathBuf::from(path), + }; + } ParsedCommand::Unknown { cmd: shlex_join(main_cmd), } From fc4249313b723102c82e59272efcc4ad27609ae3 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 9 Dec 2025 19:00:33 -0800 Subject: [PATCH 03/19] Elevated Sandbox 1 (#7788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updating helpers, refactoring some functions that will be used in the elevated sandbox - better logging - better and faster handling of ACL checks/writes - No functional change—legacy restricted-token sandbox remains the only path. --- codex-rs/Cargo.lock | 1 + codex-rs/windows-sandbox-rs/Cargo.toml | 1 + codex-rs/windows-sandbox-rs/src/acl.rs | 346 ++++++++++++++++++--- codex-rs/windows-sandbox-rs/src/audit.rs | 180 ++--------- codex-rs/windows-sandbox-rs/src/env.rs | 34 +- codex-rs/windows-sandbox-rs/src/logging.rs | 24 +- codex-rs/windows-sandbox-rs/src/process.rs | 42 ++- codex-rs/windows-sandbox-rs/src/token.rs | 64 +++- codex-rs/windows-sandbox-rs/src/winutil.rs | 20 ++ 9 files changed, 480 insertions(+), 232 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9a3cd95dfa..bca96ff631 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1727,6 +1727,7 @@ name = "codex-windows-sandbox" version = "0.0.0" dependencies = [ "anyhow", + "chrono", "codex-protocol", "dirs-next", "dunce", diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 29306371b2..1b936f05ca 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0" +chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] } dunce = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index f2e1e09480..34d523d1f5 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -1,4 +1,4 @@ -use crate::winutil::to_wide; +use crate::winutil::to_wide; use anyhow::anyhow; use anyhow::Result; use std::ffi::c_void; @@ -9,6 +9,7 @@ use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Security::AclSizeInformation; +use windows_sys::Win32::Security::Authorization::GetEffectiveRightsFromAclW; use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::GetSecurityInfo; use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; @@ -21,28 +22,148 @@ use windows_sys::Win32::Security::Authorization::TRUSTEE_W; use windows_sys::Win32::Security::EqualSid; use windows_sys::Win32::Security::GetAce; use windows_sys::Win32::Security::GetAclInformation; +use windows_sys::Win32::Security::MapGenericMask; use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; use windows_sys::Win32::Security::ACE_HEADER; use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; +use windows_sys::Win32::Security::GENERIC_MAPPING; use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; +use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; +use windows_sys::Win32::Storage::FileSystem::READ_CONTROL; const SE_KERNEL_OBJECT: u32 = 6; const INHERIT_ONLY_ACE: u8 = 0x08; const GENERIC_WRITE_MASK: u32 = 0x4000_0000; const DENY_ACCESS: i32 = 3; +/// Fetch DACL via handle-based query; caller must LocalFree the returned SD. +pub unsafe fn fetch_dacl_handle(path: &Path) -> Result<(*mut ACL, *mut c_void)> { + let wpath = to_wide(path); + let h = CreateFileW( + wpath.as_ptr(), + READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null_mut(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + 0, + ); + if h == INVALID_HANDLE_VALUE { + return Err(anyhow!("CreateFileW failed for {}", path.display())); + } + let mut p_sd: *mut c_void = std::ptr::null_mut(); + let mut p_dacl: *mut ACL = std::ptr::null_mut(); + let code = GetSecurityInfo( + h, + 1, // SE_FILE_OBJECT + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut p_dacl, + std::ptr::null_mut(), + &mut p_sd, + ); + CloseHandle(h); + if code != ERROR_SUCCESS { + return Err(anyhow!( + "GetSecurityInfo failed for {}: {}", + path.display(), + code + )); + } + Ok((p_dacl, p_sd)) +} + +/// Fast mask-based check: does any ACE for provided SIDs grant at least one desired bit? Skips inherit-only. +pub unsafe fn dacl_quick_mask_allows( + p_dacl: *mut ACL, + psids: &[*mut c_void], + desired_mask: u32, +) -> bool { + if p_dacl.is_null() { + return false; + } + let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); + let ok = GetAclInformation( + p_dacl as *const ACL, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + AclSizeInformation, + ); + if ok == 0 { + return false; + } + let mapping = GENERIC_MAPPING { + GenericRead: FILE_GENERIC_READ, + GenericWrite: FILE_GENERIC_WRITE, + GenericExecute: FILE_GENERIC_EXECUTE, + GenericAll: FILE_ALL_ACCESS, + }; + for i in 0..(info.AceCount as usize) { + let mut p_ace: *mut c_void = std::ptr::null_mut(); + if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { + continue; + } + let hdr = &*(p_ace as *const ACE_HEADER); + if hdr.AceType != 0 { + continue; // not ACCESS_ALLOWED + } + if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { + continue; + } + let base = p_ace as usize; + let sid_ptr = + (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; + let mut matched = false; + for sid in psids { + if EqualSid(sid_ptr, *sid) != 0 { + matched = true; + break; + } + } + if !matched { + continue; + } + let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); + let mut mask = ace.Mask; + MapGenericMask(&mut mask, &mapping); + if (mask & desired_mask) != 0 { + return true; + } + } + false +} + +/// Path-based wrapper around the quick mask check (single DACL fetch). +pub fn path_quick_mask_allows( + path: &Path, + psids: &[*mut c_void], + desired_mask: u32, +) -> Result { + unsafe { + let (p_dacl, sd) = fetch_dacl_handle(path)?; + let has = dacl_quick_mask_allows(p_dacl, psids, desired_mask); + if !sd.is_null() { + LocalFree(sd as HLOCAL); + } + Ok(has) + } +} + pub unsafe fn dacl_has_write_allow_for_sid(p_dacl: *mut ACL, psid: *mut c_void) -> bool { if p_dacl.is_null() { return false; @@ -131,6 +252,44 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) - // This accounts for deny ACEs and ordering; falls back to a conservative per-ACE scan if the API fails. #[allow(dead_code)] pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) -> bool { + if p_dacl.is_null() { + return false; + } + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut access: u32 = 0; + let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); + if ok == ERROR_SUCCESS { + // Map generic bits to avoid “missing” write when generic permissions are present. + let mut mapped_access = access; + if (access & GENERIC_WRITE_MASK) != 0 { + mapped_access |= FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA; + } + if (access & READ_CONTROL) != 0 { + mapped_access |= FILE_GENERIC_READ; + } + let write_bits = FILE_GENERIC_WRITE + | FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES; + return (mapped_access & write_bits) != 0; + } + // Fallback: simple allow ACE scan (already ignores inherit-only) + dacl_has_write_allow_for_sid(p_dacl, psid) +} + +#[allow(dead_code)] +pub unsafe fn dacl_effective_allows_mask( + p_dacl: *mut ACL, + psid: *mut c_void, + desired_mask: u32, +) -> bool { if p_dacl.is_null() { return false; } @@ -148,18 +307,100 @@ pub unsafe fn dacl_effective_allows_write(p_dacl: *mut ACL, psid: *mut c_void) - }; let mut access: u32 = 0; let ok = GetEffectiveRightsFromAclW(p_dacl, &trustee, &mut access); - if ok != 0 { - // Check for generic or specific write bits - let write_bits = FILE_GENERIC_WRITE - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA - | windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA - | windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; - return (access & write_bits) != 0; + if ok == ERROR_SUCCESS { + // Map generic bits to avoid “missing” when generic permissions are present. + let mut mapped_access = access; + if (access & GENERIC_WRITE_MASK) != 0 { + mapped_access |= FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA; + } + if (access & READ_CONTROL) != 0 { + mapped_access |= FILE_GENERIC_READ; + } + return (mapped_access & desired_mask) == desired_mask; } - // Fallback: simple allow ACE scan (already ignores inherit-only) - dacl_has_write_allow_for_sid(p_dacl, psid) + // Fallbacks on error: if write bits are requested, reuse the write helper; otherwise fail closed. + if (desired_mask & FILE_GENERIC_WRITE) != 0 { + return dacl_effective_allows_write(p_dacl, psid); + } + false } + +#[allow(dead_code)] +const WRITE_ALLOW_MASK: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + +/// Ensure all provided SIDs have a write-capable allow ACE on the path. +/// Returns true if any ACE was added. +#[allow(dead_code)] +pub unsafe fn ensure_allow_write_aces(path: &Path, sids: &[*mut c_void]) -> Result { + let (p_dacl, p_sd) = fetch_dacl_handle(path)?; + let mut entries: Vec = Vec::new(); + for sid in sids { + if dacl_quick_mask_allows(p_dacl, &[*sid], WRITE_ALLOW_MASK) { + continue; + } + entries.push(EXPLICIT_ACCESS_W { + grfAccessPermissions: WRITE_ALLOW_MASK, + grfAccessMode: 2, // SET_ACCESS + grfInheritance: CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: *sid as *mut u16, + }, + }); + } + let mut added = false; + if !entries.is_empty() { + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW( + entries.len() as u32, + entries.as_ptr(), + p_dacl, + &mut p_new_dacl, + ); + if code2 == ERROR_SUCCESS { + let code3 = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if code3 == ERROR_SUCCESS { + added = true; + } else { + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Err(anyhow!("SetNamedSecurityInfoW failed: {}", code3)); + } + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); + } + } else { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Err(anyhow!("SetEntriesInAclW failed: {}", code2)); + } + } + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + Ok(added) +} + +/// Adds an allow ACE granting read/write/execute to the given SID on the target path. +/// +/// # Safety +/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory. pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { let mut p_sd: *mut c_void = std::ptr::null_mut(); let mut p_dacl: *mut ACL = std::ptr::null_mut(); @@ -176,39 +417,44 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { if code != ERROR_SUCCESS { return Err(anyhow!("GetNamedSecurityInfoW failed: {}", code)); } + // Already has write? Skip costly DACL rewrite. + if dacl_has_write_allow_for_sid(p_dacl, psid) { + if !p_sd.is_null() { + LocalFree(p_sd as HLOCAL); + } + return Ok(false); + } let mut added = false; - if !dacl_has_write_allow_for_sid(p_dacl, psid) { - let trustee = TRUSTEE_W { - pMultipleTrustee: std::ptr::null_mut(), - MultipleTrusteeOperation: 0, - TrusteeForm: TRUSTEE_IS_SID, - TrusteeType: TRUSTEE_IS_UNKNOWN, - ptstrName: psid as *mut u16, - }; - let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); - explicit.grfAccessPermissions = - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; - explicit.grfAccessMode = 2; // SET_ACCESS - explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; - explicit.Trustee = trustee; - let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); - let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); - if code2 == ERROR_SUCCESS { - let code3 = SetNamedSecurityInfoW( - to_wide(path).as_ptr() as *mut u16, - 1, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - p_new_dacl, - std::ptr::null_mut(), - ); - if code3 == ERROR_SUCCESS { - added = true; - } - if !p_new_dacl.is_null() { - LocalFree(p_new_dacl as HLOCAL); - } + // Always ensure write is present: if an allow ACE exists without write, add one with write+RX. + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: psid as *mut u16, + }; + let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed(); + explicit.grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE; + explicit.grfAccessMode = 2; // SET_ACCESS + explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE; + explicit.Trustee = trustee; + let mut p_new_dacl: *mut ACL = std::ptr::null_mut(); + let code2 = SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl); + if code2 == ERROR_SUCCESS { + let code3 = SetNamedSecurityInfoW( + to_wide(path).as_ptr() as *mut u16, + 1, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + p_new_dacl, + std::ptr::null_mut(), + ); + if code3 == ERROR_SUCCESS { + added = !dacl_has_write_allow_for_sid(p_dacl, psid); + } + if !p_new_dacl.is_null() { + LocalFree(p_new_dacl as HLOCAL); } } if !p_sd.is_null() { @@ -217,6 +463,10 @@ pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result { Ok(added) } +/// Adds a deny ACE to prevent write/append/delete for the given SID on the target path. +/// +/// # Safety +/// Caller must ensure `psid` points to a valid SID and `path` refers to an existing file or directory. pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result { let mut p_sd: *mut c_void = std::ptr::null_mut(); let mut p_dacl: *mut ACL = std::ptr::null_mut(); @@ -330,6 +580,10 @@ pub unsafe fn revoke_ace(path: &Path, psid: *mut c_void) { } } +/// Grants RX to the null device for the given SID to support stdout/stderr redirection. +/// +/// # Safety +/// Caller must ensure `psid` is a valid SID pointer. pub unsafe fn allow_null_device(psid: *mut c_void) { let desired = 0x00020000 | 0x00040000; // READ_CONTROL | WRITE_DAC let h = CreateFileW( diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 6234dbf26a..7e35bf7517 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -1,12 +1,12 @@ use crate::acl::add_deny_write_ace; +use crate::acl::path_quick_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; -use crate::logging::log_note; +use crate::logging::{debug_log, log_note}; use crate::policy::SandboxPolicy; use crate::token::convert_string_sid_to_sid; use crate::token::world_sid; use anyhow::anyhow; -use crate::winutil::to_wide; use anyhow::Result; use std::collections::HashSet; use std::ffi::c_void; @@ -14,38 +14,10 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; use std::time::Instant; -use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::ERROR_SUCCESS; -use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; -use windows_sys::Win32::Foundation::LocalFree; -use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; -use windows_sys::Win32::Security::ACE_HEADER; -use windows_sys::Win32::Security::ACL; -use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; -use windows_sys::Win32::Security::AclSizeInformation; -use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; -use windows_sys::Win32::Security::Authorization::GetSecurityInfo; -use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; -use windows_sys::Win32::Security::EqualSid; -use windows_sys::Win32::Security::GetAce; -use windows_sys::Win32::Security::GetAclInformation; -use windows_sys::Win32::Security::MapGenericMask; -use windows_sys::Win32::Security::GENERIC_MAPPING; -use windows_sys::Win32::Storage::FileSystem::CreateFileW; -use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; -use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; -use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; -use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; // Preflight scan limits const MAX_ITEMS_PER_DIR: i32 = 1000; @@ -109,79 +81,10 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap } unsafe fn path_has_world_write_allow(path: &Path) -> Result { - // Prefer handle-based query (often faster than name-based), fallback to name-based on error - let mut p_sd: *mut c_void = std::ptr::null_mut(); - let mut p_dacl: *mut ACL = std::ptr::null_mut(); - - let mut try_named = false; - let wpath = to_wide(path); - let h = CreateFileW( - wpath.as_ptr(), - 0x00020000, // READ_CONTROL - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS, - 0, - ); - if h == INVALID_HANDLE_VALUE { - try_named = true; - } else { - let code = GetSecurityInfo( - h, - 1, // SE_FILE_OBJECT - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - CloseHandle(h); - if code != ERROR_SUCCESS { - try_named = true; - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - p_sd = std::ptr::null_mut(); - p_dacl = std::ptr::null_mut(); - } - } - } - - if try_named { - let code = GetNamedSecurityInfoW( - wpath.as_ptr(), - 1, - DACL_SECURITY_INFORMATION, - std::ptr::null_mut(), - std::ptr::null_mut(), - &mut p_dacl, - std::ptr::null_mut(), - &mut p_sd, - ); - if code != ERROR_SUCCESS { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - return Ok(false); - } - } - let mut world = world_sid()?; let psid_world = world.as_mut_ptr() as *mut c_void; - // Very fast mask-based check for world-writable grants (includes GENERIC_*). - if !dacl_quick_world_write_mask_allows(p_dacl, psid_world) { - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - return Ok(false); - } - // Quick detector flagged a write grant for Everyone: treat as writable. - let has = true; - if !p_sd.is_null() { - LocalFree(p_sd as HLOCAL); - } - Ok(has) + let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; + path_quick_mask_allows(path, &[psid_world], write_mask) } pub fn audit_everyone_writable( @@ -193,6 +96,21 @@ pub fn audit_everyone_writable( let mut flagged: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut checked = 0usize; + let check_world_writable = |path: &Path| -> bool { + match unsafe { path_has_world_write_allow(path) } { + Ok(has) => has, + Err(err) => { + debug_log( + &format!( + "AUDIT: treating unreadable ACL as not world-writable: {} ({err})", + path.display() + ), + logs_base_dir, + ); + false + } + } + }; // 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) { @@ -210,7 +128,7 @@ pub fn audit_everyone_writable( } let p = ent.path(); checked += 1; - let has = unsafe { path_has_world_write_allow(&p)? }; + let has = check_world_writable(&p); if has { let key = normalize_path_key(&p); if seen.insert(key) { @@ -228,7 +146,7 @@ pub fn audit_everyone_writable( break; } checked += 1; - let has_root = unsafe { path_has_world_write_allow(&root)? }; + let has_root = check_world_writable(&root); if has_root { let key = normalize_path_key(&root); if seen.insert(key) { @@ -260,7 +178,7 @@ pub fn audit_everyone_writable( } if ft.is_dir() { checked += 1; - let has_child = unsafe { path_has_world_write_allow(&p)? }; + let has_child = check_world_writable(&p); if has_child { let key = normalize_path_key(&p); if seen.insert(key) { @@ -384,57 +302,3 @@ pub fn apply_capability_denies_for_world_writable( } Ok(()) } -// Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for -// Everyone that grants write after generic bits are expanded? Skips inherit-only -// ACEs (do not apply to the current object). -unsafe fn dacl_quick_world_write_mask_allows(p_dacl: *mut ACL, psid_world: *mut c_void) -> bool { - if p_dacl.is_null() { - return false; - } - const INHERIT_ONLY_ACE: u8 = 0x08; - let mut info: ACL_SIZE_INFORMATION = std::mem::zeroed(); - let ok = GetAclInformation( - p_dacl as *const ACL, - &mut info as *mut _ as *mut c_void, - std::mem::size_of::() as u32, - AclSizeInformation, - ); - if ok == 0 { - return false; - } - let mapping = GENERIC_MAPPING { - GenericRead: FILE_GENERIC_READ, - GenericWrite: FILE_GENERIC_WRITE, - GenericExecute: FILE_GENERIC_EXECUTE, - GenericAll: FILE_ALL_ACCESS, - }; - for i in 0..(info.AceCount as usize) { - let mut p_ace: *mut c_void = std::ptr::null_mut(); - if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { - continue; - } - let hdr = &*(p_ace as *const ACE_HEADER); - if hdr.AceType != 0 { - // ACCESS_ALLOWED_ACE_TYPE - continue; - } - if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 { - continue; - } - let base = p_ace as usize; - let sid_ptr = - (base + std::mem::size_of::() + std::mem::size_of::()) as *mut c_void; // skip header + mask - if EqualSid(sid_ptr, psid_world) == 0 { - continue; - } - let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE); - let mut mask = ace.Mask; - // Expand generic bits to concrete file rights before checking for write. - MapGenericMask(&mut mask, &mapping); - let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; - if (mask & write_mask) != 0 { - return true; - } - } - false -} diff --git a/codex-rs/windows-sandbox-rs/src/env.rs b/codex-rs/windows-sandbox-rs/src/env.rs index a8a3cda71d..65950a0d59 100644 --- a/codex-rs/windows-sandbox-rs/src/env.rs +++ b/codex-rs/windows-sandbox-rs/src/env.rs @@ -1,11 +1,10 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; +use dirs_next::home_dir; use std::collections::HashMap; use std::env; -use std::fs::File; -use std::fs::{self}; +use std::fs::{self, File}; use std::io::Write; -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn normalize_null_device_env(env_map: &mut HashMap) { let keys: Vec = env_map.keys().cloned().collect(); @@ -29,6 +28,21 @@ pub fn ensure_non_interactive_pager(env_map: &mut HashMap) { env_map.entry("LESS".into()).or_insert_with(|| "".into()); } +// Keep PATH and PATHEXT stable for callers that rely on inheriting the parent process env. +#[allow(dead_code)] +pub fn inherit_path_env(env_map: &mut HashMap) { + if !env_map.contains_key("PATH") { + if let Ok(path) = env::var("PATH") { + env_map.insert("PATH".into(), path); + } + } + if !env_map.contains_key("PATHEXT") { + if let Ok(pathext) = env::var("PATHEXT") { + env_map.insert("PATHEXT".into(), pathext); + } + } +} + fn prepend_path(env_map: &mut HashMap, prefix: &str) { let existing = env_map .get("PATH") @@ -64,7 +78,7 @@ fn reorder_pathext_for_stubs(env_map: &mut HashMap) { .map(|s| s.to_string()) .collect(); let exts_norm: Vec = exts.iter().map(|e| e.to_ascii_uppercase()).collect(); - let want = [".BAT", ".CMD"]; // move to front if present + let want = [".BAT", ".CMD"]; let mut front: Vec = Vec::new(); for w in want { if let Some(idx) = exts_norm.iter().position(|e| e == w) { @@ -90,7 +104,7 @@ fn ensure_denybin(tools: &[&str], denybin_dir: Option<&Path>) -> Result let base = match denybin_dir { Some(p) => p.to_path_buf(), None => { - let home = dirs_next::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?; + let home = home_dir().ok_or_else(|| anyhow!("no home dir"))?; home.join(".sbx-denybin") } }; @@ -146,16 +160,12 @@ pub fn apply_no_network_to_env(env_map: &mut HashMap) -> Result< .entry("GIT_ALLOW_PROTOCOLS".into()) .or_insert_with(|| "".into()); - // Block interactive network tools that bypass HTTP(S) proxy settings, but - // allow curl/wget to run so commands like `curl --version` still succeed. - // Network access is disabled via proxy envs above. let base = ensure_denybin(&["ssh", "scp"], None)?; - // Clean up any stale stubs from previous runs so real curl/wget can run. for tool in ["curl", "wget"] { for ext in [".bat", ".cmd"] { let p = base.join(format!("{}{}", tool, ext)); if p.exists() { - let _ = std::fs::remove_file(&p); + let _ = fs::remove_file(&p); } } } diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index 9887ce81d2..2e4de1d29a 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -2,9 +2,20 @@ use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use std::path::PathBuf; +use std::sync::OnceLock; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; -pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log"; +pub const LOG_FILE_NAME: &str = "sandbox.log"; + +fn exe_label() -> &'static str { + static LABEL: OnceLock = OnceLock::new(); + LABEL.get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .unwrap_or_else(|| "proc".to_string()) + }) +} fn preview(command: &[String]) -> String { let joined = command.join(" "); @@ -35,17 +46,17 @@ fn append_line(line: &str, base_dir: Option<&Path>) { pub fn log_start(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("START: {p}"), base_dir); + log_note(&format!("START: {p}"), base_dir); } pub fn log_success(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("SUCCESS: {p}"), base_dir); + log_note(&format!("SUCCESS: {p}"), base_dir); } pub fn log_failure(command: &[String], detail: &str, base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("FAILURE: {p} ({detail})"), base_dir); + log_note(&format!("FAILURE: {p} ({detail})"), base_dir); } // Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs. @@ -56,7 +67,8 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) { } } -// Unconditional note logging to sandbox_commands.rust.log +// Unconditional note logging to sandbox.log pub fn log_note(msg: &str, base_dir: Option<&Path>) { - append_line(msg, base_dir); + let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir); } diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 095dcf8b98..9f73e5d0d4 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -79,6 +79,7 @@ fn quote_arg(a: &str) -> String { out.push('"'); out } +#[allow(dead_code)] unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { for kind in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { let h = GetStdHandle(kind); @@ -96,12 +97,16 @@ unsafe fn ensure_inheritable_stdio(si: &mut STARTUPINFOW) -> Result<()> { Ok(()) } +/// # Safety +/// Caller must provide a valid primary token handle (`h_token`) with appropriate access, +/// and the `argv`, `cwd`, and `env_map` must remain valid for the duration of the call. pub unsafe fn create_process_as_user( h_token: HANDLE, argv: &[String], cwd: &Path, env_map: &HashMap, logs_base_dir: Option<&Path>, + stdio: Option<(HANDLE, HANDLE, HANDLE)>, ) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> { let cmdline_str = argv .iter() @@ -117,19 +122,41 @@ pub unsafe fn create_process_as_user( // Point explicitly at the interactive desktop. let desktop = to_wide("Winsta0\\Default"); si.lpDesktop = desktop.as_ptr() as *mut u16; - ensure_inheritable_stdio(&mut si)?; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + // Ensure handles are inheritable when custom stdio is supplied. + let inherit_handles = match stdio { + Some((stdin_h, stdout_h, stderr_h)) => { + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = stdin_h; + si.hStdOutput = stdout_h; + si.hStdError = stderr_h; + for h in [stdin_h, stdout_h, stderr_h] { + if SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { + return Err(anyhow!( + "SetHandleInformation failed for stdio handle: {}", + GetLastError() + )); + } + } + true + } + None => { + ensure_inheritable_stdio(&mut si)?; + true + } + }; + let ok = CreateProcessAsUserW( h_token, std::ptr::null(), cmdline.as_mut_ptr(), std::ptr::null_mut(), std::ptr::null_mut(), - 1, + inherit_handles as i32, CREATE_UNICODE_ENVIRONMENT, env_block.as_ptr() as *mut c_void, to_wide(cwd).as_ptr(), - &si, + &mut si, &mut pi, ); if ok == 0 { @@ -149,6 +176,9 @@ pub unsafe fn create_process_as_user( Ok((pi, si)) } +/// # Safety +/// Caller must provide valid process information handles. +#[allow(dead_code)] pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result { let res = WaitForSingleObject(pi.hProcess, INFINITE); if res != 0 { @@ -161,6 +191,9 @@ pub unsafe fn wait_process_and_exitcode(pi: &PROCESS_INFORMATION) -> Result Ok(code as i32) } +/// # Safety +/// Caller must close the returned job handle. +#[allow(dead_code)] pub unsafe fn create_job_kill_on_close() -> Result { let h = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); if h == 0 { @@ -183,6 +216,9 @@ pub unsafe fn create_job_kill_on_close() -> Result { Ok(h) } +/// # Safety +/// Caller must pass valid handles for a job object and a process. +#[allow(dead_code)] pub unsafe fn assign_to_job(h_job: HANDLE, h_process: HANDLE) -> Result<()> { if AssignProcessToJobObject(h_job, h_process) == 0 { return Err(anyhow!( diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 60eae9377f..7e565bc67a 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -24,6 +24,7 @@ use windows_sys::Win32::Security::TOKEN_DUPLICATE; use windows_sys::Win32::Security::TOKEN_PRIVILEGES; use windows_sys::Win32::Security::TOKEN_QUERY; use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; const DISABLE_MAX_PRIVILEGE: u32 = 0x01; const LUA_TOKEN: u32 = 0x04; @@ -52,6 +53,8 @@ pub unsafe fn world_sid() -> Result> { Ok(buf) } +/// # Safety +/// Caller is responsible for freeing the returned SID with `LocalFree`. pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { #[link(name = "advapi32")] extern "system" { @@ -66,6 +69,9 @@ pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { } } +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] pub unsafe fn get_current_token_for_restriction() -> Result { let desired = TOKEN_DUPLICATE | TOKEN_QUERY @@ -197,13 +203,55 @@ unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> { Ok(()) } -// removed unused create_write_restricted_token_strict +/// # Safety +/// Opens the current process token and adjusts privileges; caller should ensure this is needed in the current context. +#[allow(dead_code)] +pub unsafe fn enable_privilege_on_current(name: &str) -> Result<()> { + let mut h: HANDLE = 0; + let ok = OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut h, + ); + if ok == 0 { + return Err(anyhow!("OpenProcessToken failed: {}", GetLastError())); + } + let res = enable_single_privilege(h, name); + CloseHandle(h); + res +} +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] pub unsafe fn create_workspace_write_token_with_cap( psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { let base = get_current_token_for_restriction()?; - let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let res = create_workspace_write_token_with_cap_from(base, psid_capability); + CloseHandle(base); + res +} + +/// # Safety +/// Caller must close the returned token handle. +#[allow(dead_code)] +pub unsafe fn create_readonly_token_with_cap( + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let base = get_current_token_for_restriction()?; + let res = create_readonly_token_with_cap_from(base, psid_capability); + CloseHandle(base); + res +} + +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_workspace_write_token_with_cap_from( + base_token: HANDLE, + psid_capability: *mut c_void, +) -> Result<(HANDLE, *mut c_void)> { + let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; @@ -218,7 +266,7 @@ pub unsafe fn create_workspace_write_token_with_cap( let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( - base, + base_token, flags, 0, std::ptr::null(), @@ -235,11 +283,13 @@ pub unsafe fn create_workspace_write_token_with_cap( Ok((new_token, psid_capability)) } -pub unsafe fn create_readonly_token_with_cap( +/// # Safety +/// Caller must close the returned token handle; base_token must be a valid primary token. +pub unsafe fn create_readonly_token_with_cap_from( + base_token: HANDLE, psid_capability: *mut c_void, ) -> Result<(HANDLE, *mut c_void)> { - let base = get_current_token_for_restriction()?; - let mut logon_sid_bytes = get_logon_sid_bytes(base)?; + let mut logon_sid_bytes = get_logon_sid_bytes(base_token)?; let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void; let mut everyone = world_sid()?; let psid_everyone = everyone.as_mut_ptr() as *mut c_void; @@ -254,7 +304,7 @@ pub unsafe fn create_readonly_token_with_cap( let mut new_token: HANDLE = 0; let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; let ok = CreateRestrictedToken( - base, + base_token, flags, 0, std::ptr::null(), diff --git a/codex-rs/windows-sandbox-rs/src/winutil.rs b/codex-rs/windows-sandbox-rs/src/winutil.rs index 5e74ce072e..a819561261 100644 --- a/codex-rs/windows-sandbox-rs/src/winutil.rs +++ b/codex-rs/windows-sandbox-rs/src/winutil.rs @@ -6,6 +6,7 @@ use windows_sys::Win32::System::Diagnostics::Debug::FormatMessageW; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_ALLOCATE_BUFFER; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_FROM_SYSTEM; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_IGNORE_INSERTS; +use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; pub fn to_wide>(s: S) -> Vec { let mut v: Vec = s.as_ref().encode_wide().collect(); @@ -41,3 +42,22 @@ pub fn format_last_error(err: i32) -> String { s } } + +#[allow(dead_code)] +pub fn string_from_sid_bytes(sid: &[u8]) -> Result { + unsafe { + let mut str_ptr: *mut u16 = std::ptr::null_mut(); + let ok = ConvertSidToStringSidW(sid.as_ptr() as *mut std::ffi::c_void, &mut str_ptr); + if ok == 0 || str_ptr.is_null() { + return Err(format!("ConvertSidToStringSidW failed: {}", std::io::Error::last_os_error())); + } + let mut len = 0; + while *str_ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(str_ptr, len); + let out = String::from_utf16_lossy(slice); + let _ = LocalFree(str_ptr as HLOCAL); + Ok(out) + } +} From 42e081739877ba00528b99090cb63200efc334ce Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 19:31:46 -0800 Subject: [PATCH 04/19] Revert "Revert "feat: windows codesign with Azure trusted signing"" (#7757) Reverts openai/codex#7753 Updated the tag ref matching at https://github.com/openai/openai/pull/594858 so that release with tag change can be picked up correctly. --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 0000000000..17a4fbf999 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a..b90f0027fa 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From f11520f5f1250f971917619dd1787fb7dc5533fc Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 20:19:37 -0800 Subject: [PATCH 05/19] Revert "feat: windows codesign with Azure trusted signing" (#7804) Reverts openai/codex#7757 --- .github/actions/windows-code-sign/action.yml | 54 -------------------- .github/workflows/rust-release.yml | 12 ----- 2 files changed, 66 deletions(-) delete mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml deleted file mode 100644 index 17a4fbf999..0000000000 --- a/.github/actions/windows-code-sign/action.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: windows-code-sign -description: Sign Windows binaries with Azure Trusted Signing. -inputs: - target: - description: Target triple for the artifacts to sign. - required: true - client-id: - description: Azure Trusted Signing client ID. - required: true - tenant-id: - description: Azure tenant ID for Trusted Signing. - required: true - subscription-id: - description: Azure subscription ID for Trusted Signing. - required: true - endpoint: - description: Azure Trusted Signing endpoint. - required: true - account-name: - description: Azure Trusted Signing account name. - required: true - certificate-profile-name: - description: Certificate profile name for signing. - required: true - -runs: - using: composite - steps: - - name: Azure login for Trusted Signing (OIDC) - uses: azure/login@v2 - with: - client-id: ${{ inputs.client-id }} - tenant-id: ${{ inputs.tenant-id }} - subscription-id: ${{ inputs.subscription-id }} - - - name: Sign Windows binaries with Azure Trusted Signing - uses: azure/trusted-signing-action@v0 - with: - endpoint: ${{ inputs.endpoint }} - trusted-signing-account-name: ${{ inputs.account-name }} - certificate-profile-name: ${{ inputs.certificate-profile-name }} - exclude-environment-credential: true - exclude-workload-identity-credential: true - exclude-managed-identity-credential: true - exclude-shared-token-cache-credential: true - exclude-visual-studio-credential: true - exclude-visual-studio-code-credential: true - exclude-azure-cli-credential: false - exclude-azure-powershell-credential: true - exclude-azure-developer-cli-credential: true - exclude-interactive-browser-credential: true - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index b90f0027fa..c3e9eeef9a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,18 +110,6 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - if: ${{ contains(matrix.target, 'windows') }} - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From ab9ddcd50bd8a1d6eeb1c721313c889736c2f844 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 20:42:00 -0800 Subject: [PATCH 06/19] Revert "Revert "feat: windows codesign with Azure trusted signing"" (#7806) Reverts openai/codex#7804 --- .github/actions/windows-code-sign/action.yml | 54 ++++++++++++++++++++ .github/workflows/rust-release.yml | 12 +++++ 2 files changed, 66 insertions(+) create mode 100644 .github/actions/windows-code-sign/action.yml diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml new file mode 100644 index 0000000000..17a4fbf999 --- /dev/null +++ b/.github/actions/windows-code-sign/action.yml @@ -0,0 +1,54 @@ +name: windows-code-sign +description: Sign Windows binaries with Azure Trusted Signing. +inputs: + target: + description: Target triple for the artifacts to sign. + required: true + client-id: + description: Azure Trusted Signing client ID. + required: true + tenant-id: + description: Azure tenant ID for Trusted Signing. + required: true + subscription-id: + description: Azure subscription ID for Trusted Signing. + required: true + endpoint: + description: Azure Trusted Signing endpoint. + required: true + account-name: + description: Azure Trusted Signing account name. + required: true + certificate-profile-name: + description: Certificate profile name for signing. + required: true + +runs: + using: composite + steps: + - name: Azure login for Trusted Signing (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Sign Windows binaries with Azure Trusted Signing + uses: azure/trusted-signing-action@v0 + with: + endpoint: ${{ inputs.endpoint }} + trusted-signing-account-name: ${{ inputs.account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + files: | + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe + ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c3e9eeef9a..b90f0027fa 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -110,6 +110,18 @@ jobs: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + - if: ${{ contains(matrix.target, 'windows') }} + name: Sign Windows binaries with Azure Trusted Signing + uses: ./.github/actions/windows-code-sign + with: + target: ${{ matrix.target }} + client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} + endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash From 6fa24d65f5a2d9b3880d7a0e2c5100ba8a55d498 Mon Sep 17 00:00:00 2001 From: Gav Verma Date: Tue, 9 Dec 2025 21:17:57 -0800 Subject: [PATCH 07/19] Express rate limit warning as % remaining (#7795) image Earlier, the warning was expressed as consumed% whereas status was expressed as remaining%. This change brings the two into sync to minimize confusion and improve visual consistency. --- codex-rs/tui/src/chatwidget.rs | 6 ++++-- codex-rs/tui/src/chatwidget/tests.rs | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a0b42ddbe4..1d5ad24a03 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -199,8 +199,9 @@ impl RateLimitWarningState { let limit_label = secondary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "weekly".to_string()); + let remaining_percent = 100.0 - threshold; warnings.push(format!( - "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } @@ -217,8 +218,9 @@ impl RateLimitWarningState { let limit_label = primary_window_minutes .map(get_limits_duration) .unwrap_or_else(|| "5h".to_string()); + let remaining_percent = 100.0 - threshold; warnings.push(format!( - "Heads up, you've used over {threshold:.0}% of your {limit_label} limit. Run /status for a breakdown." + "Heads up, you have less than {remaining_percent:.0}% of your {limit_label} limit left. Run /status for a breakdown." )); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0135abff73..5cc5321f37 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -515,16 +515,16 @@ fn rate_limit_warnings_emit_thresholds() { warnings, vec![ String::from( - "Heads up, you've used over 75% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 75% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.", ), String::from( - "Heads up, you've used over 95% of your 5h limit. Run /status for a breakdown." + "Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown." ), String::from( - "Heads up, you've used over 95% of your weekly limit. Run /status for a breakdown.", + "Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.", ), ], "expected one warning per limit for the highest crossed threshold" @@ -540,7 +540,7 @@ fn test_rate_limit_warnings_monthly() { assert_eq!( warnings, vec![String::from( - "Heads up, you've used over 75% of your monthly limit. Run /status for a breakdown.", + "Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.", ),], "expected one warning per limit for the highest crossed threshold" ); From d1c5db579674306c136e0f3e1f751e3510fe553b Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Tue, 9 Dec 2025 22:14:14 -0800 Subject: [PATCH 08/19] chore: disable trusted signing pkg cache hit (#7807) --- .github/actions/windows-code-sign/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index 17a4fbf999..2be64efc98 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -49,6 +49,7 @@ runs: exclude-azure-powershell-credential: true exclude-azure-developer-cli-credential: true exclude-interactive-browser-credential: true + cache-dependencies: false files: | ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe From 0ad54982ae9423965e83d78caff495d3c30247ad Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 10:30:38 +0000 Subject: [PATCH 09/19] chore: rework unified exec events (#7775) --- .../src/protocol/common.rs | 1 + .../app-server-protocol/src/protocol/v2.rs | 11 + codex-rs/app-server-test-client/src/main.rs | 4 + .../app-server/src/bespoke_event_handling.rs | 15 + codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/tools/events.rs | 3 +- .../core/src/tools/handlers/unified_exec.rs | 31 +- .../core/src/unified_exec/async_watcher.rs | 180 ++++++++++ codex-rs/core/src/unified_exec/mod.rs | 44 ++- codex-rs/core/src/unified_exec/session.rs | 43 ++- .../core/src/unified_exec/session_manager.rs | 163 ++++----- codex-rs/core/tests/suite/unified_exec.rs | 336 ++++++++++++++++-- .../src/event_processor_with_human_output.rs | 1 + .../src/event_processor_with_jsonl_output.rs | 45 ++- .../tests/event_processor_with_json_output.rs | 89 +++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/protocol.rs | 14 + codex-rs/tui/src/chatwidget.rs | 15 +- codex-rs/tui/src/chatwidget/tests.rs | 43 +++ codex-rs/utils/pty/src/lib.rs | 10 +- 20 files changed, 876 insertions(+), 174 deletions(-) create mode 100644 codex-rs/core/src/unified_exec/async_watcher.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c62acc8832..bd9f6ddedf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -527,6 +527,7 @@ server_notification_definitions! { ItemCompleted => "item/completed" (v2::ItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dbef55ed15..db987e27df 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1457,6 +1457,17 @@ pub struct ReasoningTextDeltaNotification { pub content_index: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 92255cecd3..924740896e 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -553,6 +553,10 @@ impl CodexClient { print!("{}", delta.delta); std::io::stdout().flush().ok(); } + ServerNotification::TerminalInteraction(delta) => { + println!("[stdin sent: {}]", delta.stdin); + std::io::stdout().flush().ok(); + } ServerNotification::ItemStarted(payload) => { println!("\n< item started: {:?}", payload.item); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2fda7bcf58..8956aedd13 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -573,6 +574,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::TerminalInteraction(terminal_event) => { + let item_id = terminal_event.call_id.clone(); + + let notification = TerminalInteractionNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }; + outgoing + .send_server_notification(ServerNotification::TerminalInteraction(notification)) + .await; + } EventMsg::ExecCommandEnd(exec_command_end_event) => { let ExecCommandEndEvent { call_id, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f9336..fc6e4b9afd 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -62,6 +62,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 93bce60489..cdfc575cd9 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -134,7 +134,6 @@ impl ToolEmitter { command: &[String], cwd: PathBuf, source: ExecCommandSource, - interaction_input: Option, process_id: Option, ) -> Self { let parsed_cmd = parse_command(command); @@ -142,7 +141,7 @@ impl ToolEmitter { command: command.to_vec(), cwd, source, - interaction_input, + interaction_input: None, // TODO(jif) drop this field in the protocol. parsed_cmd, process_id, } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c7230e54d7..abaaf4a7ab 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,9 +1,8 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::ExecOutputStream; +use crate::protocol::TerminalInteractionEvent; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -189,7 +188,6 @@ impl ToolHandler for UnifiedExecHandler { &command, cwd.clone(), ExecCommandSource::UnifiedExecStartup, - None, Some(process_id.clone()), ); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -218,7 +216,7 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse write_stdin arguments: {err:?}" )) })?; - manager + let response = manager .write_stdin(WriteStdinRequest { process_id: &args.session_id.to_string(), input: &args.chars, @@ -228,7 +226,18 @@ impl ToolHandler for UnifiedExecHandler { .await .map_err(|err| { FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) - })? + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + + response } other => { return Err(FunctionCallError::RespondToModel(format!( @@ -237,18 +246,6 @@ impl ToolHandler for UnifiedExecHandler { } }; - // Emit a delta event with the chunk of output we just produced, if any. - if !response.output.is_empty() { - let delta = ExecCommandOutputDeltaEvent { - call_id: response.event_call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: response.output.as_bytes().to_vec(), - }; - session - .send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta)) - .await; - } - let content = format_response(&response); Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs new file mode 100644 index 0000000000..7412d29720 --- /dev/null +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecToolCallOutput; +use crate::exec::StreamOutput; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandOutputDeltaEvent; +use crate::protocol::ExecCommandSource; +use crate::protocol::ExecOutputStream; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventStage; + +use super::CommandTranscript; +use super::UnifiedExecContext; +use super::session::UnifiedExecSession; + +/// Spawn a background task that continuously reads from the PTY, appends to the +/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 +/// boundaries. +pub(crate) fn start_streaming_output( + session: &UnifiedExecSession, + context: &UnifiedExecContext, + transcript: Arc>, +) { + let mut receiver = session.output_receiver(); + let session_ref = Arc::clone(&context.session); + let turn_ref = Arc::clone(&context.turn); + let call_id = context.call_id.clone(); + let cancellation_token = session.cancellation_token(); + + tokio::spawn(async move { + let mut pending: Vec = Vec::new(); + loop { + tokio::select! { + _ = cancellation_token.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.clone(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + }; + } + }); +} + +/// Spawn a background watcher that waits for the PTY to exit and then emits a +/// single ExecCommandEnd event with the aggregated transcript. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_exit_watcher( + session: Arc, + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: String, + transcript: Arc>, + started_at: Instant, +) { + let exit_token = session.cancellation_token(); + + tokio::spawn(async move { + exit_token.cancelled().await; + + let exit_code = session.exit_code().unwrap_or(-1); + let duration = Instant::now().saturating_duration_since(started_at); + emit_exec_end_for_unified_exec( + session_ref, + turn_ref, + call_id, + command, + cwd, + Some(process_id), + transcript, + String::new(), + exit_code, + duration, + ) + .await; + }); +} + +/// Emit an ExecCommandEnd event for a unified exec session, using the transcript +/// as the primary source of aggregated_output and falling back to the provided +/// text when the transcript is empty. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn emit_exec_end_for_unified_exec( + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: Option, + transcript: Arc>, + fallback_output: String, + exit_code: i32, + duration: Duration, +) { + let aggregated_output = resolve_aggregated_output(&transcript, fallback_output).await; + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new(session_ref.as_ref(), turn_ref.as_ref(), &call_id, None); + let emitter = ToolEmitter::unified_exec( + &command, + cwd, + ExecCommandSource::UnifiedExecStartup, + process_id, + ); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; +} + +fn split_valid_utf8_prefix(buffer: &mut Vec) -> Option> { + if buffer.is_empty() { + return None; + } + + let len = buffer.len(); + let mut split = len; + while split > 0 { + if std::str::from_utf8(&buffer[..split]).is_ok() { + let prefix = buffer[..split].to_vec(); + buffer.drain(..split); + return Some(prefix); + } + + if len - split > 4 { + break; + } + split -= 1; + } + + // If no valid UTF-8 prefix was found, emit the first byte so the stream + // keeps making progress and the transcript reflects all bytes. + let byte = buffer.drain(..1).collect(); + Some(byte) +} + +async fn resolve_aggregated_output( + transcript: &Arc>, + fallback: String, +) -> String { + let guard = transcript.lock().await; + if guard.data.is_empty() { + return fallback; + } + + String::from_utf8_lossy(&guard.data).to_string() +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 02a0f9ead7..0d86b69fda 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -34,6 +34,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +mod async_watcher; mod errors; mod session; mod session_manager; @@ -51,6 +52,24 @@ pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64; // Send a warning message to the models when it reaches this number of sessions. pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60; +#[derive(Debug, Default)] +pub(crate) struct CommandTranscript { + pub data: Vec, +} + +impl CommandTranscript { + pub fn append(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES { + let excess = self + .data + .len() + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + self.data.drain(..excess); + } + } +} + pub(crate) struct UnifiedExecContext { pub session: Arc, pub turn: Arc, @@ -92,18 +111,14 @@ pub(crate) struct UnifiedExecResponse { pub chunk_id: String, pub wall_time: Duration, pub output: String, + /// Raw bytes returned for this unified exec call before any truncation. + pub raw_output: Vec, pub process_id: Option, pub exit_code: Option, pub original_token_count: Option, pub session_command: Option>, } -#[derive(Default)] -pub(crate) struct UnifiedExecSessionManager { - session_store: Mutex, -} - -// Required for mutex sharing. #[derive(Default)] pub(crate) struct SessionStore { sessions: HashMap, @@ -115,22 +130,27 @@ impl SessionStore { self.reserved_sessions_id.remove(session_id); self.sessions.remove(session_id) } +} - pub(crate) fn clear(&mut self) { - self.reserved_sessions_id.clear(); - self.sessions.clear(); +pub(crate) struct UnifiedExecSessionManager { + session_store: Mutex, +} + +impl Default for UnifiedExecSessionManager { + fn default() -> Self { + Self { + session_store: Mutex::new(SessionStore::default()), + } } } struct SessionEntry { - session: UnifiedExecSession, + session: Arc, session_ref: Arc, turn_ref: Arc, call_id: String, process_id: String, command: Vec, - cwd: PathBuf, - started_at: tokio::time::Instant, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 02465538ec..51ebbd3569 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -98,19 +98,22 @@ impl UnifiedExecSession { let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - match receiver.recv().await { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); + tokio::select! { + _ = cancellation_token_clone.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + cancellation_token_clone.cancel(); + break; + } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } - } + }; } }); @@ -136,6 +139,14 @@ impl UnifiedExecSession { } } + pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver> { + self.session.output_receiver() + } + + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } @@ -144,6 +155,12 @@ impl UnifiedExecSession { self.session.exit_code() } + pub(super) fn terminate(&self) { + self.session.terminate(); + self.cancellation_token.cancel(); + self.output_task.abort(); + } + async fn snapshot_output(&self) -> Vec> { let guard = self.output_buffer.lock().await; guard.snapshot() @@ -246,6 +263,6 @@ impl UnifiedExecSession { impl Drop for UnifiedExecSession { fn drop(&mut self) { - self.output_task.abort(); + self.terminate(); } } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index af706b4b23..fa64eb4bb2 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -13,18 +13,12 @@ use tokio_util::sync::CancellationToken; use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; -use crate::exec::ExecToolCallOutput; -use crate::exec::StreamOutput; use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandSource; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -33,6 +27,7 @@ use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; +use super::CommandTranscript; use super::ExecCommandRequest; use super::MAX_UNIFIED_EXEC_SESSIONS; use super::SessionEntry; @@ -43,6 +38,9 @@ use super::UnifiedExecResponse; use super::UnifiedExecSessionManager; use super::WARNING_UNIFIED_EXEC_SESSIONS; use super::WriteStdinRequest; +use super::async_watcher::emit_exec_end_for_unified_exec; +use super::async_watcher::spawn_exit_watcher; +use super::async_watcher::start_streaming_output; use super::clamp_yield_time; use super::generate_chunk_id; use super::resolve_max_tokens; @@ -135,17 +133,23 @@ impl UnifiedExecSessionManager { .await; let session = match session { - Ok(session) => session, + Ok(session) => Arc::new(session), Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); } }; + let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default())); + start_streaming_output(&session, context, Arc::clone(&transcript)); + let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); let start = Instant::now(); + // For the initial exec_command call, we both stream output to events + // (via start_streaming_output above) and collect a snapshot here for + // the tool response body. let OutputHandles { output_buffer, output_notify, @@ -163,36 +167,44 @@ impl UnifiedExecSessionManager { let text = String::from_utf8_lossy(&collected).to_string(); let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); - let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let has_exited = session.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); if has_exited { + // Short‑lived command: emit ExecCommandEnd immediately using the + // same helper as the background watcher, so all end events share + // one implementation. self.release_process_id(&request.process_id).await; let exit = exit_code.unwrap_or(-1); - Self::emit_exec_end_from_context( - context, - &request.command, + emit_exec_end_for_unified_exec( + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + request.command.clone(), cwd, + Some(process_id), + Arc::clone(&transcript), output.clone(), exit, wall_time, - // We always emit the process ID in order to keep consistency between the Begin - // event and the End event. - Some(process_id), ) .await; session.check_for_sandbox_denial_with_text(&text).await?; } else { - // Only store session if not exited. + // Long‑lived command: persist the session so write_stdin can reuse + // it, and register a background watcher that will emit + // ExecCommandEnd when the PTY eventually exits (even if no further + // tool calls are made). self.store_session( - session, + Arc::clone(&session), context, &request.command, cwd.clone(), start, process_id, + Arc::clone(&transcript), ) .await; @@ -205,6 +217,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id: if has_exited { None } else { @@ -238,6 +251,8 @@ impl UnifiedExecSessionManager { if !request.input.is_empty() { Self::send_input(&writer_tx, request.input.as_bytes()).await?; + // Give the remote process a brief window to react so that we are + // more likely to capture its output in the poll below. tokio::time::sleep(Duration::from_millis(100)).await; } @@ -259,16 +274,20 @@ impl UnifiedExecSessionManager { let original_token_count = approx_token_count(&text); let chunk_id = generate_chunk_id(); + // After polling, refresh_session_state tells us whether the PTY is + // still alive or has exited and been removed from the store; we thread + // that through so the handler can tag TerminalInteraction with an + // appropriate process_id and exit_code. let status = self.refresh_session_state(process_id.as_str()).await; - let (process_id, exit_code, completion_entry, event_call_id) = match status { + let (process_id, exit_code, event_call_id) = match status { SessionStatus::Alive { exit_code, call_id, process_id, - } => (Some(process_id), exit_code, None, call_id), + } => (Some(process_id), exit_code, call_id), SessionStatus::Exited { exit_code, entry } => { let call_id = entry.call_id.clone(); - (None, exit_code, Some(*entry), call_id) + (None, exit_code, call_id) } SessionStatus::Unknown => { return Err(UnifiedExecError::UnknownSessionId { @@ -282,6 +301,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id, exit_code, original_token_count: Some(original_token_count), @@ -292,12 +312,6 @@ impl UnifiedExecSessionManager { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } - if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) { - let total_duration = Instant::now().saturating_duration_since(entry.started_at); - Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration) - .await; - } - Ok(response) } @@ -371,28 +385,27 @@ impl UnifiedExecSessionManager { #[allow(clippy::too_many_arguments)] async fn store_session( &self, - session: UnifiedExecSession, + session: Arc, context: &UnifiedExecContext, command: &[String], cwd: PathBuf, started_at: Instant, process_id: String, + transcript: Arc>, ) { let entry = SessionEntry { - session, + session: Arc::clone(&session), session_ref: Arc::clone(&context.session), turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), - cwd, - started_at, last_used: started_at, }; let number_sessions = { let mut store = self.session_store.lock().await; Self::prune_sessions_if_needed(&mut store); - store.sessions.insert(process_id, entry); + store.sessions.insert(process_id.clone(), entry); store.sessions.len() }; @@ -405,73 +418,18 @@ impl UnifiedExecSessionManager { ) .await; }; - } - async fn emit_exec_end_from_entry( - entry: SessionEntry, - aggregated_output: String, - exit_code: i32, - duration: Duration, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - entry.session_ref.as_ref(), - entry.turn_ref.as_ref(), - &entry.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - &entry.command, - entry.cwd, - ExecCommandSource::UnifiedExecStartup, - None, - Some(entry.process_id.clone()), - ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; - } - - async fn emit_exec_end_from_context( - context: &UnifiedExecContext, - command: &[String], - cwd: PathBuf, - aggregated_output: String, - exit_code: i32, - duration: Duration, - process_id: Option, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - context.session.as_ref(), - context.turn.as_ref(), - &context.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - command, + spawn_exit_watcher( + Arc::clone(&session), + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + command.to_vec(), cwd, - ExecCommandSource::UnifiedExecStartup, - None, process_id, + transcript, + started_at, ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; } async fn emit_waiting_status( @@ -567,7 +525,7 @@ impl UnifiedExecSessionManager { cancellation_token: &CancellationToken, deadline: Instant, ) -> Vec { - const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25); + const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(50); let mut collected: Vec = Vec::with_capacity(4096); let mut exit_signal_received = cancellation_token.is_cancelled(); @@ -634,7 +592,9 @@ impl UnifiedExecSessionManager { .collect(); if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) { - store.remove(&session_id); + if let Some(entry) = store.remove(&session_id) { + entry.session.terminate(); + } return true; } @@ -671,8 +631,17 @@ impl UnifiedExecSessionManager { } pub(crate) async fn terminate_all_sessions(&self) { - let mut sessions = self.session_store.lock().await; - sessions.clear(); + let entries: Vec = { + let mut sessions = self.session_store.lock().await; + let entries: Vec = + sessions.sessions.drain().map(|(_, entry)| entry).collect(); + sessions.reserved_sessions_id.clear(); + entries + }; + + for entry in entries { + entry.session.terminate(); + } } } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6a62e35dfb..5def7aadb2 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -648,16 +648,16 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { }) .await?; - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()), + let event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), _ => None, }) .await; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + let text = event.stdout; assert!( text.contains("HELLO-UEXEC"), - "delta chunk missing expected text: {text:?}" + "delta chunk missing expected text: {text:?}", ); wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; @@ -665,7 +665,116 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { +async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(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 call_id = "uexec-full-lifecycle"; + let args = json!({ + "cmd": "printf 'HELLO-FULL-LIFECYCLE'", + "yield_time_ms": 50, + }); + + 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: "exercise full unified exec lifecycle".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?; + + let mut begin_event = None; + let mut end_event = None; + let mut saw_delta_with_marker = 0; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev), + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => { + let text = String::from_utf8_lossy(&ev.chunk); + if text.contains("HELLO-FULL-LIFECYCLE") { + saw_delta_with_marker += 1; + } + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => { + assert!( + end_event.is_none(), + "expected a single ExecCommandEnd event for this call id" + ); + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); + assert_eq!(begin_event.call_id, call_id); + assert!( + begin_event.process_id.is_some(), + "begin event should include a process_id for a long-lived session" + ); + + assert_eq!( + saw_delta_with_marker, 0, + "no ExecCommandOutputDelta should be sent for early exit commands" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include process_id emitted by background watcher" + ); + assert!( + end_event.aggregated_output.contains("HELLO-FULL-LIFECYCLE"), + "aggregated_output should contain the full PTY transcript; got {:?}", + end_event.aggregated_output + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -740,27 +849,210 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { }) .await?; - // Expect a delta event corresponding to the write_stdin call. - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { - let text = String::from_utf8_lossy(&ev.chunk); - if text.contains("WSTDIN-MARK") { - Some(ev.clone()) - } else { - None - } - } - _ => None, - }) - .await; + let mut terminal_interaction = None; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_interaction = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let delta = terminal_interaction.expect("expected TerminalInteraction event"); + assert_eq!(delta.process_id, "1000"); + let expected_stdin = stdin_args + .get("chars") + .and_then(Value::as_str) + .expect("stdin chars"); + assert_eq!(delta.stdin, expected_stdin); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(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 open_call_id = "uexec-delayed-open"; + let open_args = json!({ + "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "yield_time_ms": 10, + }); + + // Poll stdin three times: first for no output, second after the first marker, + // and a final long poll to capture the second marker. + let first_poll_call_id = "uexec-delayed-poll-1"; + let first_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10, + }); + + let second_poll_call_id = "uexec-delayed-poll-2"; + let second_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 6000, + }); + + let third_poll_call_id = "uexec-delayed-poll-3"; + let third_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10000, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + open_call_id, + "exec_command", + &serde_json::to_string(&open_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + first_poll_call_id, + "write_stdin", + &serde_json::to_string(&first_poll_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + second_poll_call_id, + "write_stdin", + &serde_json::to_string(&second_poll_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_function_call( + third_poll_call_id, + "write_stdin", + &serde_json::to_string(&third_poll_args)?, + ), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-1", "complete"), + ev_completed("resp-5"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "delayed terminal interaction output".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut terminal_events = Vec::new(); + let mut delta_text = String::new(); + + // Consume all events for this turn so we can assert on each stage. + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == open_call_id => { + begin_event = Some(ev); + } + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { + delta_text.push_str(&String::from_utf8_lossy(&ev.chunk)); + } + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_events.push(ev); + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); assert!( - text.contains("WSTDIN-MARK"), - "stdin delta chunk missing expected text: {text:?}" + begin_event.process_id.is_some(), + "begin event should include process_id for a live session" + ); + + // We expect three terminal interactions matching the three write_stdin calls. + assert_eq!( + terminal_events.len(), + 3, + "expected three terminal interactions; got {terminal_events:?}" + ); + + for event in &terminal_events { + assert_eq!(event.call_id, open_call_id); + assert_eq!(event.process_id, "1000"); + } + assert_eq!( + terminal_events + .iter() + .map(|ev| ev.stdin.as_str()) + .collect::>(), + vec!["", "", ""], + "terminal interactions should reflect the three stdin polls" + ); + + assert!( + delta_text.contains("MARKER1") && delta_text.contains("MARKER2"), + "streamed deltas should contain both markers; got {delta_text:?}" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, open_call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include the process_id" + ); + assert!( + end_event.aggregated_output.contains("MARKER1") + && end_event.aggregated_output.contains("MARKER2"), + "aggregated output should include both markers in order; got {:?}", + end_event.aggregated_output ); - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; Ok(()) } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 64a5358f35..6eec8b71fc 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -566,6 +566,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::WebSearchBegin(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 23dff015eb..03c51662b1 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -72,6 +73,7 @@ pub struct EventProcessorWithJsonOutput { struct RunningCommand { command: String, item_id: String, + aggregated_output: String, } #[derive(Debug, Clone)] @@ -109,6 +111,10 @@ impl EventProcessorWithJsonOutput { EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), + EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), + EventMsg::ExecCommandOutputDelta(ev) => { + self.handle_output_chunk(&ev.call_id, &ev.chunk) + } EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), @@ -172,6 +178,16 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { + //TODO see how we want to process them + vec![] + } + + fn handle_terminal_interaction(&mut self, _ev: &TerminalInteractionEvent) -> Vec { + //TODO see how we want to process them + vec![] + } + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), @@ -214,6 +230,7 @@ impl EventProcessorWithJsonOutput { RunningCommand { command: command_string.clone(), item_id: item_id.clone(), + aggregated_output: String::new(), }, ); @@ -366,7 +383,11 @@ impl EventProcessorWithJsonOutput { } fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) + let Some(RunningCommand { + command, + item_id, + aggregated_output, + }) = self.running_commands.remove(&ev.call_id) else { warn!( call_id = ev.call_id, @@ -379,12 +400,17 @@ impl EventProcessorWithJsonOutput { } else { CommandExecutionStatus::Failed }; + let aggregated_output = if ev.aggregated_output.is_empty() { + aggregated_output + } else { + ev.aggregated_output.clone() + }; let item = ThreadItem { id: item_id, details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, - aggregated_output: ev.aggregated_output.clone(), + aggregated_output, exit_code: Some(ev.exit_code), status, }), @@ -455,6 +481,21 @@ impl EventProcessorWithJsonOutput { items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } + if !self.running_commands.is_empty() { + for (_, running) in self.running_commands.drain() { + let item = ThreadItem { + id: running.item_id, + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: running.command, + aggregated_output: running.aggregated_output, + exit_code: None, + status: CommandExecutionStatus::Completed, + }), + }; + items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + } + if let Some(error) = self.last_critical_error.take() { items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); } else { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 7b2d3c18ba..2b3673f5a6 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -48,6 +48,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -699,6 +701,93 @@ fn exec_command_end_success_produces_completed_command_item() { ); } +#[test] +fn command_execution_output_delta_updates_item_progress() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo delta".to_string(), + ]; + let cwd = std::env::current_dir().unwrap(); + let parsed_cmd = Vec::new(); + + let begin = event( + "d1", + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + ); + let out_begin = ep.collect_thread_events(&begin); + assert_eq!( + out_begin, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })] + ); + + let delta = event( + "d2", + EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: "delta-1".to_string(), + stream: ExecOutputStream::Stdout, + chunk: b"partial output\n".to_vec(), + }), + ); + let out_delta = ep.collect_thread_events(&delta); + assert_eq!(out_delta, Vec::::new()); + + let end = event( + "d3", + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_millis(3), + formatted_output: String::new(), + }), + ); + let out_end = ep.collect_thread_events(&end); + assert_eq!( + out_end, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + })] + ); +} + #[test] fn exec_command_end_failure_produces_failed_command_item() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index aa895d8dd3..908cba1cc3 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -282,6 +282,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 89b5fd315a..1e40618f01 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,6 +518,9 @@ pub enum EventMsg { /// Incremental chunk of output from a running command. ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), + /// Terminal interaction for an in-progress command (stdin sent and stdout observed). + TerminalInteraction(TerminalInteractionEvent), + ExecCommandEnd(ExecCommandEndEvent), /// Notification that the agent attached a local image via the view_image tool. @@ -1455,6 +1458,17 @@ pub struct ExecCommandOutputDeltaEvent { pub chunk: Vec, } +#[serde_as] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TerminalInteractionEvent { + /// Identifier for the ExecCommandBegin that produced this chunk. + pub call_id: String, + /// Process id associated with the running command. + pub process_id: String, + /// Stdin sent to the running session. + pub stdin: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1d5ad24a03..dd623ed583 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -46,6 +46,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; @@ -825,6 +826,10 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } + fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready + } + fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, @@ -1022,11 +1027,7 @@ impl ChatWidget { } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => ( - vec![ev.call_id.clone()], - Vec::new(), - ExecCommandSource::Agent, - ), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), }; let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); @@ -1043,7 +1044,7 @@ impl ChatWidget { command, parsed, source, - None, + ev.interaction_input.clone(), self.config.animations, ))); } @@ -1789,6 +1790,7 @@ impl ChatWidget { match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); @@ -1846,6 +1848,7 @@ impl ChatWidget { self.on_elicitation_request(ev); } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5cc5321f37..fc1d25edfe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1175,6 +1175,49 @@ fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo orphaned".to_string(), + ]; + let parsed_cmd = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "call-orphan".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-orphan".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done".to_string(), + stderr: String::new(), + aggregated_output: "done".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo orphaned"), + "expected command text to come from event: {blob:?}" + ); + assert!( + !blob.contains("call-orphan"), + "call id should not be rendered when event has the command: {blob:?}" + ); +} + #[test] fn exec_history_shows_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index dbaf4b81f7..cd98107ca0 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -94,10 +94,8 @@ impl ExecCommandSession { pub fn exit_code(&self) -> Option { self.exit_code.lock().ok().and_then(|guard| *guard) } -} -impl Drop for ExecCommandSession { - fn drop(&mut self) { + pub fn terminate(&self) { if let Ok(mut killer_opt) = self.killer.lock() { if let Some(mut killer) = killer_opt.take() { let _ = killer.kill(); @@ -122,6 +120,12 @@ impl Drop for ExecCommandSession { } } +impl Drop for ExecCommandSession { + fn drop(&mut self) { + self.terminate(); + } +} + #[derive(Debug)] pub struct SpawnedPty { pub session: ExecCommandSession, From 463249eff3ddab40ee17b1647dea3f39b23fa000 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 16:35:28 +0000 Subject: [PATCH 10/19] fix: flaky test 2 (#7818) --- codex-rs/core/src/shell_snapshot.rs | 30 ++++-- .../core/src/unified_exec/async_watcher.rs | 101 +++++++++++++----- codex-rs/core/src/unified_exec/session.rs | 30 +++--- codex-rs/core/tests/suite/shell_snapshot.rs | 6 +- codex-rs/core/tests/suite/unified_exec.rs | 15 ++- 5 files changed, 127 insertions(+), 55 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 4df54997b7..2c4c423f5c 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -367,6 +367,10 @@ mod tests { #[tokio::test] async fn timed_out_snapshot_shell_is_terminated() -> Result<()> { use std::process::Stdio; + use tokio::time::Duration as TokioDuration; + use tokio::time::Instant; + use tokio::time::sleep; + let dir = tempdir()?; let shell_path = dir.path().join("hanging-shell.sh"); let pid_path = dir.path().join("pid"); @@ -402,16 +406,22 @@ mod tests { .trim() .parse::()?; - let kill_status = StdCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stderr(Stdio::null()) - .stdout(Stdio::null()) - .status()?; - assert!( - !kill_status.success(), - "timed out snapshot shell should be terminated" - ); + let deadline = Instant::now() + TokioDuration::from_secs(1); + loop { + let kill_status = StdCommand::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + if !kill_status.success() { + break; + } + if Instant::now() >= deadline { + panic!("timed out snapshot shell is still alive after grace period"); + } + sleep(TokioDuration::from_millis(50)).await; + } Ok(()) } diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 7412d29720..19d91dbccf 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -1,9 +1,11 @@ use std::path::PathBuf; +use std::pin::Pin; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::Duration; use tokio::time::Instant; +use tokio::time::Sleep; use crate::codex::Session; use crate::codex::TurnContext; @@ -21,6 +23,8 @@ use super::CommandTranscript; use super::UnifiedExecContext; use super::session::UnifiedExecSession; +pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); + /// Spawn a background task that continuously reads from the PTY, appends to the /// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 /// boundaries. @@ -30,39 +34,58 @@ pub(crate) fn start_streaming_output( transcript: Arc>, ) { let mut receiver = session.output_receiver(); + let output_drained = session.output_drained_notify(); + let exit_token = session.cancellation_token(); + let session_ref = Arc::clone(&context.session); let turn_ref = Arc::clone(&context.turn); let call_id = context.call_id.clone(); - let cancellation_token = session.cancellation_token(); tokio::spawn(async move { - let mut pending: Vec = Vec::new(); + use tokio::sync::broadcast::error::RecvError; + + let mut pending = Vec::::new(); + + let mut grace_sleep: Option>> = None; + loop { tokio::select! { - _ = cancellation_token.cancelled() => break, - result = receiver.recv() => match result { - Ok(chunk) => { - pending.extend_from_slice(&chunk); - while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { - { - let mut guard = transcript.lock().await; - guard.append(&prefix); - } - - let event = ExecCommandOutputDeltaEvent { - call_id: call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: prefix, - }; - session_ref - .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) - .await; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + _ = exit_token.cancelled(), if grace_sleep.is_none() => { + let deadline = Instant::now() + TRAILING_OUTPUT_GRACE; + grace_sleep.replace(Box::pin(tokio::time::sleep_until(deadline))); } - }; + + _ = async { + if let Some(sleep) = grace_sleep.as_mut() { + sleep.as_mut().await; + } + }, if grace_sleep.is_some() => { + output_drained.notify_one(); + break; + } + + received = receiver.recv() => { + let chunk = match received { + Ok(chunk) => chunk, + Err(RecvError::Lagged(_)) => { + continue; + }, + Err(RecvError::Closed) => { + output_drained.notify_one(); + break; + } + }; + + process_chunk( + &mut pending, + &transcript, + &call_id, + &session_ref, + &turn_ref, + chunk, + ).await; + } + } } }); } @@ -82,9 +105,11 @@ pub(crate) fn spawn_exit_watcher( started_at: Instant, ) { let exit_token = session.cancellation_token(); + let output_drained = session.output_drained_notify(); tokio::spawn(async move { exit_token.cancelled().await; + output_drained.notified().await; let exit_code = session.exit_code().unwrap_or(-1); let duration = Instant::now().saturating_duration_since(started_at); @@ -104,6 +129,32 @@ pub(crate) fn spawn_exit_watcher( }); } +async fn process_chunk( + pending: &mut Vec, + transcript: &Arc>, + call_id: &str, + session_ref: &Arc, + turn_ref: &Arc, + chunk: Vec, +) { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.to_string(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } +} + /// Emit an ExecCommandEnd event for a unified exec session, using the transcript /// as the primary source of aggregated_output and falling back to the provided /// text when the transcript is empty. diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 51ebbd3569..4973a1a641 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -79,6 +79,7 @@ pub(crate) struct UnifiedExecSession { output_buffer: OutputBuffer, output_notify: Arc, cancellation_token: CancellationToken, + output_drained: Arc, output_task: JoinHandle<()>, sandbox_type: SandboxType, } @@ -92,27 +93,21 @@ impl UnifiedExecSession { let output_buffer = Arc::new(Mutex::new(OutputBufferState::default())); let output_notify = Arc::new(Notify::new()); let cancellation_token = CancellationToken::new(); + let output_drained = Arc::new(Notify::new()); let mut receiver = initial_output_rx; let buffer_clone = Arc::clone(&output_buffer); let notify_clone = Arc::clone(&output_notify); - let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - tokio::select! { - _ = cancellation_token_clone.cancelled() => break, - result = receiver.recv() => match result { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } + match receiver.recv().await { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, }; } }); @@ -122,6 +117,7 @@ impl UnifiedExecSession { output_buffer, output_notify, cancellation_token, + output_drained, output_task, sandbox_type, } @@ -147,6 +143,10 @@ impl UnifiedExecSession { self.cancellation_token.clone() } + pub(super) fn output_drained_notify(&self) -> Arc { + Arc::clone(&self.output_drained) + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index f50e153ddc..cc9d4ee77c 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -132,6 +132,7 @@ fn assert_posix_snapshot_sections(snapshot: &str) { async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { let command = "echo snapshot-linux"; let run = run_snapshot_command(command).await?; + let stdout = normalize_newlines(&run.end.stdout); let shell_path = run .begin @@ -150,8 +151,11 @@ async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { assert!(run.snapshot_path.starts_with(&run.codex_home)); assert_posix_snapshot_sections(&run.snapshot_content); - assert_eq!(normalize_newlines(&run.end.stdout).trim(), "snapshot-linux"); assert_eq!(run.end.exit_code, 0); + assert!( + stdout.contains("snapshot-linux"), + "stdout should contain snapshot marker; stdout={stdout:?}" + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 5def7aadb2..e2dcb0c567 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -228,6 +228,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { false } EventMsg::ExecCommandBegin(event) if event.call_id == call_id => { + println!("Saw it"); saw_exec_begin = true; false } @@ -893,7 +894,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let open_call_id = "uexec-delayed-open"; let open_args = json!({ - "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", "yield_time_ms": 10, }); @@ -910,14 +911,14 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let second_poll_args = json!({ "chars": "", "session_id": 1000, - "yield_time_ms": 6000, + "yield_time_ms": 4000, }); let third_poll_call_id = "uexec-delayed-poll-3"; let third_poll_args = json!({ "chars": "", "session_id": 1000, - "yield_time_ms": 10000, + "yield_time_ms": 6000, }); let responses = vec![ @@ -984,6 +985,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let mut begin_event = None; let mut end_event = None; + let mut task_completed = false; let mut terminal_events = Vec::new(); let mut delta_text = String::new(); @@ -1003,8 +1005,13 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { end_event = Some(ev); } - EventMsg::TaskComplete(_) => break, + EventMsg::TaskComplete(_) => { + task_completed = true; + } _ => {} + }; + if task_completed && end_event.is_some() { + break; } } From 97b90094cdda249e99de33b7d44a50fe6c47ea4e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 17:04:52 +0000 Subject: [PATCH 11/19] feat: use remote branch for review is local trails (#7813) --- codex-rs/utils/git/src/branch.rs | 140 +++++++++++++++++++++++++++---- 1 file changed, 126 insertions(+), 14 deletions(-) diff --git a/codex-rs/utils/git/src/branch.rs b/codex-rs/utils/git/src/branch.rs index 543dffd9c9..f65de0c6bf 100644 --- a/codex-rs/utils/git/src/branch.rs +++ b/codex-rs/utils/git/src/branch.rs @@ -7,7 +7,8 @@ use crate::operations::resolve_head; use crate::operations::resolve_repository_root; use crate::operations::run_git_for_stdout; -/// Returns the merge-base commit between `HEAD` and the provided branch, if both exist. +/// Returns the merge-base commit between `HEAD` and the latest version between local +/// and remote of the provided branch, if both exist. /// /// The function mirrors `git merge-base HEAD ` but returns `Ok(None)` when /// the repository has no `HEAD` yet or when the branch cannot be resolved. @@ -22,26 +23,23 @@ pub fn merge_base_with_head( None => return Ok(None), }; - let branch_ref = match run_git_for_stdout( - repo_root.as_path(), - vec![ - OsString::from("rev-parse"), - OsString::from("--verify"), - OsString::from(branch), - ], - None, - ) { - Ok(rev) => rev, - Err(GitToolingError::GitCommand { .. }) => return Ok(None), - Err(other) => return Err(other), + let Some(branch_ref) = resolve_branch_ref(repo_root.as_path(), branch)? else { + return Ok(None); }; + let preferred_ref = + if let Some(upstream) = resolve_upstream_if_remote_ahead(repo_root.as_path(), branch)? { + resolve_branch_ref(repo_root.as_path(), &upstream)?.unwrap_or(branch_ref) + } else { + branch_ref + }; + let merge_base = run_git_for_stdout( repo_root.as_path(), vec![ OsString::from("merge-base"), OsString::from(head), - OsString::from(branch_ref), + OsString::from(preferred_ref), ], None, )?; @@ -49,6 +47,75 @@ pub fn merge_base_with_head( Ok(Some(merge_base)) } +fn resolve_branch_ref(repo_root: &Path, branch: &str) -> Result, GitToolingError> { + let rev = run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-parse"), + OsString::from("--verify"), + OsString::from(branch), + ], + None, + ); + + match rev { + Ok(rev) => Ok(Some(rev)), + Err(GitToolingError::GitCommand { .. }) => Ok(None), + Err(other) => Err(other), + } +} + +fn resolve_upstream_if_remote_ahead( + repo_root: &Path, + branch: &str, +) -> Result, GitToolingError> { + let upstream = match run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-parse"), + OsString::from("--abbrev-ref"), + OsString::from("--symbolic-full-name"), + OsString::from(format!("{branch}@{{upstream}}")), + ], + None, + ) { + Ok(name) => { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Ok(None); + } + trimmed.to_string() + } + Err(GitToolingError::GitCommand { .. }) => return Ok(None), + Err(other) => return Err(other), + }; + + let counts = match run_git_for_stdout( + repo_root, + vec![ + OsString::from("rev-list"), + OsString::from("--left-right"), + OsString::from("--count"), + OsString::from(format!("{branch}...{upstream}")), + ], + None, + ) { + Ok(counts) => counts, + Err(GitToolingError::GitCommand { .. }) => return Ok(None), + Err(other) => return Err(other), + }; + + let mut parts = counts.split_whitespace(); + let _left: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + let right: i64 = parts.next().unwrap_or("0").parse().unwrap_or(0); + + if right > 0 { + Ok(Some(upstream)) + } else { + Ok(None) + } +} + #[cfg(test)] mod tests { use super::merge_base_with_head; @@ -126,6 +193,51 @@ mod tests { Ok(()) } + #[test] + fn merge_base_prefers_upstream_when_remote_ahead() -> Result<(), GitToolingError> { + let temp = tempdir()?; + let repo = temp.path().join("repo"); + let remote = temp.path().join("remote.git"); + std::fs::create_dir_all(&repo)?; + std::fs::create_dir_all(&remote)?; + + run_git_in(&remote, &["init", "--bare"]); + run_git_in(&repo, &["init", "--initial-branch=main"]); + run_git_in(&repo, &["config", "core.autocrlf", "false"]); + + std::fs::write(repo.join("base.txt"), "base\n")?; + run_git_in(&repo, &["add", "base.txt"]); + commit(&repo, "base commit"); + + run_git_in( + &repo, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + run_git_in(&repo, &["push", "-u", "origin", "main"]); + + run_git_in(&repo, &["checkout", "-b", "feature"]); + std::fs::write(repo.join("feature.txt"), "feature change\n")?; + run_git_in(&repo, &["add", "feature.txt"]); + commit(&repo, "feature commit"); + + run_git_in(&repo, &["checkout", "--orphan", "rewrite"]); + run_git_in(&repo, &["rm", "-rf", "."]); + std::fs::write(repo.join("new-main.txt"), "rewritten main\n")?; + run_git_in(&repo, &["add", "new-main.txt"]); + commit(&repo, "rewrite main"); + run_git_in(&repo, &["branch", "-M", "rewrite", "main"]); + run_git_in(&repo, &["branch", "--set-upstream-to=origin/main", "main"]); + + run_git_in(&repo, &["checkout", "feature"]); + run_git_in(&repo, &["fetch", "origin"]); + + let expected = run_git_stdout(&repo, &["merge-base", "HEAD", "origin/main"]); + let merge_base = merge_base_with_head(&repo, "main")?; + assert_eq!(merge_base, Some(expected)); + + Ok(()) + } + #[test] fn merge_base_returns_none_when_branch_missing() -> Result<(), GitToolingError> { let temp = tempdir()?; From e0fb3ca1dbea0c418cf8b3c7b76ed671d62147e3 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Wed, 10 Dec 2025 09:18:48 -0800 Subject: [PATCH 12/19] refactoring with_escalated_permissions to use SandboxPermissions instead (#7750) helpful in the future if we want more granularity for requesting escalated permissions: e.g when running in readonly sandbox, model can request to escalate to a sandbox that allows writes --- .../app-server/src/codex_message_processor.rs | 3 +- codex-rs/core/gpt-5.1-codex-max_prompt.md | 6 +- codex-rs/core/gpt_5_1_prompt.md | 6 +- codex-rs/core/gpt_5_codex_prompt.md | 6 +- codex-rs/core/src/codex.rs | 13 +- codex-rs/core/src/exec.rs | 15 +- codex-rs/core/src/sandboxing/mod.rs | 29 +--- codex-rs/core/src/tasks/user_shell.rs | 3 +- codex-rs/core/src/tools/handlers/shell.rs | 23 ++- .../core/src/tools/handlers/unified_exec.rs | 9 +- codex-rs/core/src/tools/router.rs | 3 +- .../core/src/tools/runtimes/apply_patch.rs | 3 +- codex-rs/core/src/tools/runtimes/mod.rs | 5 +- codex-rs/core/src/tools/runtimes/shell.rs | 11 +- .../core/src/tools/runtimes/unified_exec.rs | 15 +- codex-rs/core/src/tools/spec.rs | 24 ++-- codex-rs/core/src/unified_exec/mod.rs | 5 +- .../core/src/unified_exec/session_manager.rs | 8 +- codex-rs/core/tests/suite/approvals.rs | 131 ++++++++++-------- codex-rs/core/tests/suite/codex_delegate.rs | 3 +- codex-rs/core/tests/suite/exec.rs | 3 +- codex-rs/core/tests/suite/tools.rs | 3 +- codex-rs/exec-server/src/posix.rs | 16 ++- .../exec-server/src/posix/escalate_server.rs | 3 +- .../src/posix/mcp_escalation_policy.rs | 13 +- .../linux-sandbox/tests/suite/landlock.rs | 5 +- codex-rs/protocol/src/models.rs | 31 ++++- 27 files changed, 216 insertions(+), 179 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0a8445055d..8576c5c381 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -136,6 +136,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget as CoreReviewTarget; use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; +use codex_core::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; @@ -1191,7 +1192,7 @@ impl CodexMessageProcessor { cwd, expiration: timeout_ms.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/gpt-5.1-codex-max_prompt.md b/codex-rs/core/gpt-5.1-codex-max_prompt.md index 292e5d7d0f..a8227c893f 100644 --- a/codex-rs/core/gpt-5.1-codex-max_prompt.md +++ b/codex-rs/core/gpt-5.1-codex-max_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/gpt_5_1_prompt.md b/codex-rs/core/gpt_5_1_prompt.md index 97a3875fe5..3201ffeb68 100644 --- a/codex-rs/core/gpt_5_1_prompt.md +++ b/codex-rs/core/gpt_5_1_prompt.md @@ -182,7 +182,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters. Within this harness, prefer requesting approval via the tool over asking in natural language. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -193,8 +193,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Validating your work diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 57d06761ba..e2f9017874 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -48,7 +48,7 @@ When you are running with `approval_policy == on-request`, and sandboxing enable - You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) - You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. - You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. - You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for - (for all of these, you should weigh alternative paths that do not require approval) @@ -59,8 +59,8 @@ You will be told what filesystem sandboxing, network sandboxing, and approval mo Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. When requesting approval to execute a command that will require escalated privileges: - - Provide the `with_escalated_permissions` parameter with the boolean value true - - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter ## Special user requests diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 042ae1a37a..4129bb6a1f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3325,6 +3325,7 @@ mod tests { use crate::exec::ExecParams; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; use std::collections::HashMap; @@ -3335,6 +3336,7 @@ mod tests { let mut turn_context = Arc::new(turn_context_raw); let timeout_ms = 1000; + let sandbox_permissions = SandboxPermissions::RequireEscalated; let params = ExecParams { command: if cfg!(windows) { vec![ @@ -3352,13 +3354,13 @@ mod tests { cwd: turn_context.cwd.clone(), expiration: timeout_ms.into(), env: HashMap::new(), - with_escalated_permissions: Some(true), + sandbox_permissions, justification: Some("test".to_string()), arg0: None, }; let params2 = ExecParams { - with_escalated_permissions: Some(false), + sandbox_permissions: SandboxPermissions::UseDefault, command: params.command.clone(), cwd: params.cwd.clone(), expiration: timeout_ms.into(), @@ -3385,7 +3387,7 @@ mod tests { "command": params.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params.expiration.timeout_ms(), - "with_escalated_permissions": params.with_escalated_permissions, + "sandbox_permissions": params.sandbox_permissions, "justification": params.justification.clone(), }) .to_string(), @@ -3422,7 +3424,7 @@ mod tests { "command": params2.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), "timeout_ms": params2.expiration.timeout_ms(), - "with_escalated_permissions": params2.with_escalated_permissions, + "sandbox_permissions": params2.sandbox_permissions, "justification": params2.justification.clone(), }) .to_string(), @@ -3455,6 +3457,7 @@ mod tests { #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::protocol::AskForApproval; + use crate::sandboxing::SandboxPermissions; use crate::turn_diff_tracker::TurnDiffTracker; let (session, mut turn_context_raw) = make_session_and_context(); @@ -3474,7 +3477,7 @@ mod tests { payload: ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo hi", - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, "justification": "need unsandboxed execution", }) .to_string(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index ba1ac43004..596f325059 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -28,6 +28,7 @@ use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxManager; +use crate::sandboxing::SandboxPermissions; use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; @@ -55,7 +56,7 @@ pub struct ExecParams { pub cwd: PathBuf, pub expiration: ExecExpiration, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -144,7 +145,7 @@ pub async fn process_exec_tool_call( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0: _, } = params; @@ -162,7 +163,7 @@ pub async fn process_exec_tool_call( cwd, env, expiration, - with_escalated_permissions, + sandbox_permissions, justification, }; @@ -192,7 +193,7 @@ pub(crate) async fn execute_exec_env( env, expiration, sandbox, - with_escalated_permissions, + sandbox_permissions, justification, arg0, } = env; @@ -202,7 +203,7 @@ pub(crate) async fn execute_exec_env( cwd, expiration, env, - with_escalated_permissions, + sandbox_permissions, justification, arg0, }; @@ -857,7 +858,7 @@ mod tests { cwd: std::env::current_dir()?, expiration: 500.into(), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -902,7 +903,7 @@ mod tests { cwd: cwd.clone(), expiration: ExecExpiration::Cancellation(cancel_token), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index d43646021e..3f56ce3ae9 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -23,32 +23,11 @@ use crate::seatbelt::create_seatbelt_command_args; use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use crate::tools::sandboxing::SandboxablePreference; +pub use codex_protocol::models::SandboxPermissions; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SandboxPermissions { - UseDefault, - RequireEscalated, -} - -impl SandboxPermissions { - pub fn requires_escalated_permissions(self) -> bool { - matches!(self, SandboxPermissions::RequireEscalated) - } -} - -impl From for SandboxPermissions { - fn from(with_escalated_permissions: bool) -> Self { - if with_escalated_permissions { - SandboxPermissions::RequireEscalated - } else { - SandboxPermissions::UseDefault - } - } -} - #[derive(Debug)] pub struct CommandSpec { pub program: String, @@ -56,7 +35,7 @@ pub struct CommandSpec { pub cwd: PathBuf, pub env: HashMap, pub expiration: ExecExpiration, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -67,7 +46,7 @@ pub struct ExecEnv { pub env: HashMap, pub expiration: ExecExpiration, pub sandbox: SandboxType, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub arg0: Option, } @@ -181,7 +160,7 @@ impl SandboxManager { env, expiration: spec.expiration, sandbox, - with_escalated_permissions: spec.with_escalated_permissions, + sandbox_permissions: spec.sandbox_permissions, justification: spec.justification, arg0: arg0_override, }) diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index ca5243241a..aec09514ca 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -24,6 +24,7 @@ use crate::protocol::ExecCommandSource; use crate::protocol::SandboxPolicy; use crate::protocol::TaskStartedEvent; use crate::sandboxing::ExecEnv; +use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::user_shell_command::user_shell_command_record_item; @@ -100,7 +101,7 @@ impl SessionTask for UserShellCommandTask { // should use that instead of an "arbitrarily large" timeout here. expiration: USER_SHELL_TIMEOUT_MS.into(), sandbox: SandboxType::None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 98bd883d13..9c306a186e 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -10,7 +10,6 @@ use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; -use crate::sandboxing::SandboxPermissions; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -35,7 +34,7 @@ impl ShellHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -56,7 +55,7 @@ impl ShellCommandHandler { cwd: turn_context.resolve_path(params.workdir.clone()), expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), - with_escalated_permissions: params.with_escalated_permissions, + sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), justification: params.justification, arg0: None, } @@ -206,7 +205,9 @@ impl ShellHandler { freeform: bool, ) -> Result { // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params.with_escalated_permissions.unwrap_or(false) + if exec_params + .sandbox_permissions + .requires_escalated_permissions() && !matches!( turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -251,7 +252,7 @@ impl ShellHandler { &exec_params.command, turn.approval_policy, &turn.sandbox_policy, - SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)), + exec_params.sandbox_permissions, ) .await; @@ -260,7 +261,7 @@ impl ShellHandler { cwd: exec_params.cwd.clone(), timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), - with_escalated_permissions: exec_params.with_escalated_permissions, + sandbox_permissions: exec_params.sandbox_permissions, justification: exec_params.justification.clone(), exec_approval_requirement, }; @@ -295,6 +296,7 @@ mod tests { use crate::codex::make_session_and_context; use crate::exec_env::create_env; use crate::is_safe_command::is_known_safe_command; + use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::handlers::ShellCommandHandler; @@ -343,7 +345,7 @@ mod tests { let workdir = Some("subdir".to_string()); let login = None; let timeout_ms = Some(1234); - let with_escalated_permissions = Some(true); + let sandbox_permissions = SandboxPermissions::RequireEscalated; let justification = Some("because tests".to_string()); let expected_command = session.user_shell().derive_exec_args(&command, true); @@ -355,7 +357,7 @@ mod tests { workdir, login, timeout_ms, - with_escalated_permissions, + sandbox_permissions: Some(sandbox_permissions), justification: justification.clone(), }; @@ -366,10 +368,7 @@ mod tests { assert_eq!(exec_params.cwd, expected_cwd); assert_eq!(exec_params.env, expected_env); assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); - assert_eq!( - exec_params.with_escalated_permissions, - with_escalated_permissions - ); + assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); assert_eq!(exec_params.justification, justification); assert_eq!(exec_params.arg0, None); } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index abaaf4a7ab..0d3a11da10 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -3,6 +3,7 @@ use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; use crate::protocol::TerminalInteractionEvent; +use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -40,7 +41,7 @@ struct ExecCommandArgs { #[serde(default)] max_output_tokens: Option, #[serde(default)] - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, #[serde(default)] justification: Option, } @@ -131,12 +132,12 @@ impl ToolHandler for UnifiedExecHandler { login, yield_time_ms, max_output_tokens, - with_escalated_permissions, + sandbox_permissions, justification, .. } = args; - if with_escalated_permissions.unwrap_or(false) + if sandbox_permissions.requires_escalated_permissions() && !matches!( context.turn.approval_policy, codex_protocol::protocol::AskForApproval::OnRequest @@ -200,7 +201,7 @@ impl ToolHandler for UnifiedExecHandler { yield_time_ms, max_output_tokens, workdir, - with_escalated_permissions, + sandbox_permissions, justification, }, &context, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 7152d3c1ec..b6675bcd5d 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -5,6 +5,7 @@ use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::sandboxing::SandboxPermissions; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -114,7 +115,7 @@ impl ToolRouter { command: exec.command, workdir: exec.working_directory, timeout_ms: exec.timeout_ms, - with_escalated_permissions: None, + sandbox_permissions: Some(SandboxPermissions::UseDefault), justification: None, }; Ok(Some(ToolCall { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 7ef8d33767..bf4b66ce9d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -7,6 +7,7 @@ use crate::CODEX_APPLY_PATCH_ARG1; use crate::exec::ExecToolCallOutput; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -70,7 +71,7 @@ impl ApplyPatchRuntime { expiration: req.timeout_ms.into(), // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }) } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 437f4af428..2431b3c97d 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -6,6 +6,7 @@ small and focused and reuses the orchestrator for approvals + sandbox + retry. */ use crate::exec::ExecExpiration; use crate::sandboxing::CommandSpec; +use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ToolError; use std::collections::HashMap; use std::path::Path; @@ -21,7 +22,7 @@ pub(crate) fn build_command_spec( cwd: &Path, env: &HashMap, expiration: ExecExpiration, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, ) -> Result { let (program, args) = command @@ -33,7 +34,7 @@ pub(crate) fn build_command_spec( cwd: cwd.to_path_buf(), env: env.clone(), expiration, - with_escalated_permissions, + sandbox_permissions, justification, }) } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 50b6a6785a..595bda0e96 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -5,6 +5,7 @@ Executes shell requests under the orchestrator: asks for approval when needed, builds a CommandSpec, and runs it under the current SandboxAttempt. */ use crate::exec::ExecToolCallOutput; +use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; @@ -30,7 +31,7 @@ pub struct ShellRequest { pub cwd: PathBuf, pub timeout_ms: Option, pub env: std::collections::HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -51,7 +52,7 @@ pub struct ShellRuntime; pub(crate) struct ApprovalKey { command: Vec, cwd: PathBuf, - escalated: bool, + sandbox_permissions: SandboxPermissions, } impl ShellRuntime { @@ -84,7 +85,7 @@ impl Approvable for ShellRuntime { ApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -129,7 +130,7 @@ impl Approvable for ShellRuntime { } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { @@ -157,7 +158,7 @@ impl ToolRuntime for ShellRuntime { &req.cwd, &req.env, req.timeout_ms.into(), - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), )?; let env = attempt diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index d21e6de1e2..b6a8047080 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,6 +7,7 @@ the session manager to spawn PTYs once an ExecEnv is prepared. use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecExpiration; +use crate::sandboxing::SandboxPermissions; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -34,7 +35,7 @@ pub struct UnifiedExecRequest { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, pub exec_approval_requirement: ExecApprovalRequirement, } @@ -52,7 +53,7 @@ impl ProvidesSandboxRetryData for UnifiedExecRequest { pub struct UnifiedExecApprovalKey { pub command: Vec, pub cwd: PathBuf, - pub escalated: bool, + pub sandbox_permissions: SandboxPermissions, } pub struct UnifiedExecRuntime<'a> { @@ -64,7 +65,7 @@ impl UnifiedExecRequest { command: Vec, cwd: PathBuf, env: HashMap, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, exec_approval_requirement: ExecApprovalRequirement, ) -> Self { @@ -72,7 +73,7 @@ impl UnifiedExecRequest { command, cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, } @@ -102,7 +103,7 @@ impl Approvable for UnifiedExecRuntime<'_> { UnifiedExecApprovalKey { command: req.command.clone(), cwd: req.cwd.clone(), - escalated: req.with_escalated_permissions.unwrap_or(false), + sandbox_permissions: req.sandbox_permissions, } } @@ -150,7 +151,7 @@ impl Approvable for UnifiedExecRuntime<'_> { } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { - if req.with_escalated_permissions.unwrap_or(false) + if req.sandbox_permissions.requires_escalated_permissions() || matches!( req.exec_approval_requirement, ExecApprovalRequirement::Skip { @@ -178,7 +179,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.cwd, &req.env, ExecExpiration::DefaultTimeout, - req.with_escalated_permissions, + req.sandbox_permissions, req.justification.clone(), ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 89a71b0edc..0b74b9e10a 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -174,10 +174,10 @@ fn create_exec_command_tool() -> ToolSpec { }, ); properties.insert( - "with_escalated_permissions".to_string(), - JsonSchema::Boolean { + "sandbox_permissions".to_string(), + JsonSchema::String { description: Some( - "Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions" + "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." .to_string(), ), }, @@ -186,7 +186,7 @@ fn create_exec_command_tool() -> ToolSpec { "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." + "Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command." .to_string(), ), }, @@ -274,15 +274,15 @@ fn create_shell_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()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".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()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); @@ -347,15 +347,15 @@ fn create_shell_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()), + "sandbox_permissions".to_string(), + JsonSchema::String { + description: Some("Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\".".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()), + description: Some("Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command.".to_string()), }, ); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 0d86b69fda..814001f41f 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,6 +33,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +use crate::sandboxing::SandboxPermissions; mod async_watcher; mod errors; @@ -93,7 +94,7 @@ pub(crate) struct ExecCommandRequest { pub yield_time_ms: u64, pub max_output_tokens: Option, pub workdir: Option, - pub with_escalated_permissions: Option, + pub sandbox_permissions: SandboxPermissions, pub justification: Option, } @@ -217,7 +218,7 @@ mod tests { yield_time_ms, max_output_tokens: None, workdir: None, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, }, &context, diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index fa64eb4bb2..4b24c574ac 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -126,7 +126,7 @@ impl UnifiedExecSessionManager { .open_session_with_sandbox( &request.command, cwd.clone(), - request.with_escalated_permissions, + request.sandbox_permissions, request.justification, context, ) @@ -476,7 +476,7 @@ impl UnifiedExecSessionManager { &self, command: &[String], cwd: PathBuf, - with_escalated_permissions: Option, + sandbox_permissions: SandboxPermissions, justification: Option, context: &UnifiedExecContext, ) -> Result { @@ -490,14 +490,14 @@ impl UnifiedExecSessionManager { command, context.turn.approval_policy, &context.turn.sandbox_policy, - SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)), + sandbox_permissions, ) .await; let req = UnifiedExecToolRequest::new( command.to_vec(), cwd, env, - with_escalated_permissions, + sandbox_permissions, justification, exec_approval_requirement, ); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 4570e6a5b9..879ad56d47 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -9,6 +9,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::protocol::ReviewDecision; use codex_protocol::user_input::UserInput; @@ -96,14 +97,14 @@ impl ActionKind { test: &TestCodex, server: &MockServer, call_id: &str, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result<(Value, Option)> { match self { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let command = format!("printf {content:?} > {path:?} && cat {path:?}"); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::FetchUrl { @@ -125,11 +126,11 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { - let event = shell_event(call_id, command, 1_000, with_escalated_permissions)?; + let event = shell_event(call_id, command, 1_000, sandbox_permissions)?; Ok((event, Some(command.to_string()))) } ActionKind::RunUnifiedExecCommand { @@ -140,7 +141,7 @@ impl ActionKind { call_id, command, Some(1000), - with_escalated_permissions, + sandbox_permissions, *justification, )?; Ok((event, Some(command.to_string()))) @@ -156,7 +157,7 @@ impl ActionKind { let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); let command = shell_apply_patch_command(&patch); - let event = shell_event(call_id, &command, 5_000, with_escalated_permissions)?; + let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?; Ok((event, Some(command))) } } @@ -181,14 +182,14 @@ fn shell_event( call_id: &str, command: &str, timeout_ms: u64, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, ) -> Result { let mut args = json!({ "command": command, "timeout_ms": timeout_ms, }); - if with_escalated_permissions { - args["with_escalated_permissions"] = json!(true); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "shell_command", &args_str)) @@ -198,7 +199,7 @@ fn exec_command_event( call_id: &str, cmd: &str, yield_time_ms: Option, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, justification: Option<&str>, ) -> Result { let mut args = json!({ @@ -207,8 +208,8 @@ fn exec_command_event( 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); + if sandbox_permissions.requires_escalated_permissions() { + args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); } @@ -466,7 +467,7 @@ struct ScenarioSpec { approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, action: ActionKind, - with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, features: Vec, model_override: Option<&'static str>, outcome: Outcome, @@ -637,7 +638,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -654,7 +655,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -671,7 +672,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -687,7 +688,7 @@ fn scenarios() -> Vec { endpoint: "/dfa/network", response_body: "danger-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -702,7 +703,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -717,7 +718,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-unless", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -733,7 +734,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -750,7 +751,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_on_failure_5_1.txt"), content: "danger-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -767,7 +768,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -787,7 +788,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"), content: "danger-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -807,7 +808,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -824,7 +825,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"), content: "danger-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -841,7 +842,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -861,7 +862,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -880,7 +881,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -895,7 +896,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-read-only", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, @@ -911,7 +912,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-blocked", response_body: "should-not-see", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -925,7 +926,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_request_denied.txt"), content: "should-not-write", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: None, outcome: Outcome::ExecApproval { @@ -946,7 +947,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -967,7 +968,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_on_failure_5_1.txt"), content: "read-only-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -987,7 +988,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1006,7 +1007,7 @@ fn scenarios() -> Vec { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1025,7 +1026,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1045,7 +1046,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function.txt"), content: "function-apply-patch", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1062,7 +1063,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), content: "function-patch-danger", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::ApplyPatchFreeform], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1079,7 +1080,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), content: "function-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1099,7 +1100,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), content: "function-patch-outside-denied", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1119,7 +1120,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), content: "shell-patch-outside", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::PatchApproval { @@ -1139,7 +1140,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), content: "function-patch-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::PatchApproval { @@ -1159,7 +1160,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), content: "function-patch-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1-codex"), outcome: Outcome::Auto, @@ -1178,7 +1179,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1198,7 +1199,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), content: "read-only-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::ExecApproval { @@ -1218,7 +1219,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ro_never.txt"), content: "read-only-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1241,7 +1242,7 @@ fn scenarios() -> Vec { action: ActionKind::RunCommand { command: "echo trusted-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1257,7 +1258,7 @@ fn scenarios() -> Vec { target: TargetPath::Workspace("ww_on_request.txt"), content: "workspace-on-request", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1274,7 +1275,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-blocked", response_body: "workspace-network-blocked", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1288,7 +1289,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), content: "workspace-on-request-outside", }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1308,7 +1309,7 @@ fn scenarios() -> Vec { endpoint: "/ww/network-ok", response_body: "workspace-network-ok", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1325,7 +1326,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_on_failure.txt"), content: "workspace-on-failure", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1345,7 +1346,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), content: "workspace-unless-trusted", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1365,7 +1366,7 @@ fn scenarios() -> Vec { target: TargetPath::OutsideWorkspace("ww_never.txt"), content: "workspace-never", }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: None, outcome: Outcome::Auto, @@ -1389,7 +1390,7 @@ fn scenarios() -> Vec { command: "echo \"hello unified exec\"", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::Auto, @@ -1407,7 +1408,7 @@ fn scenarios() -> Vec { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, - with_escalated_permissions: true, + sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], model_override: Some("gpt-5"), outcome: Outcome::ExecApproval { @@ -1426,7 +1427,7 @@ fn scenarios() -> Vec { command: "git reset --hard", justification: None, }, - with_escalated_permissions: false, + sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], model_override: None, outcome: Outcome::ExecApproval { @@ -1472,7 +1473,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { let call_id = scenario.name; let (event, expected_command) = scenario .action - .prepare(&test, &server, call_id, scenario.with_escalated_permissions) + .prepare(&test, &server, call_id, scenario.sandbox_permissions) .await?; let _ = mount_sse_once( @@ -1578,7 +1579,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (first_event, expected_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_first, false) + .prepare( + &test, + &server, + call_id_first, + SandboxPermissions::UseDefault, + ) .await?; let expected_command = expected_command.expect("execpolicy amendment scenario should produce a shell command"); @@ -1656,7 +1662,12 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts let (second_event, second_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } - .prepare(&test, &server, call_id_second, false) + .prepare( + &test, + &server, + call_id_second, + SandboxPermissions::UseDefault, + ) .await?; assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index f5fe1a7df9..2bd156d6a8 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -5,6 +5,7 @@ use codex_core::protocol::ReviewDecision; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -31,7 +32,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { let args = serde_json::json!({ "command": "rm -rf delegated", "timeout_ms": 1000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }) .to_string(); let sse1 = sse(vec![ diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index 6c2283107b..c093482157 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -8,6 +8,7 @@ use codex_core::exec::ExecToolCallOutput; use codex_core::exec::SandboxType; use codex_core::exec::process_exec_tool_call; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use tempfile::TempDir; @@ -34,7 +35,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result<()> { let first_args = json!({ "command": command, "timeout_ms": 1_000, - "with_escalated_permissions": true, + "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ "command": command, diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index 1a4b0a0e1f..ba481264e2 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -63,6 +63,7 @@ use anyhow::Context as _; use clap::Parser; use codex_core::config::find_codex_home; use codex_core::is_dangerous_command::command_might_be_dangerous; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; @@ -202,13 +203,19 @@ pub(crate) fn evaluate_exec_policy( && rule_match.decision() == evaluation.decision }); + let sandbox_permissions = if decision_driven_by_policy { + SandboxPermissions::RequireEscalated + } else { + SandboxPermissions::UseDefault + }; + Ok(match evaluation.decision { Decision::Forbidden => ExecPolicyOutcome::Forbidden, Decision::Prompt => ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, Decision::Allow => ExecPolicyOutcome::Allow { - run_with_escalated_permissions: decision_driven_by_policy, + sandbox_permissions, }, }) } @@ -231,6 +238,7 @@ async fn load_exec_policy() -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Decision; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; @@ -247,7 +255,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Prompt { - run_with_escalated_permissions: false + sandbox_permissions: SandboxPermissions::UseDefault } ); } @@ -276,7 +284,7 @@ mod tests { assert_eq!( outcome, ExecPolicyOutcome::Allow { - run_with_escalated_permissions: true + sandbox_permissions: SandboxPermissions::RequireEscalated } ); } diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index 72934607a3..d99f300704 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -10,6 +10,7 @@ use path_absolutize::Absolutize as _; use codex_core::SandboxState; use codex_core::exec::process_exec_tool_call; +use codex_core::sandboxing::SandboxPermissions; use tokio::process::Command; use tokio_util::sync::CancellationToken; @@ -85,7 +86,7 @@ impl EscalateServer { cwd: PathBuf::from(&workdir), expiration: ExecExpiration::Cancellation(cancel_rx), env, - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }, diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs index 97e76a6844..6d0c1bb338 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs @@ -1,5 +1,6 @@ use std::path::Path; +use codex_core::sandboxing::SandboxPermissions; use codex_execpolicy::Policy; use rmcp::ErrorData as McpError; use rmcp::RoleServer; @@ -18,10 +19,10 @@ use tokio::sync::RwLock; #[derive(Debug, PartialEq, Eq)] pub(crate) enum ExecPolicyOutcome { Allow { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Prompt { - run_with_escalated_permissions: bool, + sandbox_permissions: SandboxPermissions, }, Forbidden, } @@ -108,16 +109,16 @@ impl EscalationPolicy for McpEscalationPolicy { crate::posix::evaluate_exec_policy(&policy, file, argv, self.preserve_program_paths)?; let action = match outcome { ExecPolicyOutcome::Allow { - run_with_escalated_permissions, + sandbox_permissions, } => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run } } ExecPolicyOutcome::Prompt { - run_with_escalated_permissions, + sandbox_permissions, } => { let result = self .prompt(file, argv, workdir, self.context.clone()) @@ -125,7 +126,7 @@ impl EscalationPolicy for McpEscalationPolicy { // TODO: Extract reason from `result.content`. match result.action { ElicitationAction::Accept => { - if run_with_escalated_permissions { + if sandbox_permissions.requires_escalated_permissions() { EscalateAction::Escalate } else { EscalateAction::Run diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index e145aa2f73..791f9b1ea7 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -6,6 +6,7 @@ use codex_core::exec::ExecParams; use codex_core::exec::process_exec_tool_call; use codex_core::exec_env::create_env; use codex_core::protocol::SandboxPolicy; +use codex_core::sandboxing::SandboxPermissions; use std::collections::HashMap; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -41,7 +42,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { cwd, expiration: timeout_ms.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; @@ -143,7 +144,7 @@ async fn assert_network_blocked(cmd: &[&str]) { // do not stall the suite. expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), - with_escalated_permissions: None, + sandbox_permissions: SandboxPermissions::UseDefault, justification: None, arg0: None, }; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 9f66d08dca..51e977cb95 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -14,6 +14,25 @@ use codex_git::GhostCommit; use codex_utils_image::error::ImageProcessingError; use schemars::JsonSchema; +/// Controls whether a command should use the session sandbox or bypass it. +#[derive( + Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, +)] +#[serde(rename_all = "snake_case")] +pub enum SandboxPermissions { + /// Run with the configured sandbox + #[default] + UseDefault, + /// Request to run outside the sandbox + RequireEscalated, +} + +impl SandboxPermissions { + pub fn requires_escalated_permissions(self) -> bool { + matches!(self, SandboxPermissions::RequireEscalated) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseInputItem { @@ -327,8 +346,9 @@ pub struct ShellToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -346,8 +366,9 @@ pub struct ShellCommandToolCallParams { /// This is the maximum time in milliseconds that the command is allowed to run. #[serde(alias = "timeout")] pub timeout_ms: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub with_escalated_permissions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub sandbox_permissions: Option, #[serde(skip_serializing_if = "Option::is_none")] pub justification: Option, } @@ -742,7 +763,7 @@ mod tests { command: vec!["ls".to_string(), "-l".to_string()], workdir: Some("/tmp".to_string()), timeout_ms: Some(1000), - with_escalated_permissions: None, + sandbox_permissions: None, justification: None, }, params From c4af707e09b91cae7ccb83569596d1725eeefebe Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 10 Dec 2025 11:48:11 -0600 Subject: [PATCH 13/19] Removed experimental "command risk assessment" feature (#7799) This experimental feature received lukewarm reception during internal testing. Removing from the code base. --- codex-rs/Cargo.lock | 52 ---- codex-rs/Cargo.toml | 1 - .../src/protocol/common.rs | 2 - .../app-server-protocol/src/protocol/v1.rs | 2 - .../app-server-protocol/src/protocol/v2.rs | 37 --- codex-rs/app-server-test-client/src/main.rs | 4 - .../app-server/src/bespoke_event_handling.rs | 6 +- .../suite/codex_message_processor_flow.rs | 1 - codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/codex.rs | 31 -- codex-rs/core/src/codex_delegate.rs | 1 - codex-rs/core/src/config/mod.rs | 14 - codex-rs/core/src/config/profile.rs | 1 - codex-rs/core/src/features.rs | 12 - codex-rs/core/src/features/legacy.rs | 11 - codex-rs/core/src/sandboxing/assessment.rs | 268 ------------------ codex-rs/core/src/sandboxing/mod.rs | 2 - codex-rs/core/src/tools/orchestrator.rs | 39 --- .../core/src/tools/runtimes/apply_patch.rs | 10 - codex-rs/core/src/tools/runtimes/shell.rs | 13 - .../core/src/tools/runtimes/unified_exec.rs | 13 - codex-rs/core/src/tools/sandboxing.rs | 14 - .../templates/sandboxing/assessment_prompt.md | 24 -- codex-rs/exec/src/lib.rs | 1 - codex-rs/mcp-server/src/codex_tool_config.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 2 - codex-rs/mcp-server/src/exec_approval.rs | 5 - codex-rs/mcp-server/tests/suite/codex_tool.rs | 1 - codex-rs/otel/src/otel_event_manager.rs | 47 --- codex-rs/protocol/src/approvals.rs | 27 -- codex-rs/protocol/src/protocol.rs | 2 - .../tui/src/bottom_pane/approval_overlay.rs | 36 --- codex-rs/tui/src/bottom_pane/mod.rs | 1 - codex-rs/tui/src/chatwidget.rs | 1 - ...hatwidget__tests__approval_modal_exec.snap | 2 + ...roval_history_decision_approved_short.snap | 1 - codex-rs/tui/src/chatwidget/tests.rs | 6 - codex-rs/tui/src/lib.rs | 1 - docs/config.md | 21 +- docs/example-config.md | 2 - 40 files changed, 13 insertions(+), 703 deletions(-) delete mode 100644 codex-rs/core/src/sandboxing/assessment.rs delete mode 100644 codex-rs/core/templates/sandboxing/assessment_prompt.md diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bca96ff631..8ee790f676 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -238,48 +238,6 @@ dependencies = [ "term", ] -[[package]] -name = "askama" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" -dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 2.0.104", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow", -] - [[package]] name = "assert-json-diff" version = "2.0.2" @@ -557,15 +515,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - [[package]] name = "beef" version = "0.5.2" @@ -1137,7 +1086,6 @@ name = "codex-core" version = "0.0.0" dependencies = [ "anyhow", - "askama", "assert_cmd", "assert_matches", "async-channel", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a2521e3bda..cdf55434fe 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -109,7 +109,6 @@ allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" arboard = { version = "3", features = ["wayland-data-control"] } -askama = "0.14" assert_cmd = "2" assert_matches = "1.5.0" async-channel = "2.3.1" diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index bd9f6ddedf..116a3c62dd 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -654,7 +654,6 @@ mod tests { command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello".to_string(), }], @@ -674,7 +673,6 @@ mod tests { "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", - "risk": null, "parsedCmd": [ { "type": "unknown", diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 1576eb0d93..853cb03b40 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -13,7 +13,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TurnAbortReason; @@ -226,7 +225,6 @@ pub struct ExecCommandApprovalParams { pub command: Vec, pub cwd: PathBuf, pub reason: Option, - pub risk: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index db987e27df..211f0ba375 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,7 +4,6 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -275,14 +274,6 @@ pub struct ConfigEdit { pub merge_strategy: MergeStrategy, } -v2_enum_from_core!( - pub enum CommandRiskLevel from codex_protocol::approvals::SandboxRiskLevel { - Low, - Medium, - High - } -); - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -362,32 +353,6 @@ impl From for SandboxPolicy { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: CommandRiskLevel, -} - -impl SandboxCommandAssessment { - pub fn into_core(self) -> CoreSandboxCommandAssessment { - CoreSandboxCommandAssessment { - description: self.description, - risk_level: self.risk_level.to_core(), - } - } -} - -impl From for SandboxCommandAssessment { - fn from(value: CoreSandboxCommandAssessment) -> Self { - Self { - description: value.description, - risk_level: CommandRiskLevel::from(value.risk_level), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(transparent)] #[ts(type = "Array", export_to = "v2/")] @@ -1535,8 +1500,6 @@ pub struct CommandExecutionRequestApprovalParams { pub item_id: String, /// Optional explanatory reason (e.g. request for network access). pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - pub risk: Option, /// Optional proposed execpolicy amendment to allow similar commands without prompting. pub proposed_execpolicy_amendment: Option, } diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 924740896e..b66c59d55a 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -756,7 +756,6 @@ impl CodexClient { turn_id, item_id, reason, - risk, proposed_execpolicy_amendment, } = params; @@ -766,9 +765,6 @@ impl CodexClient { if let Some(reason) = reason.as_deref() { println!("< reason: {reason}"); } - if let Some(risk) = risk.as_ref() { - println!("< risk assessment: {risk:?}"); - } if let Some(execpolicy_amendment) = proposed_execpolicy_amendment.as_ref() { println!("< proposed execpolicy amendment: {execpolicy_amendment:?}"); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8956aedd13..b0161cd9fd 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ReasoningTextDeltaNotification; -use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::TerminalInteractionNotification; @@ -180,7 +179,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }) => match api_version { @@ -191,7 +189,6 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, reason, - risk, parsed_cmd, }; let rx = outgoing @@ -219,7 +216,6 @@ pub(crate) async fn apply_bespoke_event_handling( // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. item_id: item_id.clone(), reason, - risk: risk.map(V2SandboxCommandAssessment::from), proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2, }; let rx = outgoing @@ -1214,7 +1210,7 @@ async fn construct_mcp_tool_call_notification( } } -/// simiilar to handle_mcp_tool_call_end in exec +/// similar to handle_mcp_tool_call_end in exec async fn construct_mcp_tool_call_end_notification( end_event: McpToolCallEndEvent, thread_id: String, diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 4b206436c8..e417198994 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -271,7 +271,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { command: format_with_current_shell("python3 -c 'print(42)'"), cwd: working_directory.clone(), reason: None, - risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2bc281d903..a11e7c24d6 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] anyhow = { workspace = true } -askama = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4129bb6a1f..6f637d143c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -95,7 +95,6 @@ use crate::protocol::RateLimitSnapshot; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; @@ -875,34 +874,6 @@ impl Session { .await; } - pub(crate) async fn assess_sandbox_command( - &self, - turn_context: &TurnContext, - call_id: &str, - command: &[String], - failure_message: Option<&str>, - ) -> Option { - let config = turn_context.client.config(); - let provider = turn_context.client.provider().clone(); - let auth_manager = Arc::clone(&self.services.auth_manager); - let otel = self.services.otel_event_manager.clone(); - crate::sandboxing::assessment::assess_command( - config, - provider, - auth_manager, - &otel, - self.conversation_id, - self.services.models_manager.clone(), - turn_context.client.get_session_source(), - call_id, - command, - &turn_context.sandbox_policy, - &turn_context.cwd, - failure_message, - ) - .await - } - /// Adds an execpolicy amendment to both the in-memory and on-disk policies so future /// commands can use the newly approved prefix. pub(crate) async fn persist_execpolicy_amendment( @@ -950,7 +921,6 @@ impl Session { command: Vec, cwd: PathBuf, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); @@ -978,7 +948,6 @@ impl Session { command, cwd, reason, - risk, proposed_execpolicy_amendment, parsed_cmd, }); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 670225ead0..75b29eddee 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -280,7 +280,6 @@ async fn handle_exec_approval( event.command, event.cwd, event.reason, - event.risk, event.proposed_execpolicy_amendment, ); let decision = await_approval_with_cancel( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8db08c55a2..bdf7a54177 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -246,9 +246,6 @@ pub struct Config { pub tools_web_search_request: bool, - /// When `true`, run a model-based assessment for commands denied by the sandbox. - pub experimental_sandbox_command_assessment: bool, - /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -733,7 +730,6 @@ pub struct ConfigToml { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". pub oss_provider: Option, } @@ -919,7 +915,6 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -978,7 +973,6 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, additional_writable_roots, } = overrides; @@ -1003,7 +997,6 @@ impl Config { let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, - experimental_sandbox_command_assessment: sandbox_command_assessment_override, }; let features = Features::from_config(&cfg, &config_profile, feature_overrides); @@ -1102,8 +1095,6 @@ impl Config { let tools_web_search_request = features.enabled(Feature::WebSearchRequest); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient); - let experimental_sandbox_command_assessment = - features.enabled(Feature::SandboxCommandAssessment); let forced_chatgpt_workspace_id = cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| { @@ -1234,7 +1225,6 @@ impl Config { forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, tools_web_search_request, - experimental_sandbox_command_assessment, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, features, @@ -2990,7 +2980,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3065,7 +3054,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3155,7 +3143,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), @@ -3231,7 +3218,6 @@ model_verbosity = "high" forced_login_method: None, include_apply_patch_tool: false, tools_web_search_request: false, - experimental_sandbox_command_assessment: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, features: Features::with_defaults(), diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 5629465c40..978e1fcb63 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -27,7 +27,6 @@ pub struct ConfigProfile { pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, pub experimental_use_freeform_apply_patch: Option, - pub experimental_sandbox_command_assessment: Option, pub tools_web_search: Option, pub tools_view_image: Option, /// Optional feature toggles scoped to this profile. diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d714f8e85e..4b8370039a 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -48,8 +48,6 @@ pub enum Feature { WebSearchRequest, /// Gate the execpolicy enforcement for shell/unified exec. ExecPolicy, - /// Enable the model-based risk assessments for sandboxed commands. - SandboxCommandAssessment, /// Enable Windows sandbox (restricted token) on Windows. WindowsSandbox, /// Remote compaction enabled (only for ChatGPT auth) @@ -104,7 +102,6 @@ pub struct Features { pub struct FeatureOverrides { pub include_apply_patch_tool: Option, pub web_search_request: Option, - pub experimental_sandbox_command_assessment: Option, } impl FeatureOverrides { @@ -196,7 +193,6 @@ impl Features { let mut features = Features::with_defaults(); let base_legacy = LegacyFeatureToggles { - experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch, experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, experimental_use_rmcp_client: cfg.experimental_use_rmcp_client, @@ -212,8 +208,6 @@ impl Features { let profile_legacy = LegacyFeatureToggles { include_apply_patch_tool: config_profile.include_apply_patch_tool, - experimental_sandbox_command_assessment: config_profile - .experimental_sandbox_command_assessment, experimental_use_freeform_apply_patch: config_profile .experimental_use_freeform_apply_patch, @@ -327,12 +321,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: true, }, - FeatureSpec { - id: Feature::SandboxCommandAssessment, - key: "experimental_sandbox_command_assessment", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::WindowsSandbox, key: "enable_experimental_windows_sandbox", diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 4d59f2a9a3..0c74d380e8 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,10 +9,6 @@ struct Alias { } const ALIASES: &[Alias] = &[ - Alias { - legacy_key: "experimental_sandbox_command_assessment", - feature: Feature::SandboxCommandAssessment, - }, Alias { legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, @@ -48,7 +44,6 @@ pub(crate) fn feature_for_key(key: &str) -> Option { #[derive(Debug, Default)] pub struct LegacyFeatureToggles { pub include_apply_patch_tool: Option, - pub experimental_sandbox_command_assessment: Option, pub experimental_use_freeform_apply_patch: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, @@ -64,12 +59,6 @@ impl LegacyFeatureToggles { self.include_apply_patch_tool, "include_apply_patch_tool", ); - set_if_some( - features, - Feature::SandboxCommandAssessment, - self.experimental_sandbox_command_assessment, - "experimental_sandbox_command_assessment", - ); set_if_some( features, Feature::ApplyPatchFreeform, diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs deleted file mode 100644 index b7a9c952d1..0000000000 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ /dev/null @@ -1,268 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use crate::AuthManager; -use crate::ModelProviderInfo; -use crate::client::ModelClient; -use crate::client_common::Prompt; -use crate::client_common::ResponseEvent; -use crate::config::Config; -use crate::openai_models::models_manager::ModelsManager; -use crate::protocol::SandboxPolicy; -use askama::Template; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::ConversationId; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::protocol::SandboxCommandAssessment; -use codex_protocol::protocol::SessionSource; -use futures::StreamExt; -use serde_json::json; -use tokio::time::timeout; -use tracing::warn; - -const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(15); -const SANDBOX_ASSESSMENT_REASONING_EFFORT: ReasoningEffortConfig = ReasoningEffortConfig::Medium; - -#[derive(Template)] -#[template(path = "sandboxing/assessment_prompt.md", escape = "none")] -struct SandboxAssessmentPromptTemplate<'a> { - platform: &'a str, - sandbox_policy: &'a str, - filesystem_roots: Option<&'a str>, - working_directory: &'a str, - command_argv: &'a str, - command_joined: &'a str, - sandbox_failure_message: Option<&'a str>, -} - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn assess_command( - config: Arc, - provider: ModelProviderInfo, - auth_manager: Arc, - parent_otel: &OtelEventManager, - conversation_id: ConversationId, - models_manager: Arc, - session_source: SessionSource, - call_id: &str, - command: &[String], - sandbox_policy: &SandboxPolicy, - cwd: &Path, - failure_message: Option<&str>, -) -> Option { - if !config.experimental_sandbox_command_assessment || command.is_empty() { - return None; - } - - let command_json = serde_json::to_string(command).unwrap_or_else(|_| "[]".to_string()); - let command_joined = - shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); - let failure = failure_message - .map(str::trim) - .filter(|msg| !msg.is_empty()) - .map(str::to_string); - - let cwd_str = cwd.to_string_lossy().to_string(); - let sandbox_summary = summarize_sandbox_policy(sandbox_policy); - let mut roots = sandbox_roots_for_prompt(sandbox_policy, cwd); - roots.sort(); - roots.dedup(); - - let platform = std::env::consts::OS; - let roots_formatted = roots.iter().map(|root| root.to_string_lossy().to_string()); - let filesystem_roots = match roots_formatted.collect::>() { - collected if collected.is_empty() => None, - collected => Some(collected.join(", ")), - }; - - let prompt_template = SandboxAssessmentPromptTemplate { - platform, - sandbox_policy: sandbox_summary.as_str(), - filesystem_roots: filesystem_roots.as_deref(), - working_directory: cwd_str.as_str(), - command_argv: command_json.as_str(), - command_joined: command_joined.as_str(), - sandbox_failure_message: failure.as_deref(), - }; - let rendered_prompt = match prompt_template.render() { - Ok(rendered) => rendered, - Err(err) => { - warn!("failed to render sandbox assessment prompt: {err}"); - return None; - } - }; - let (system_prompt_section, user_prompt_section) = match rendered_prompt.split_once("\n---\n") { - Some(split) => split, - None => { - warn!("rendered sandbox assessment prompt missing separator"); - return None; - } - }; - let system_prompt = system_prompt_section - .strip_prefix("System Prompt:\n") - .unwrap_or(system_prompt_section) - .trim() - .to_string(); - let user_prompt = user_prompt_section - .strip_prefix("User Prompt:\n") - .unwrap_or(user_prompt_section) - .trim() - .to_string(); - - let prompt = Prompt { - input: vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: user_prompt }], - }], - tools: Vec::new(), - parallel_tool_calls: false, - base_instructions_override: Some(system_prompt), - output_schema: Some(sandbox_assessment_schema()), - }; - - let model_family = models_manager - .construct_model_family(&config.model, &config) - .await; - - let child_otel = parent_otel.with_model(config.model.as_str(), model_family.slug.as_str()); - - let client = ModelClient::new( - Arc::clone(&config), - Some(auth_manager), - model_family, - child_otel, - provider, - Some(SANDBOX_ASSESSMENT_REASONING_EFFORT), - config.model_reasoning_summary, - conversation_id, - session_source, - ); - - let start = Instant::now(); - let assessment_result = timeout(SANDBOX_ASSESSMENT_TIMEOUT, async move { - let mut stream = client.stream(&prompt).await?; - let mut last_json: Option = None; - while let Some(event) = stream.next().await { - match event { - Ok(ResponseEvent::OutputItemDone(item)) => { - if let Some(text) = response_item_text(&item) { - last_json = Some(text); - } - } - Ok(ResponseEvent::RateLimits(_)) => {} - Ok(ResponseEvent::Completed { .. }) => break, - Ok(_) => continue, - Err(err) => return Err(err), - } - } - Ok(last_json) - }) - .await; - let duration = start.elapsed(); - parent_otel.sandbox_assessment_latency(call_id, duration); - - match assessment_result { - Ok(Ok(Some(raw))) => match serde_json::from_str::(raw.trim()) { - Ok(assessment) => { - parent_otel.sandbox_assessment( - call_id, - "success", - Some(assessment.risk_level), - duration, - ); - return Some(assessment); - } - Err(err) => { - warn!("failed to parse sandbox assessment JSON: {err}"); - parent_otel.sandbox_assessment(call_id, "parse_error", None, duration); - } - }, - Ok(Ok(None)) => { - warn!("sandbox assessment response did not include any message"); - parent_otel.sandbox_assessment(call_id, "no_output", None, duration); - } - Ok(Err(err)) => { - warn!("sandbox assessment failed: {err}"); - parent_otel.sandbox_assessment(call_id, "model_error", None, duration); - } - Err(_) => { - warn!("sandbox assessment timed out"); - parent_otel.sandbox_assessment(call_id, "timeout", None, duration); - } - } - - None -} - -fn summarize_sandbox_policy(policy: &SandboxPolicy) -> String { - match policy { - SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), - SandboxPolicy::ReadOnly => "read-only".to_string(), - SandboxPolicy::WorkspaceWrite { network_access, .. } => { - let network = if *network_access { - "network" - } else { - "no-network" - }; - format!("workspace-write (network_access={network})") - } - } -} - -fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec { - let mut roots = vec![cwd.to_path_buf()]; - if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy { - roots.extend(writable_roots.iter().cloned()); - } - roots -} - -fn sandbox_assessment_schema() -> serde_json::Value { - json!({ - "type": "object", - "required": ["description", "risk_level"], - "properties": { - "description": { - "type": "string", - "minLength": 1, - "maxLength": 500 - }, - "risk_level": { - "type": "string", - "enum": ["low", "medium", "high"] - }, - }, - "additionalProperties": false - }) -} - -fn response_item_text(item: &ResponseItem) -> Option { - match item { - ResponseItem::Message { content, .. } => { - let mut buffers: Vec<&str> = Vec::new(); - for segment in content { - match segment { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - if !text.is_empty() { - buffers.push(text); - } - } - ContentItem::InputImage { .. } => {} - } - } - if buffers.is_empty() { - None - } else { - Some(buffers.join("\n")) - } - } - ResponseItem::FunctionCallOutput { output, .. } => Some(output.content.clone()), - _ => None, - } -} diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 3f56ce3ae9..5d719a7922 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -6,8 +6,6 @@ sandbox placement and transformation of portable CommandSpec into a ready‑to‑spawn environment. */ -pub mod assessment; - use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::SandboxType; diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 4c34658fcd..003c727610 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -7,12 +7,10 @@ retry without sandbox on denial (no re‑approval thanks to caching). */ use crate::error::CodexErr; use crate::error::SandboxErr; -use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::ToolCtx; @@ -43,7 +41,6 @@ impl ToolOrchestrator { ) -> Result where T: ToolRuntime, - Rq: ProvidesSandboxRetryData, { let otel = turn_ctx.client.get_otel_event_manager(); let otel_tn = &tool_ctx.tool_name; @@ -65,26 +62,11 @@ impl ToolOrchestrator { return Err(ToolError::Rejected(reason)); } ExecApprovalRequirement::NeedsApproval { reason, .. } => { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - None, - ) - .await; - } - let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: reason, - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; @@ -141,33 +123,12 @@ impl ToolOrchestrator { // Ask for approval before retrying without sandbox. if !tool.should_bypass_approval(approval_policy, already_approved) { - let mut risk = None; - - if let Some(metadata) = req.sandbox_retry_data() { - let err = SandboxErr::Denied { - output: output.clone(), - }; - let friendly = get_error_message_ui(&CodexErr::Sandbox(err)); - let failure_summary = format!("failed in sandbox: {friendly}"); - - risk = tool_ctx - .session - .assess_sandbox_command( - turn_ctx, - &tool_ctx.call_id, - &metadata.command, - Some(failure_summary.as_str()), - ) - .await; - } - let reason_msg = build_denial_reason_from_output(output.as_ref()); let approval_ctx = ApprovalCtx { session: tool_ctx.session, turn: turn_ctx, call_id: &tool_ctx.call_id, retry_reason: Some(reason_msg), - risk, }; let decision = tool.start_approval_async(req, approval_ctx).await; diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index bf4b66ce9d..26d04f578c 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -11,9 +11,7 @@ use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -35,12 +33,6 @@ pub struct ApplyPatchRequest { pub codex_exe: Option, } -impl ProvidesSandboxRetryData for ApplyPatchRequest { - fn sandbox_retry_data(&self) -> Option { - None - } -} - #[derive(Default)] pub struct ApplyPatchRuntime; @@ -115,7 +107,6 @@ impl Approvable for ApplyPatchRuntime { let call_id = ctx.call_id.to_string(); let cwd = req.cwd.clone(); let retry_reason = ctx.retry_reason.clone(); - let risk = ctx.risk.clone(); let user_explicitly_approved = req.user_explicitly_approved; Box::pin(async move { with_cached_approval(&session.services, key, move || async move { @@ -127,7 +118,6 @@ impl Approvable for ApplyPatchRuntime { vec!["apply_patch".to_string()], cwd, Some(reason), - risk, None, ) .await diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 595bda0e96..078be68e89 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -11,10 +11,8 @@ use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -36,15 +34,6 @@ pub struct ShellRequest { pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for ShellRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(Default)] pub struct ShellRuntime; @@ -101,7 +90,6 @@ impl Approvable for ShellRuntime { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); @@ -114,7 +102,6 @@ impl Approvable for ShellRuntime { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index b6a8047080..3d35987c7e 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -12,10 +12,8 @@ use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; -use crate::tools::sandboxing::SandboxRetryData; use crate::tools::sandboxing::Sandboxable; use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; @@ -40,15 +38,6 @@ pub struct UnifiedExecRequest { pub exec_approval_requirement: ExecApprovalRequirement, } -impl ProvidesSandboxRetryData for UnifiedExecRequest { - fn sandbox_retry_data(&self) -> Option { - Some(SandboxRetryData { - command: self.command.clone(), - cwd: self.cwd.clone(), - }) - } -} - #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, @@ -122,7 +111,6 @@ impl Approvable for UnifiedExecRuntime<'_> { .retry_reason .clone() .or_else(|| req.justification.clone()); - let risk = ctx.risk.clone(); Box::pin(async move { with_cached_approval(&session.services, key, || async move { session @@ -132,7 +120,6 @@ impl Approvable for UnifiedExecRuntime<'_> { command, cwd, reason, - risk, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 5e69696923..96bc633c58 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -7,7 +7,6 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; -use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; @@ -20,7 +19,6 @@ use std::collections::HashMap; use std::fmt::Debug; use std::hash::Hash; use std::path::Path; -use std::path::PathBuf; use futures::Future; use futures::future::BoxFuture; @@ -84,7 +82,6 @@ pub(crate) struct ApprovalCtx<'a> { pub turn: &'a TurnContext, pub call_id: &'a str, pub retry_reason: Option, - pub risk: Option, } // Specifies what tool orchestrator should do with a given tool call. @@ -219,17 +216,6 @@ pub(crate) struct ToolCtx<'a> { pub tool_name: String, } -/// Captures the command metadata needed to re-run a tool request without sandboxing. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct SandboxRetryData { - pub command: Vec, - pub cwd: PathBuf, -} - -pub(crate) trait ProvidesSandboxRetryData { - fn sandbox_retry_data(&self) -> Option; -} - #[derive(Debug)] pub(crate) enum ToolError { Rejected(String), diff --git a/codex-rs/core/templates/sandboxing/assessment_prompt.md b/codex-rs/core/templates/sandboxing/assessment_prompt.md deleted file mode 100644 index dc59f47d7c..0000000000 --- a/codex-rs/core/templates/sandboxing/assessment_prompt.md +++ /dev/null @@ -1,24 +0,0 @@ -You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk to help the user decide whether to approve command execution. Return strictly valid JSON with the keys: -- description (concise summary of command intent and potential effects, no more than one sentence, use present tense) -- risk_level ("low", "medium", or "high") -Risk level examples: -- low: read-only inspections, listing files, printing configuration, fetching artifacts from trusted sources -- medium: modifying project files, installing dependencies -- high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls -If information is insufficient, choose the most cautious risk level supported by the evidence. -Respond with JSON only, without markdown code fences or extra commentary. - ---- - -Command metadata: -Platform: {{ platform }} -Sandbox policy: {{ sandbox_policy }} -{% if let Some(roots) = filesystem_roots %} -Filesystem roots: {{ roots }} -{% endif %} -Working directory: {{ working_directory }} -Command argv: {{ command_argv }} -Command (joined): {{ command_joined }} -{% if let Some(message) = sandbox_failure_message %} -Sandbox failure message: {{ message }} -{% endif %} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0cf5aaf788..7dfeeecf7b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -200,7 +200,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: add_dir, }; diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 4e61bde02b..feadf0add6 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -169,7 +169,6 @@ impl CodexToolCallParam { include_apply_patch_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: Vec::new(), }; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 908cba1cc3..d39a38cde9 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -179,7 +179,6 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, - risk, proposed_execpolicy_amendment: _, parsed_cmd, }) => { @@ -193,7 +192,6 @@ async fn run_codex_tool_session_inner( event.id.clone(), call_id, parsed_cmd, - risk, ) .await; continue; diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 033523ac0d..44607b754d 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; @@ -38,8 +37,6 @@ pub struct ExecApprovalElicitRequestParams { pub codex_command: Vec, pub codex_cwd: PathBuf, pub codex_parsed_cmd: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub codex_risk: Option, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -62,7 +59,6 @@ pub(crate) async fn handle_exec_approval_request( event_id: String, call_id: String, codex_parsed_cmd: Vec, - codex_risk: Option, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -85,7 +81,6 @@ pub(crate) async fn handle_exec_approval_request( codex_command: command, codex_cwd: cwd, codex_parsed_cmd, - codex_risk, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index f65495c473..d0a78ae392 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -200,7 +200,6 @@ fn create_expected_elicitation_request( codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), codex_parsed_cmd, - codex_risk: None, })?), }) } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index d3536cd8db..54e3fe3dc1 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -8,7 +8,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SandboxRiskLevel; use codex_protocol::user_input::UserInput; use eventsource_stream::Event as StreamEvent; use eventsource_stream::EventStreamError as StreamError; @@ -374,52 +373,6 @@ impl OtelEventManager { ); } - pub fn sandbox_assessment( - &self, - call_id: &str, - status: &str, - risk_level: Option, - duration: Duration, - ) { - let level = risk_level.map(|level| level.as_str()); - - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - status = %status, - risk_level = level, - duration_ms = %duration.as_millis(), - ); - } - - pub fn sandbox_assessment_latency(&self, call_id: &str, duration: Duration) { - tracing::event!( - tracing::Level::INFO, - event.name = "codex.sandbox_assessment_latency", - event.timestamp = %timestamp(), - conversation.id = %self.metadata.conversation_id, - app.version = %self.metadata.app_version, - auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, - user.email = self.metadata.account_email, - terminal.type = %self.metadata.terminal_type, - model = %self.metadata.model, - slug = %self.metadata.slug, - call_id = %call_id, - duration_ms = %duration.as_millis(), - ); - } - pub async fn log_tool_result( &self, tool_name: &str, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index c892b6ec99..78050dfa86 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -9,14 +9,6 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -pub enum SandboxRiskLevel { - Low, - Medium, - High, -} - /// Proposed execpolicy change to allow commands starting with this prefix. /// /// The `command` tokens form the prefix that would be added as an execpolicy @@ -45,22 +37,6 @@ impl From> for ExecPolicyAmendment { } } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] -pub struct SandboxCommandAssessment { - pub description: String, - pub risk_level: SandboxRiskLevel, -} - -impl SandboxRiskLevel { - pub fn as_str(&self) -> &'static str { - match self { - Self::Low => "low", - Self::Medium => "medium", - Self::High => "high", - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated exec call, if available. @@ -76,9 +52,6 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - /// Optional model-provided risk assessment describing the blocked command. - #[serde(skip_serializing_if = "Option::is_none")] - pub risk: Option, /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1e40618f01..973fd26582 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -40,8 +40,6 @@ pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; -pub use crate::approvals::SandboxCommandAssessment; -pub use crate::approvals::SandboxRiskLevel; /// Open/close tags for special user-input blocks. Used across crates to avoid /// duplicated hardcoded strings. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 768fe030d4..d42861eb1d 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -23,8 +23,6 @@ use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; -use codex_core::protocol::SandboxCommandAssessment; -use codex_core::protocol::SandboxRiskLevel; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -45,7 +43,6 @@ pub(crate) enum ApprovalRequest { id: String, command: Vec, reason: Option, - risk: Option, proposed_execpolicy_amendment: Option, }, ApplyPatch { @@ -345,18 +342,11 @@ impl From for ApprovalRequestState { id, command, reason, - risk, proposed_execpolicy_amendment, } => { - let reason = reason.filter(|item| !item.is_empty()); - let has_reason = reason.is_some(); let mut header: Vec> = Vec::new(); if let Some(reason) = reason { header.push(Line::from(vec!["Reason: ".into(), reason.italic()])); - } - if let Some(risk) = risk.as_ref() { - header.extend(render_risk_lines(risk)); - } else if has_reason { header.push(Line::from("")); } let full_cmd = strip_bash_lc_and_escape(&command); @@ -419,28 +409,6 @@ impl From for ApprovalRequestState { } } -fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec> { - let level_span = match risk.risk_level { - SandboxRiskLevel::Low => "LOW".green().bold(), - SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(), - SandboxRiskLevel::High => "HIGH".red().bold(), - }; - - let mut lines = Vec::new(); - - let description = risk.description.trim(); - if !description.is_empty() { - lines.push(Line::from(vec![ - "Summary: ".into(), - description.to_string().into(), - ])); - } - - lines.push(vec!["Risk: ".into(), level_span].into()); - lines.push(Line::from("")); - lines -} - #[derive(Clone)] enum ApprovalVariant { Exec { @@ -570,7 +538,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), - risk: None, proposed_execpolicy_amendment: None, } } @@ -613,7 +580,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -652,7 +618,6 @@ mod tests { id: "test".to_string(), command: vec!["echo".to_string()], reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".to_string(), ])), @@ -679,7 +644,6 @@ mod tests { id: "test".into(), command, reason: None, - risk: None, proposed_execpolicy_amendment: None, }; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8a4336f6fe..554810de7f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,7 +570,6 @@ mod tests { id: "1".to_string(), command: vec!["echo".into(), "ok".into()], reason: None, - risk: None, proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dd623ed583..f9e53c8055 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1095,7 +1095,6 @@ impl ChatWidget { id, command: ev.command, reason: ev.reason, - risk: ev.risk, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index ca093f271a..15511611a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -2,6 +2,8 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend().vt100().screen().contents() --- + + Would you like to run the following command? Reason: this is a test reason such as one that would be produced by the model diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap index 1b18a23d4d..2f0f1412a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_approved_short.snap @@ -3,4 +3,3 @@ source: tui/src/chatwidget/tests.rs expression: lines_to_single_string(&decision) --- ✔ You approved codex to run echo hello world this time - diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fc1d25edfe..2355493250 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -755,7 +755,6 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -800,7 +799,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -851,7 +849,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; @@ -2105,7 +2102,6 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2157,7 +2153,6 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello".into(), @@ -2376,7 +2371,6 @@ fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), - risk: None, proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ "echo".into(), "hello world".into(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0aa422cc61..d9793a07a0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -218,7 +218,6 @@ pub async fn run_main( include_apply_patch_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: None, - experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; diff --git a/docs/config.md b/docs/config.md index 4e78da7ac6..8b131cd63a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -39,17 +39,16 @@ web_search_request = true # allow the model to request web searches Supported features: -| Key | Default | Stage | Description | -| ----------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | -| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | -| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | -| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | -| `view_image_tool` | true | Stable | Include the `view_image` tool | -| `web_search_request` | false | Stable | Allow the model to issue web searches | -| `experimental_sandbox_command_assessment` | false | Experimental | Enable model-based sandbox risk assessment | -| `ghost_commit` | false | Experimental | Create a ghost commit each turn | -| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | -| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | +| Key | Default | Stage | Description | +| ------------------------------------- | :-----: | ------------ | ----------------------------------------------------- | +| `unified_exec` | false | Experimental | Use the unified PTY-backed exec tool | +| `rmcp_client` | false | Experimental | Enable oauth support for streamable HTTP MCP servers | +| `apply_patch_freeform` | false | Beta | Include the freeform `apply_patch` tool | +| `view_image_tool` | true | Stable | Include the `view_image` tool | +| `web_search_request` | false | Stable | Allow the model to issue web searches | +| `ghost_commit` | false | Experimental | Create a ghost commit each turn | +| `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | +| `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | Notes: diff --git a/docs/example-config.md b/docs/example-config.md index 1f326ac14b..2345274e1b 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -218,7 +218,6 @@ rmcp_client = false apply_patch_freeform = false view_image_tool = true web_search_request = false -experimental_sandbox_command_assessment = false ghost_commit = false enable_experimental_windows_sandbox = false @@ -314,7 +313,6 @@ experimental_use_freeform_apply_patch = false # experimental_compact_prompt_file = "compact_prompt.txt" # include_apply_patch_tool = false # experimental_use_freeform_apply_patch = false -# experimental_sandbox_command_assessment = false # tools_web_search = false # tools_view_image = true # features = { unified_exec = false } From f677d05871b49628d62cd289e5fa2d1b5aa7af36 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 10 Dec 2025 17:57:53 +0000 Subject: [PATCH 14/19] fix: flaky tests 3 (#7826) --- codex-rs/core/tests/suite/approvals.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 879ad56d47..10a510af42 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -126,7 +126,7 @@ impl ActionKind { ); let command = format!("python3 -c \"{script}\""); - let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; + let event = shell_event(call_id, &command, 3_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { From bd51d1b10383f4cc28d5228c29e6726935800c7c Mon Sep 17 00:00:00 2001 From: Amit Halfon Date: Wed, 10 Dec 2025 20:17:00 +0200 Subject: [PATCH 15/19] fix: Upgrade @modelcontextprotocol/sdk to ^1.24.0 (#7817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Upgrades @modelcontextprotocol/sdk from ^1.20.2 to ^1.24.0 in the TypeScript SDK's devDependencies. ## Why? Related to #7737 - keeping development dependencies up to date with the latest MCP SDK version that includes the fix for CVE-2025-66414. Note: This change does not address the CVE for Codex users, as the MCP SDK is only in devDependencies here. The actual MCP integration that would be affected by the CVE is in the Rust codebase. ## How? • Updated dependency version in sdk/typescript/package.json • Ran pnpm install to update lockfile • Fixed formatting (added missing newline in package.json) ## Related Issue Related to #7737 ## Test Status ⚠️ After this upgrade, 2 additional tests timeout (1 test was already failing on main): • tests/run.test.ts: "sends previous items when run is called twice" • tests/run.test.ts: "resumes thread by id" • tests/runStreamed.test.ts: "sends previous items when runStreamed is called twice" Marking as draft to investigate test timeouts. Maintainer guidance would be appreciated. Co-authored-by: HalfonA --- pnpm-lock.yaml | 74 +++++++++++++++++++++++++++++++++---- sdk/typescript/package.json | 2 +- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03d88bce45..01112d6d3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: sdk/typescript: devDependencies: '@modelcontextprotocol/sdk': - specifier: ^1.20.2 - version: 1.20.2 + specifier: ^1.24.0 + version: 1.24.3(zod@3.25.76) '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -573,9 +573,15 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@modelcontextprotocol/sdk@1.20.2': - resolution: {integrity: sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==} + '@modelcontextprotocol/sdk@1.24.3': + resolution: {integrity: sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw==} engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -846,9 +852,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1294,6 +1311,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1677,6 +1697,9 @@ packages: node-notifier: optional: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1706,6 +1729,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2053,6 +2079,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -2476,6 +2506,11 @@ packages: peerDependencies: zod: ^3.24.1 + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + peerDependencies: + zod: ^3.25 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3012,9 +3047,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.20.2': + '@modelcontextprotocol/sdk@1.24.3(zod@3.25.76)': dependencies: - ajv: 6.12.6 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 @@ -3022,10 +3058,11 @@ snapshots: eventsource-parser: 3.0.6 express: 5.1.0 express-rate-limit: 7.5.1(express@5.1.0) + jose: 6.1.3 pkce-challenge: 5.0.0 raw-body: 3.0.1 zod: 3.25.76 - zod-to-json-schema: 3.24.6(zod@3.25.76) + zod-to-json-schema: 3.25.0(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -3292,6 +3329,10 @@ snapshots: acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3299,6 +3340,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3795,6 +3843,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -4367,6 +4417,8 @@ snapshots: - supports-color - ts-node + jose@6.1.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -4388,6 +4440,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} @@ -4670,6 +4724,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -5116,4 +5172,8 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.0(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index b5a6ce82c3..55ecd1abf3 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -45,7 +45,7 @@ "prepare": "pnpm run build" }, "devDependencies": { - "@modelcontextprotocol/sdk": "^1.20.2", + "@modelcontextprotocol/sdk": "^1.24.0", "@types/jest": "^29.5.14", "@types/node": "^20.19.18", "eslint": "^9.36.0", From 9f40d6eeebebbee316bca6eac8e7f312a9c1d0b8 Mon Sep 17 00:00:00 2001 From: Koichi Shiraishi Date: Thu, 11 Dec 2025 03:23:01 +0900 Subject: [PATCH 16/19] fix: remove duplicated parallel FeatureSpec (#7823) regression: #7589 Signed-off-by: Koichi Shiraishi --- codex-rs/core/src/features.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4b8370039a..a011884fc4 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -339,12 +339,6 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, - FeatureSpec { - id: Feature::ParallelToolCalls, - key: "parallel", - stage: Stage::Experimental, - default_enabled: false, - }, FeatureSpec { id: Feature::Skills, key: "skills", From 4b684c53aea9a4a0573a7ffd3530eac71a670a04 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 10 Dec 2025 10:44:12 -0800 Subject: [PATCH 17/19] Remove conversation_id and bring back request ID logging (#7830) --- codex-rs/Cargo.lock | 1 + codex-rs/codex-client/src/default_client.rs | 143 ++++++++++++++++++++ codex-rs/codex-client/src/lib.rs | 3 + codex-rs/codex-client/src/transport.rs | 10 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/auth.rs | 2 +- codex-rs/core/src/default_client.rs | 134 +----------------- 7 files changed, 159 insertions(+), 135 deletions(-) create mode 100644 codex-rs/codex-client/src/default_client.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8ee790f676..df26fcfc93 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1098,6 +1098,7 @@ dependencies = [ "codex-apply-patch", "codex-arg0", "codex-async-utils", + "codex-client", "codex-core", "codex-execpolicy", "codex-file-search", diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs new file mode 100644 index 0000000000..8a25846385 --- /dev/null +++ b/codex-rs/codex-client/src/default_client.rs @@ -0,0 +1,143 @@ +use http::Error as HttpError; +use reqwest::IntoUrl; +use reqwest::Method; +use reqwest::Response; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Display; +use std::time::Duration; + +#[derive(Clone, Debug)] +pub struct CodexHttpClient { + inner: reqwest::Client, +} + +impl CodexHttpClient { + pub fn new(inner: reqwest::Client) -> Self { + Self { inner } + } + + pub fn get(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::GET, url) + } + + pub fn post(&self, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + where + U: IntoUrl, + { + let url_str = url.as_str().to_string(); + CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + } +} + +#[must_use = "requests are not sent unless `send` is awaited"] +#[derive(Debug)] +pub struct CodexRequestBuilder { + builder: reqwest::RequestBuilder, + method: Method, + url: String, +} + +impl CodexRequestBuilder { + fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { + Self { + builder, + method, + url, + } + } + + fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { + Self { + builder: f(self.builder), + method: self.method, + url: self.url, + } + } + + pub fn headers(self, headers: HeaderMap) -> Self { + self.map(|builder| builder.headers(headers)) + } + + pub fn header(self, key: K, value: V) -> Self + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + self.map(|builder| builder.header(key, value)) + } + + pub fn bearer_auth(self, token: T) -> Self + where + T: Display, + { + self.map(|builder| builder.bearer_auth(token)) + } + + pub fn timeout(self, timeout: Duration) -> Self { + self.map(|builder| builder.timeout(timeout)) + } + + pub fn json(self, value: &T) -> Self + where + T: ?Sized + Serialize, + { + self.map(|builder| builder.json(value)) + } + + pub async fn send(self) -> Result { + match self.builder.send().await { + Ok(response) => { + let request_ids = Self::extract_request_ids(&response); + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + request_ids = ?request_ids, + version = ?response.version(), + "Request completed" + ); + + Ok(response) + } + Err(error) => { + let status = error.status(); + tracing::debug!( + method = %self.method, + url = %self.url, + status = status.map(|s| s.as_u16()), + error = %error, + "Request failed" + ); + Err(error) + } + } + } + + fn extract_request_ids(response: &Response) -> HashMap { + ["cf-ray", "x-request-id", "x-oai-request-id"] + .iter() + .filter_map(|&name| { + let header_name = HeaderName::from_static(name); + let value = response.headers().get(header_name)?; + let value = value.to_str().ok()?.to_owned(); + Some((name.to_owned(), value)) + }) + .collect() + } +} diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 3ac00a21a8..66d1083c07 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,3 +1,4 @@ +mod default_client; mod error; mod request; mod retry; @@ -5,6 +6,8 @@ mod sse; mod telemetry; mod transport; +pub use crate::default_client::CodexHttpClient; +pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; pub use crate::request::Request; diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs index 5edc9a7b77..986ba3a679 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/codex-client/src/transport.rs @@ -1,3 +1,5 @@ +use crate::default_client::CodexHttpClient; +use crate::default_client::CodexRequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::Response; @@ -28,15 +30,17 @@ pub trait HttpTransport: Send + Sync { #[derive(Clone, Debug)] pub struct ReqwestTransport { - client: reqwest::Client, + client: CodexHttpClient, } impl ReqwestTransport { pub fn new(client: reqwest::Client) -> Self { - Self { client } + Self { + client: CodexHttpClient::new(client), + } } - fn build(&self, req: Request) -> Result { + fn build(&self, req: Request) -> Result { let mut builder = self .client .request( diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a11e7c24d6..4c231e4dda 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -23,6 +23,7 @@ codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-async-utils = { workspace = true } +codex-client = { workspace = true } codex-execpolicy = { workspace = true } codex-file-search = { workspace = true } codex-git = { workspace = true } diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 57ffa17260..20943982d4 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -23,7 +23,6 @@ pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::config::Config; -use crate::default_client::CodexHttpClient; use crate::error::RefreshTokenFailedError; use crate::error::RefreshTokenFailedReason; use crate::token_data::KnownPlan as InternalKnownPlan; @@ -31,6 +30,7 @@ use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; use crate::util::try_parse_error_message; +use codex_client::CodexHttpClient; use codex_protocol::account::PlanType as AccountPlanType; use once_cell::sync::Lazy; use serde_json::Value; diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 29986c401d..7ae2f8c35a 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -1,17 +1,12 @@ use crate::spawn::CODEX_SANDBOX_ENV_VAR; -use http::Error as HttpError; -use reqwest::IntoUrl; -use reqwest::Method; -use reqwest::Response; -use reqwest::header::HeaderName; use reqwest::header::HeaderValue; -use serde::Serialize; -use std::collections::HashMap; -use std::fmt::Display; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::OnceLock; +use codex_client::CodexHttpClient; +pub use codex_client::CodexRequestBuilder; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -31,129 +26,6 @@ pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; -#[derive(Clone, Debug)] -pub struct CodexHttpClient { - inner: reqwest::Client, -} - -impl CodexHttpClient { - fn new(inner: reqwest::Client) -> Self { - Self { inner } - } - - pub fn get(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::GET, url) - } - - pub fn post(&self, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - self.request(Method::POST, url) - } - - pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder - where - U: IntoUrl, - { - let url_str = url.as_str().to_string(); - CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) - } -} - -#[must_use = "requests are not sent unless `send` is awaited"] -#[derive(Debug)] -pub struct CodexRequestBuilder { - builder: reqwest::RequestBuilder, - method: Method, - url: String, -} - -impl CodexRequestBuilder { - fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { - Self { - builder, - method, - url, - } - } - - fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self { - Self { - builder: f(self.builder), - method: self.method, - url: self.url, - } - } - - pub fn header(self, key: K, value: V) -> Self - where - HeaderName: TryFrom, - >::Error: Into, - HeaderValue: TryFrom, - >::Error: Into, - { - self.map(|builder| builder.header(key, value)) - } - - pub fn bearer_auth(self, token: T) -> Self - where - T: Display, - { - self.map(|builder| builder.bearer_auth(token)) - } - - pub fn json(self, value: &T) -> Self - where - T: ?Sized + Serialize, - { - self.map(|builder| builder.json(value)) - } - - pub async fn send(self) -> Result { - match self.builder.send().await { - Ok(response) => { - let request_ids = Self::extract_request_ids(&response); - tracing::debug!( - method = %self.method, - url = %self.url, - status = %response.status(), - request_ids = ?request_ids, - version = ?response.version(), - "Request completed" - ); - - Ok(response) - } - Err(error) => { - let status = error.status(); - tracing::debug!( - method = %self.method, - url = %self.url, - status = status.map(|s| s.as_u16()), - error = %error, - "Request failed" - ); - Err(error) - } - } - } - - fn extract_request_ids(response: &Response) -> HashMap { - ["cf-ray", "x-request-id", "x-oai-request-id"] - .iter() - .filter_map(|&name| { - let header_name = HeaderName::from_static(name); - let value = response.headers().get(header_name)?; - let value = value.to_str().ok()?.to_owned(); - Some((name.to_owned(), value)) - }) - .collect() - } -} #[derive(Debug, Clone)] pub struct Originator { pub value: String, From 8a71f8b6348a4ff48a403615674b17fb890b320e Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 10 Dec 2025 11:14:27 -0800 Subject: [PATCH 18/19] [app-server] Make sure that config writes preserve comments & order or configs (#7789) Make sure that config writes preserve comments and order of configs by utilizing the ConfigEditsBuilder in core. Tested by running a real example and made sure that nothing in the config file changes other than the configs to edit. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/config_api.rs | 190 ++++++++++++++++++++++---- codex-rs/core/src/config/edit.rs | 26 ++++ 4 files changed, 189 insertions(+), 29 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index df26fcfc93..304343bf2c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -851,6 +851,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "tracing", "tracing-subscriber", "uuid", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index e4a326a2c3..948facdea6 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -35,6 +35,7 @@ sha2 = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ae02927f7a..98fe93fb25 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,6 +1,5 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use anyhow::anyhow; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; @@ -15,6 +14,8 @@ use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::OverriddenMetadata; use codex_app_server_protocol::WriteStatus; use codex_core::config::ConfigToml; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::LoadedConfigLayers; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::load_config_layers_with_overrides; @@ -26,9 +27,8 @@ use sha2::Sha256; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use tempfile::NamedTempFile; -use tokio::task; use toml::Value as TomlValue; +use toml_edit::Item as TomlItem; const SESSION_FLAGS_SOURCE: &str = "--config"; const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; @@ -141,19 +141,20 @@ impl ConfigApi { } let mut user_config = layers.user.config.clone(); - let mut mutated = false; let mut parsed_segments = Vec::new(); + let mut config_edits = Vec::new(); for (key_path, value, strategy) in edits.into_iter() { let segments = parse_key_path(&key_path).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; + let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) })?; - let changed = apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy) - .map_err(|err| match err { + apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( + |err| match err { MergeError::PathNotFound => config_write_error( ConfigWriteErrorCode::ConfigPathNotFound, "Path not found", @@ -161,9 +162,24 @@ impl ConfigApi { MergeError::Validation(message) => { config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) } - })?; + }, + )?; + + let updated_value = value_at_path(&user_config, &segments).cloned(); + if original_value != updated_value { + let edit = match updated_value { + Some(value) => ConfigEdit::SetPath { + segments: segments.clone(), + value: toml_value_to_item(&value) + .map_err(|err| internal_error("failed to build config edits", err))?, + }, + None => ConfigEdit::ClearPath { + segments: segments.clone(), + }, + }; + config_edits.push(edit); + } - mutated |= changed; parsed_segments.push(segments); } @@ -183,8 +199,10 @@ impl ConfigApi { ) })?; - if mutated { - self.persist_user_config(&user_config) + if !config_edits.is_empty() { + ConfigEditsBuilder::new(&self.codex_home) + .with_edits(config_edits) + .apply() .await .map_err(|err| internal_error("failed to persist config.toml", err))?; } @@ -253,25 +271,6 @@ impl ConfigApi { mdm, }) } - - async fn persist_user_config(&self, user_config: &TomlValue) -> anyhow::Result<()> { - let codex_home = self.codex_home.clone(); - let serialized = toml::to_string_pretty(user_config)?; - - task::spawn_blocking(move || -> anyhow::Result<()> { - std::fs::create_dir_all(&codex_home)?; - - let target = codex_home.join(CONFIG_FILE_NAME); - let tmp = NamedTempFile::new_in(&codex_home)?; - std::fs::write(tmp.path(), serialized.as_bytes())?; - tmp.persist(&target)?; - Ok(()) - }) - .await - .map_err(|err| anyhow!("config persistence task panicked: {err}"))??; - - Ok(()) - } } fn parse_value(value: JsonValue) -> Result, String> { @@ -422,6 +421,44 @@ fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result anyhow::Result { + match value { + TomlValue::Table(table) => { + let mut table_item = toml_edit::Table::new(); + table_item.set_implicit(false); + for (key, val) in table { + table_item.insert(key, toml_value_to_item(val)?); + } + Ok(TomlItem::Table(table_item)) + } + other => Ok(TomlItem::Value(toml_value_to_value(other)?)), + } +} + +fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), + TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(toml_value_to_value(item)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, val) in table { + inline.insert(key, toml_value_to_value(val)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + #[derive(Clone)] struct LayerState { name: ConfigLayerName, @@ -735,9 +772,104 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::*; + use anyhow::Result; use pretty_assertions::assert_eq; use tempfile::tempdir; + #[test] + fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); + } + + #[tokio::test] + async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), original)?; + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + api.write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), + key_path: "features.remote_compaction".to_string(), + value: json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +remote_compaction = true +"#; + assert_eq!(updated, expected); + Ok(()) + } + #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 68e2d206f0..37c2aba6ef 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -555,6 +555,14 @@ impl ConfigEditsBuilder { self } + pub fn with_edits(mut self, edits: I) -> Self + where + I: IntoIterator, + { + self.edits.extend(edits); + self + } + /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) @@ -603,6 +611,24 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } + #[test] + fn builder_with_edits_applies_custom_paths() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits(vec![ConfigEdit::SetPath { + segments: vec!["enabled".to_string()], + value: value(true), + }]) + .apply_blocking() + .expect("persist"); + + let contents = + std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "enabled = true\n"); + } + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); From afec384ca2dfb46b1434d764f6315abb68eaaeb8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 10 Dec 2025 11:18:31 -0800 Subject: [PATCH 19/19] fix: fix accept_elicitation_for_prompt_rule() test --- .github/workflows/rust-ci.yml | 8 -------- codex-rs/exec-server/tests/common/lib.rs | 16 +++++++++++++++- .../tests/suite/accept_elicitation.rs | 4 +++- codex-rs/exec-server/tests/suite/list_tools.rs | 4 +++- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 0be45540c1..13fcaacbbd 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -412,14 +412,6 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@v2 - - name: Pre-fetch DotSlash artifacts - # The Bash wrapper is not available on Windows. - if: ${{ !startsWith(matrix.runner, 'windows') }} - shell: bash - run: | - set -euo pipefail - dotslash -- fetch exec-server/tests/suite/bash - - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs index c6df5c32c7..a968db9657 100644 --- a/codex-rs/exec-server/tests/common/lib.rs +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -23,7 +23,10 @@ use std::sync::Arc; use std::sync::Mutex; use tokio::process::Command; -pub fn create_transport

(codex_home: P) -> anyhow::Result +pub async fn create_transport

( + codex_home: P, + dotslash_cache: P, +) -> anyhow::Result where P: AsRef, { @@ -36,11 +39,22 @@ where .join("suite") .join("bash"); + // Need to ensure the artifact associated with the bash DotSlash file is + // available before it is run in a read-only sandbox. + Command::new("dotslash") + .arg("--") + .arg("fetch") + .arg(bash.clone()) + .env("DOTSLASH_CACHE", dotslash_cache.as_ref()) + .status() + .await?; + let transport = TokioChildProcess::new(Command::new(mcp_executable.get_program()).configure(|cmd| { cmd.arg("--bash").arg(bash); cmd.arg("--execve").arg(execve_wrapper.get_program()); cmd.env("CODEX_HOME", codex_home.as_ref()); + cmd.env("DOTSLASH_CACHE", dotslash_cache.as_ref()); // Important: pipe stdio so rmcp can speak JSON-RPC over stdin/stdout cmd.stdin(Stdio::piped()); diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs index 2093f9a577..b56c717599 100644 --- a/codex-rs/exec-server/tests/suite/accept_elicitation.rs +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -42,7 +42,9 @@ prefix_rule( codex_home.as_ref(), ) .await?; - let transport = create_transport(codex_home.as_ref())?; + let dotslash_cache_temp_dir = TempDir::new()?; + let dotslash_cache = dotslash_cache_temp_dir.path(); + let transport = create_transport(codex_home.as_ref(), dotslash_cache).await?; // Create an MCP client that approves expected elicitation messages. let project_root = TempDir::new()?; diff --git a/codex-rs/exec-server/tests/suite/list_tools.rs b/codex-rs/exec-server/tests/suite/list_tools.rs index 17505c7613..2f3d412df7 100644 --- a/codex-rs/exec-server/tests/suite/list_tools.rs +++ b/codex-rs/exec-server/tests/suite/list_tools.rs @@ -22,7 +22,9 @@ async fn list_tools() -> Result<()> { policy_dir.join("default.codexpolicy"), r#"prefix_rule(pattern=["ls"], decision="prompt")"#, )?; - let transport = create_transport(codex_home.path())?; + let dotslash_cache_temp_dir = TempDir::new()?; + let dotslash_cache = dotslash_cache_temp_dir.path(); + let transport = create_transport(codex_home.path(), dotslash_cache).await?; let service = ().serve(transport).await?; let tools = service.list_tools(Default::default()).await?.tools;