diff --git a/README.md b/README.md index abd611e7bb..8141610035 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,13 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [**Getting started**](./docs/getting-started.md) - [CLI usage](./docs/getting-started.md#cli-usage) + - [Slash Commands](./docs/slash_commands.md) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) - [**Configuration**](./docs/config.md) + - [Example config](./docs/example-config.md) - [**Sandbox & approvals**](./docs/sandbox.md) - [**Authentication**](./docs/authentication.md) - [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3db45129e4..42f05a819e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -994,6 +994,7 @@ dependencies = [ "supports-color", "tempfile", "tokio", + "toml", ] [[package]] @@ -1471,6 +1472,7 @@ dependencies = [ "regex-lite", "serde", "serde_json", + "serial_test", "shlex", "strum 0.27.2", "strum_macros 0.27.2", diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index ab70df79bd..9692fb95c5 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -31,8 +31,8 @@ use std::process::Command; use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; -type JsonSchemaEmitter = fn(&Path) -> Result; +type JsonSchemaEmitter = fn(&Path) -> Result; pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { generate_ts(out_dir, prettier)?; generate_json(out_dir)?; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 097066019a..8227f5f41b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -548,6 +548,11 @@ server_notification_definitions! { #[strum(serialize = "item/mcpToolCall/progress")] McpToolCallProgress(v2::McpToolCallProgressNotification), + #[serde(rename = "account/updated")] + #[ts(rename = "account/updated")] + #[strum(serialize = "account/updated")] + AccountUpdated(v2::AccountUpdatedNotification), + #[serde(rename = "account/rateLimits/updated")] #[ts(rename = "account/rateLimits/updated")] #[strum(serialize = "account/rateLimits/updated")] diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 8fbcf6b7f3..252fb0410a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -400,6 +400,7 @@ pub struct SessionConfiguredNotification { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] +/// Deprecated notification. Use AccountUpdatedNotification instead. pub struct AuthStatusChangeNotification { pub auth_method: Option, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f179aa5392..bab28f2b79 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::PathBuf; +use crate::protocol::common::AuthMode; use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningEffort; @@ -239,9 +240,7 @@ pub struct FeedbackUploadResponse { } // === Threads, Turns, and Items === - // Thread APIs - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -657,4 +656,8 @@ impl From for RateLimitWindow { resets_at: value.resets_at, } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AccountUpdatedNotification { + pub auth_method: Option, } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b44e5b259b..20cf57c0e4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5,6 +5,7 @@ use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; +use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; use codex_app_server_protocol::ApplyPatchApprovalParams; @@ -248,8 +249,7 @@ impl CodexMessageProcessor { request_id, params: _, } => { - self.send_unimplemented_error(request_id, "account/logout") - .await; + self.logout_v2(request_id).await; } ClientRequest::GetAccount { request_id, @@ -298,7 +298,7 @@ impl CodexMessageProcessor { request_id, params: _, } => { - self.logout_chatgpt(request_id).await; + self.logout_v1(request_id).await; } ClientRequest::GetAuthStatus { request_id, params } => { self.get_auth_status(request_id, params).await; @@ -542,9 +542,9 @@ impl CodexMessageProcessor { } } - async fn logout_chatgpt(&mut self, request_id: RequestId) { + async fn logout_common(&mut self) -> std::result::Result, JSONRPCErrorError> { + // Cancel any active login attempt. { - // Cancel any active login attempt. let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { active.drop(); @@ -552,31 +552,61 @@ impl CodexMessageProcessor { } if let Err(err) = self.auth_manager.logout() { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!("logout failed: {err}"), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } - self.outgoing - .send_response( - request_id, - codex_app_server_protocol::LogoutChatGptResponse {}, - ) - .await; + // Reflect the current auth method after logout (likely None). + Ok(self.auth_manager.auth().map(|auth| auth.mode)) + } - // Send auth status change notification reflecting the current auth mode - // after logout. - let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode); - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, - }; - self.outgoing - .send_server_notification(ServerNotification::AuthStatusChange(payload)) - .await; + async fn logout_v1(&mut self, request_id: RequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response( + request_id, + codex_app_server_protocol::LogoutChatGptResponse {}, + ) + .await; + + let payload = AuthStatusChangeNotification { + auth_method: current_auth_method, + }; + self.outgoing + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn logout_v2(&mut self, request_id: RequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response( + request_id, + codex_app_server_protocol::LogoutAccountResponse {}, + ) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_method: current_auth_method, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } } async fn get_auth_status( diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 11b6b001e2..6563c1ff17 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -141,6 +141,8 @@ pub(crate) struct OutgoingError { #[cfg(test)] mod tests { + use codex_app_server_protocol::AccountUpdatedNotification; + use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; @@ -210,4 +212,24 @@ mod tests { "ensure the notification serializes correctly" ); } + + #[test] + fn verify_account_updated_notification_serialization() { + let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { + auth_method: Some(AuthMode::ApiKey), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/updated", + "params": { + "authMethod": "apikey" + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 9570faf386..d6226db82f 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -381,6 +381,11 @@ impl McpProcess { self.send_request("logoutChatGpt", None).await } + /// Send an `account/logout` JSON-RPC request. + pub async fn send_logout_account_request(&mut self) -> anyhow::Result { + self.send_request("account/logout", None).await + } + /// Send a `fuzzyFileSearch` JSON-RPC request. pub async fn send_fuzzy_file_search_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs new file mode 100644 index 0000000000..5cc3719f24 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use anyhow::bail; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_login::login_with_api_key; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +// Helper to create a minimal config.toml for the app server +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:0/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"#, + ) +} + +#[tokio::test] +async fn logout_account_removes_auth_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + )?; + assert!(codex_home.path().join("auth.json").exists()); + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let id = mcp.send_logout_account_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(id)), + ) + .await??; + let _ok: LogoutAccountResponse = to_response(resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert!( + payload.auth_method.is_none(), + "auth_method should be None after logout" + ); + + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be deleted" + ); + + let status_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + let status_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(status_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(status_resp)?; + assert_eq!(status.auth_method, None); + assert_eq!(status.auth_token, None); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bebc591bb4..587afef108 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,3 +1,4 @@ +mod account; mod model_list; mod rate_limits; mod thread_archive; diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index b5c5b33b8c..041644b34d 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -39,6 +39,7 @@ ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } +toml = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 889bc5a694..2b90cb932d 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -124,6 +124,7 @@ async fn run_command_under_sandbox( let cwd_clone = cwd.clone(); let env_map = env.clone(); let command_vec = command.clone(); + let base_dir = config.codex_home.clone(); let res = tokio::task::spawn_blocking(move || { run_windows_sandbox_capture( policy_str, @@ -132,6 +133,7 @@ async fn run_command_under_sandbox( &cwd_clone, env_map, None, + Some(base_dir.as_path()), ) }) .await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9b2d106c0f..929fd9e785 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -510,15 +510,21 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { // Respect root-level `-c` overrides plus top-level flags like `--profile`. - let cli_kv_overrides = root_config_overrides + let mut cli_kv_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; + // Honor `--search` via the new feature toggle. + if interactive.web_search { + cli_kv_overrides.push(( + "features.web_search_request".to_string(), + toml::Value::Boolean(true), + )); + } + // Thread through relevant top-level flags (at minimum, `--profile`). - // Also honor `--search` since it maps to a feature toggle. let overrides = ConfigOverrides { config_profile: interactive.config_profile.clone(), - tools_web_search_request: interactive.web_search.then_some(true), ..Default::default() }; diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 274717b318..be08cfb683 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -353,7 +353,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) .context("failed to load configuration")?; if !config.features.enabled(Feature::RmcpClient) { - bail!("OAuth login is only supported when [feature].rmcp_client is true in config.toml."); + bail!("OAuth login is only supported when [features].rmcp_client is true in config.toml."); } let LoginArgs { name, scopes } = login_args; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1199c6ca84..a2756a7adf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1773,19 +1773,14 @@ pub(crate) async fn run_task( sess.clone_history().await.get_history_for_prompt() }; - let turn_input_messages: Vec = turn_input + let turn_input_messages = turn_input .iter() - .filter_map(|item| match item { - ResponseItem::Message { content, .. } => Some(content), + .filter_map(|item| match parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => Some(user_message), _ => None, }) - .flat_map(|content| { - content.iter().filter_map(|item| match item { - ContentItem::OutputText { text } => Some(text.clone()), - _ => None, - }) - }) - .collect(); + .map(|user_message| user_message.message()) + .collect::>(); match run_turn( Arc::clone(&sess), Arc::clone(&turn_context), diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 9a5570a249..ef493f1458 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -516,8 +516,8 @@ fn truncate_formatted_exec_output(content: &str, total_lines: usize) -> String { result } -/// Anything that is not a system message or "reasoning" message is considered -/// an API message. +/// API messages include every non-system item (user/assistant messages, reasoning, +/// tool calls, tool outputs, shell calls, and web-search calls). fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", @@ -542,6 +542,8 @@ mod tests { use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; + use codex_protocol::models::ReasoningItemContent; + use codex_protocol::models::ReasoningItemReasoningSummary; use pretty_assertions::assert_eq; fn assistant_msg(text: &str) -> ResponseItem { @@ -570,10 +572,23 @@ mod tests { } } + fn reasoning_msg(text: &str) -> ResponseItem { + ResponseItem::Reasoning { + id: String::new(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: text.to_string(), + }]), + encrypted_content: None, + } + } + #[test] fn filters_non_api_messages() { let mut h = ConversationHistory::default(); - // System message is not an API message; Other is ignored. + // System message is not API messages; Other is ignored. let system = ResponseItem::Message { id: None, role: "system".to_string(), @@ -581,7 +596,8 @@ mod tests { text: "ignored".to_string(), }], }; - h.record_items([&system, &ResponseItem::Other]); + let reasoning = reasoning_msg("thinking..."); + h.record_items([&system, &reasoning, &ResponseItem::Other]); // User and assistant should be retained. let u = user_msg("hi"); @@ -592,6 +608,16 @@ mod tests { assert_eq!( items, vec![ + ResponseItem::Reasoning { + id: String::new(), + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "summary".to_string(), + }], + content: Some(vec![ReasoningItemContent::ReasoningText { + text: "thinking...".to_string(), + }]), + encrypted_content: None, + }, ResponseItem::Message { id: None, role: "user".to_string(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index e8eb064498..b4dacd9af6 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -171,6 +171,7 @@ async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, ) -> Result { + use crate::config::find_codex_home; use codex_windows_sandbox::run_windows_sandbox_capture; let ExecParams { @@ -188,8 +189,17 @@ async fn exec_windows_sandbox( }; let sandbox_cwd = cwd.clone(); + let logs_base_dir = find_codex_home().ok(); let spawn_res = tokio::task::spawn_blocking(move || { - run_windows_sandbox_capture(policy_str, &sandbox_cwd, command, &cwd, env, timeout_ms) + run_windows_sandbox_capture( + policy_str, + &sandbox_cwd, + command, + &cwd, + env, + timeout_ms, + logs_base_dir.as_deref(), + ) }) .await; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b9bd97ca23..413cc76bf1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -85,6 +85,7 @@ pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; pub use command_safety::is_safe_command; pub use safety::get_platform_sandbox; +pub use safety::set_windows_sandbox_enabled; // Re-export the protocol types from the standalone `codex-protocol` crate so existing // `codex_core::protocol::...` references continue to work across the workspace. pub use codex_protocol::protocol; diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 57258f4cd9..e56b9bf7f6 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -10,6 +10,8 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ReasoningContentDeltaEvent; +use codex_protocol::protocol::ReasoningRawContentDeltaEvent; use codex_protocol::protocol::ReviewOutputEvent; use tokio_util::sync::CancellationToken; @@ -122,7 +124,9 @@ async fn process_review_events( .. }) | EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { .. }) - | EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) => {} + | EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent { .. }) + | EventMsg::ReasoningContentDelta(ReasoningContentDeltaEvent { .. }) + | EventMsg::ReasoningRawContentDelta(ReasoningRawContentDeltaEvent { .. }) => {} EventMsg::TaskComplete(task_complete) => { // Parse review output from the last agent message (if present). let out = task_complete diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 7340f5cc9c..baa5321da8 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::time::Instant; use tokio::sync::RwLock; use tokio_util::either::Either; @@ -53,13 +54,16 @@ impl ToolCallRuntime { let turn = Arc::clone(&self.turn_context); let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); - let aborted_response = Self::aborted_response(&call); + let started = Instant::now(); let readiness = self.turn_context.tool_call_gate.clone(); let handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { tokio::select! { - _ = cancellation_token.cancelled() => Ok(aborted_response), + _ = cancellation_token.cancelled() => { + let secs = started.elapsed().as_secs_f32().max(0.1); + Ok(Self::aborted_response(&call, secs)) + }, res = async { tracing::info!("waiting for tool gate"); readiness.wait_ready().await; @@ -71,7 +75,7 @@ impl ToolCallRuntime { }; router - .dispatch_tool_call(session, turn, tracker, call) + .dispatch_tool_call(session, turn, tracker, call.clone()) .await } => res, } @@ -91,23 +95,32 @@ impl ToolCallRuntime { } impl ToolCallRuntime { - fn aborted_response(call: &ToolCall) -> ResponseInputItem { + fn aborted_response(call: &ToolCall, secs: f32) -> ResponseInputItem { match &call.payload { ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput { call_id: call.call_id.clone(), - output: "aborted".to_string(), + output: Self::abort_message(call, secs), }, ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput { call_id: call.call_id.clone(), - result: Err("aborted".to_string()), + result: Err(Self::abort_message(call, secs)), }, _ => ResponseInputItem::FunctionCallOutput { call_id: call.call_id.clone(), output: FunctionCallOutputPayload { - content: "aborted".to_string(), + content: Self::abort_message(call, secs), ..Default::default() }, }, } } + + fn abort_message(call: &ToolCall, secs: f32) -> String { + match call.tool_name.as_str() { + "shell" | "container.exec" | "local_shell" | "unified_exec" => { + format!("Wall time: {secs:.1} seconds\naborted by user") + } + _ => format!("aborted by user after {secs:.1}s"), + } + } } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0064d0e5a4..3f75ed1819 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -244,7 +244,7 @@ pub mod fs_wait { if path.exists() { Ok(path) } else { - Err(anyhow!("timed out waiting for {:?}", path)) + Err(anyhow!("timed out waiting for {path:?}")) } } @@ -284,7 +284,7 @@ pub mod fs_wait { if let Some(found) = scan_for_match(&root, predicate) { Ok(found) } else { - Err(anyhow!("timed out waiting for matching file in {:?}", root)) + Err(anyhow!("timed out waiting for matching file in {root:?}")) } } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c9d595088a..6c03739593 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -1,3 +1,4 @@ +use assert_matches::assert_matches; use std::sync::Arc; use std::time::Duration; @@ -13,6 +14,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event_with_timeout; +use regex_lite::Regex; use serde_json::json; /// Integration test: spawn a long‑running shell tool via a mocked Responses SSE @@ -123,6 +125,7 @@ async fn interrupt_tool_records_history_entries() { ) .await; + tokio::time::sleep(Duration::from_secs_f32(0.1)).await; codex.submit(Op::Interrupt).await.unwrap(); wait_for_event_with_timeout( @@ -159,9 +162,26 @@ async fn interrupt_tool_records_history_entries() { response_mock.saw_function_call(call_id), "function call not recorded in responses payload" ); - assert_eq!( - response_mock.function_call_output_text(call_id).as_deref(), - Some("aborted"), - "aborted function call output not recorded in responses payload" + let output = response_mock + .function_call_output_text(call_id) + .expect("missing function_call_output text"); + let re = Regex::new(r"^Wall time: ([0-9]+(?:\.[0-9])?) seconds\naborted by user$") + .expect("compile regex"); + let captures = re.captures(&output); + assert_matches!( + captures.as_ref(), + Some(caps) if caps.get(1).is_some(), + "aborted message with elapsed seconds" + ); + let secs: f32 = captures + .expect("aborted message with elapsed seconds") + .get(1) + .unwrap() + .as_str() + .parse() + .unwrap(); + assert!( + secs >= 0.1, + "expected at least one tenth of a second of elapsed time, got {secs}" ); } diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 11deb70f3d..6fc31370c3 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -11,6 +11,9 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; use tempfile::TempDir; use wiremock::matchers::any; @@ -61,6 +64,12 @@ echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#, // We fork the notify script, so we need to wait for it to write to the file. fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payload: Value = serde_json::from_str(¬ify_payload_raw)?; + + assert_eq!(payload["type"], json!("agent-turn-complete")); + assert_eq!(payload["input-messages"], json!(["hello world"])); + assert_eq!(payload["last-assistant-message"], json!("Done")); Ok(()) } diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index 0ae512102a..0afc9ea6a2 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -159,6 +159,8 @@ pub fn run( .threads(num_walk_builder_threads) // Allow hidden entries. .hidden(false) + // Follow symlinks to search their contents. + .follow_links(true) // Don't require git to be present to apply to apply git-related ignore rules. .require_git(false); if !respect_gitignore { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c30ba42b56..2d0b0f013a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -812,12 +812,8 @@ impl TokenUsage { (self.non_cached_input() + self.output_tokens.max(0)).max(0) } - /// For estimating what % of the model's context window is used, we need to account - /// for reasoning output tokens from prior turns being dropped from the context window. - /// We approximate this here by subtracting reasoning output tokens from the total. - /// This will be off for the current turn and pending function calls. pub fn tokens_in_context_window(&self) -> i64 { - (self.total_tokens - self.reasoning_output_tokens).max(0) + self.total_tokens } /// Estimate the remaining user-controllable percentage of the model's context window. diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 1109271c1d..8a99c41a26 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -40,12 +40,23 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ## CLI ``` -codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] +codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] [--upstream-url ] ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. - `--server-info `: If set, the proxy writes a single line of JSON with `{ "port": , "pid": }` once listening. - `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. +- `--upstream-url `: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`. +- Authentication is fixed to `Authorization: Bearer ` to match the Codex CLI expectations. + +For Azure, for example (ensure your deployment accepts `Authorization: Bearer `): + +```shell +printenv AZURE_OPENAI_API_KEY | env -u AZURE_OPENAI_API_KEY codex-responses-api-proxy \ + --http-shutdown \ + --server-info /tmp/server-info.json \ + --upstream-url "https://YOUR_PROJECT_NAME.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT/responses?api-version=2025-04-01-preview" +``` ## Notes @@ -57,7 +68,7 @@ codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdow Care is taken to restrict access/copying to the value of `OPENAI_API_KEY` retained in memory: - We leverage [`codex_process_hardening`](https://github.com/openai/codex/blob/main/codex-rs/process-hardening/README.md) so `codex-responses-api-proxy` is run with standard process-hardening techniques. -- At startup, we allocate a `1024` byte buffer on the stack and write `"Bearer "` as the first `7` bytes. +- At startup, we allocate a `1024` byte buffer on the stack and copy `"Bearer "` into the start of the buffer. - We then read from `stdin`, copying the contents into the buffer after `"Bearer "`. - After verifying the key matches `/^[a-zA-Z0-9_-]+$/` (and does not exceed the buffer), we create a `String` from that buffer (so the data is now on the heap). - We zero out the stack-allocated buffer using https://crates.io/crates/zeroize so it is not optimized away by the compiler. diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 50d003bc42..bbe0484d4a 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -12,6 +12,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use clap::Parser; +use reqwest::Url; use reqwest::blocking::Client; use reqwest::header::AUTHORIZATION; use reqwest::header::HOST; @@ -44,6 +45,10 @@ pub struct Args { /// Enable HTTP shutdown endpoint at GET /shutdown #[arg(long)] pub http_shutdown: bool, + + /// Absolute URL the proxy should forward requests to (defaults to OpenAI). + #[arg(long, default_value = "https://api.openai.com/v1/responses")] + pub upstream_url: String, } #[derive(Serialize)] @@ -52,10 +57,29 @@ struct ServerInfo { pid: u32, } +struct ForwardConfig { + upstream_url: Url, + host_header: HeaderValue, +} + /// Entry point for the library main, for parity with other crates. pub fn run_main(args: Args) -> Result<()> { let auth_header = read_auth_header_from_stdin()?; + let upstream_url = Url::parse(&args.upstream_url).context("parsing --upstream-url")?; + let host = match (upstream_url.host_str(), upstream_url.port()) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + _ => return Err(anyhow!("upstream URL must include a host")), + }; + let host_header = + HeaderValue::from_str(&host).context("constructing Host header from upstream URL")?; + + let forward_config = Arc::new(ForwardConfig { + upstream_url, + host_header, + }); + let (listener, bound_addr) = bind_listener(args.port)?; if let Some(path) = args.server_info.as_ref() { write_server_info(path, bound_addr.port())?; @@ -75,13 +99,14 @@ pub fn run_main(args: Args) -> Result<()> { let http_shutdown = args.http_shutdown; for request in server.incoming_requests() { let client = client.clone(); + let forward_config = forward_config.clone(); std::thread::spawn(move || { if http_shutdown && request.method() == &Method::Get && request.url() == "/shutdown" { let _ = request.respond(Response::new_empty(StatusCode(200))); std::process::exit(0); } - if let Err(e) = forward_request(&client, auth_header, request) { + if let Err(e) = forward_request(&client, auth_header, &forward_config, request) { eprintln!("forwarding error: {e}"); } }); @@ -115,7 +140,12 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> { Ok(()) } -fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) -> Result<()> { +fn forward_request( + client: &Client, + auth_header: &'static str, + config: &ForwardConfig, + mut req: Request, +) -> Result<()> { // Only allow POST /v1/responses exactly, no query string. let method = req.method().clone(); let url_path = req.url().to_string(); @@ -157,11 +187,10 @@ fn forward_request(client: &Client, auth_header: &'static str, mut req: Request) auth_header_value.set_sensitive(true); headers.insert(AUTHORIZATION, auth_header_value); - headers.insert(HOST, HeaderValue::from_static("api.openai.com")); + headers.insert(HOST, config.host_header.clone()); - let upstream = "https://api.openai.com/v1/responses"; let upstream_resp = client - .post(upstream) + .post(config.upstream_url.clone()) .headers(headers) .body(body) .send() diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index f3950b540b..df5e2106c7 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -121,7 +121,7 @@ where if total_read == capacity && !saw_newline && !saw_eof { buf.zeroize(); return Err(anyhow!( - "OPENAI_API_KEY is too large to fit in the 512-byte buffer" + "API key is too large to fit in the {BUFFER_SIZE}-byte buffer" )); } @@ -133,7 +133,7 @@ where if total == AUTH_HEADER_PREFIX.len() { buf.zeroize(); return Err(anyhow!( - "OPENAI_API_KEY must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" + "API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)" )); } @@ -214,7 +214,7 @@ fn validate_auth_header_bytes(key_bytes: &[u8]) -> Result<()> { } Err(anyhow!( - "OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'" + "API key may only contain ASCII letters, numbers, '-' or '_'" )) } @@ -290,7 +290,9 @@ mod tests { }) .unwrap_err(); let message = format!("{err:#}"); - assert!(message.contains("OPENAI_API_KEY is too large to fit in the 512-byte buffer")); + let expected_error = + format!("API key is too large to fit in the {BUFFER_SIZE}-byte buffer"); + assert!(message.contains(&expected_error)); } #[test] @@ -317,9 +319,7 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!( - message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") - ); + assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); } #[test] @@ -337,8 +337,6 @@ mod tests { .unwrap_err(); let message = format!("{err:#}"); - assert!( - message.contains("OPENAI_API_KEY may only contain ASCII letters, numbers, '-' or '_'") - ); + assert!(message.contains("API key may only contain ASCII letters, numbers, '-' or '_'")); } } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 85a3cc5a2a..c56aba0fcf 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -106,3 +106,4 @@ insta = { workspace = true } pretty_assertions = { workspace = true } rand = { workspace = true } vt100 = { workspace = true } +serial_test = { workspace = true } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 2571f7bb86..cd913b00d4 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -14,6 +14,12 @@ use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; + +fn is_word_separator(ch: char) -> bool { + WORD_SEPARATORS.contains(ch) +} + #[derive(Debug, Clone)] struct TextElement { range: Range, @@ -841,21 +847,23 @@ impl TextArea { pub(crate) fn beginning_of_previous_word(&self) -> usize { let prefix = &self.text[..self.cursor_pos]; - let Some((first_non_ws_idx, _)) = prefix + let Some((first_non_ws_idx, ch)) = prefix .char_indices() .rev() .find(|&(_, ch)| !ch.is_whitespace()) else { return 0; }; - let before = &prefix[..first_non_ws_idx]; - let candidate = before - .char_indices() - .rev() - .find(|&(_, ch)| ch.is_whitespace()) - .map(|(idx, ch)| idx + ch.len_utf8()) - .unwrap_or(0); - self.adjust_pos_out_of_elements(candidate, true) + let is_separator = is_word_separator(ch); + let mut start = first_non_ws_idx; + for (idx, ch) in prefix[..first_non_ws_idx].char_indices().rev() { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + start = idx + ch.len_utf8(); + break; + } + start = idx; + } + self.adjust_pos_out_of_elements(start, true) } pub(crate) fn end_of_next_word(&self) -> usize { @@ -864,11 +872,19 @@ impl TextArea { return self.text.len(); }; let word_start = self.cursor_pos + first_non_ws; - let candidate = match self.text[word_start..].find(|c: char| c.is_whitespace()) { - Some(rel_idx) => word_start + rel_idx, - None => self.text.len(), + let mut iter = self.text[word_start..].char_indices(); + let Some((_, first_ch)) = iter.next() else { + return word_start; }; - self.adjust_pos_out_of_elements(candidate, false) + let is_separator = is_word_separator(first_ch); + let mut end = self.text.len(); + for (idx, ch) in iter { + if ch.is_whitespace() || is_word_separator(ch) != is_separator { + end = word_start + idx; + break; + } + } + self.adjust_pos_out_of_elements(end, false) } fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { @@ -1239,6 +1255,56 @@ mod tests { assert_eq!(t.cursor(), elem_range.start); } + #[test] + fn delete_backward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "path/to/"); + assert_eq!(t.cursor(), t.text().len()); + + t.delete_backward_word(); + assert_eq!(t.text(), "path/to"); + assert_eq!(t.cursor(), t.text().len()); + + let mut t = ta_with("foo/ "); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 3); + + let mut t = ta_with("foo /"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "foo "); + assert_eq!(t.cursor(), 4); + } + + #[test] + fn delete_forward_word_respects_word_separators() { + let mut t = ta_with("path/to/file"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "/to/file"); + assert_eq!(t.cursor(), 0); + + t.delete_forward_word(); + assert_eq!(t.text(), "to/file"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with("/ foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), " foo"); + assert_eq!(t.cursor(), 0); + + let mut t = ta_with(" /foo"); + t.set_cursor(0); + t.delete_forward_word(); + assert_eq!(t.text(), "foo"); + assert_eq!(t.cursor(), 0); + } + #[test] fn yank_restores_last_kill() { let mut t = ta_with("hello"); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index eb5d3ae9f3..99312ec198 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -16,6 +16,7 @@ use codex_core::auth::enforce_login_restrictions; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::find_conversation_path_by_id_str; +use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; @@ -93,7 +94,7 @@ use std::io::Write as _; // (tests access modules directly within the crate) pub async fn run_main( - cli: Cli, + mut cli: Cli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { @@ -113,6 +114,13 @@ pub async fn run_main( ) }; + // Map the legacy --search flag to the new feature toggle. + if cli.web_search { + cli.config_overrides + .raw_overrides + .push("features.web_search_request=true".to_string()); + } + // When using `--oss`, let the bootstrapper pick the model (defaulting to // gpt-oss:20b) and ensure it is present locally. Also, force the built‑in // `oss` model provider. @@ -148,7 +156,7 @@ pub async fn run_main( compact_prompt: None, include_apply_patch_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), - tools_web_search_request: cli.web_search.then_some(true), + tools_web_search_request: None, experimental_sandbox_command_assessment: None, additional_writable_roots: additional_dirs, }; @@ -512,8 +520,8 @@ async fn load_config_or_exit( /// or if the current cwd project is already trusted. If not, we need to /// show the trust screen. fn should_show_trust_screen(config: &Config) -> bool { - if cfg!(target_os = "windows") { - // Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely. + if cfg!(target_os = "windows") && get_platform_sandbox().is_none() { + // If the experimental sandbox is not enabled, Native Windows cannot enforce sandboxed write access without WSL; skip the trust prompt entirely. return false; } if config.did_user_set_custom_approval_policy_or_sandbox_mode { @@ -557,10 +565,13 @@ mod tests { use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::ProjectConfig; + use codex_core::set_windows_sandbox_enabled; + use serial_test::serial; use tempfile::TempDir; #[test] - fn windows_skips_trust_prompt() -> std::io::Result<()> { + #[serial] + fn windows_skips_trust_prompt_without_sandbox() -> std::io::Result<()> { let temp_dir = TempDir::new()?; let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), @@ -569,6 +580,7 @@ mod tests { )?; config.did_user_set_custom_approval_policy_or_sandbox_mode = false; config.active_project = ProjectConfig { trust_level: None }; + set_windows_sandbox_enabled(false); let should_show = should_show_trust_screen(&config); if cfg!(target_os = "windows") { @@ -584,4 +596,31 @@ mod tests { } Ok(()) } + #[test] + #[serial] + fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + temp_dir.path().to_path_buf(), + )?; + config.did_user_set_custom_approval_policy_or_sandbox_mode = false; + config.active_project = ProjectConfig { trust_level: None }; + set_windows_sandbox_enabled(true); + + let should_show = should_show_trust_screen(&config); + if cfg!(target_os = "windows") { + assert!( + should_show, + "Windows trust prompt should be shown on native Windows with sandbox enabled" + ); + } else { + assert!( + should_show, + "Non-Windows should still show trust prompt when project is untrusted" + ); + } + Ok(()) + } } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index 0b7b74d745..123547cb56 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -17,7 +17,7 @@ expression: sanitized │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.1K used / 272K) │ +│ Context window: 100% left (2.25K used / 272K) │ │ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ │ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │ ╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index 2fc0d88744..3a98ce95ca 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -17,7 +17,7 @@ expression: sanitized │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.1K used / 272K) │ +│ Context window: 100% left (2.25K used / 272K) │ │ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │ │ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │ │ Warning: limits may be stale - start new turn to refresh. │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index d86e43a458..5bbaaef7cd 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -19,7 +19,7 @@ expression: sanitized │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + │ -│ Context window: 100% left (2.1K used / │ +│ Context window: 100% left (2.25K used / │ │ 5h limit: [███████████████░░░░░] │ │ (resets 03:14) │ ╰────────────────────────────────────────────╯ diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index d0b9292dc2..5e165f42dd 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -182,6 +182,7 @@ mod windows_impl { cwd: &Path, mut env_map: HashMap, timeout_ms: Option, + logs_base_dir: Option<&Path>, ) -> Result { let policy = SandboxPolicy::parse(policy_json_or_preset)?; normalize_null_device_env(&mut env_map); @@ -191,7 +192,7 @@ mod windows_impl { let current_dir = cwd.to_path_buf(); // for now, don't fail if we detect world-writable directories // audit::audit_everyone_writable(¤t_dir, &env_map)?; - log_start(&command); + log_start(&command, logs_base_dir); let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe { match &policy.0 { SandboxMode::ReadOnly => { @@ -295,7 +296,7 @@ mod windows_impl { env_block.len(), si.dwFlags, ); - debug_log(&dbg); + debug_log(&dbg, logs_base_dir); unsafe { CloseHandle(in_r); CloseHandle(in_w); @@ -395,9 +396,9 @@ mod windows_impl { }; if exit_code == 0 { - log_success(&command); + log_success(&command, logs_base_dir); } else { - log_failure(&command, &format!("exit code {}", exit_code)); + log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir); } if !persist_aces { @@ -446,6 +447,7 @@ mod stub { _cwd: &Path, _env_map: HashMap, _timeout_ms: Option, + _logs_base_dir: Option<&Path>, ) -> Result { bail!("Windows sandbox is only available on Windows") } diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index feb42ece1a..42d6d80731 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -1,5 +1,7 @@ use std::fs::OpenOptions; use std::io::Write; +use std::path::Path; +use std::path::PathBuf; const LOG_COMMAND_PREVIEW_LIMIT: usize = 200; pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log"; @@ -13,35 +15,43 @@ fn preview(command: &[String]) -> String { } } -fn append_line(line: &str) { - if let Ok(mut f) = OpenOptions::new() - .create(true) - .append(true) - .open(LOG_FILE_NAME) - { - let _ = writeln!(f, "{}", line); +fn log_file_path(base_dir: &Path) -> Option { + if base_dir.is_dir() { + Some(base_dir.join(LOG_FILE_NAME)) + } else { + None } } -pub fn log_start(command: &[String]) { - let p = preview(command); - append_line(&format!("START: {}", p)); +fn append_line(line: &str, base_dir: Option<&Path>) { + if let Some(dir) = base_dir { + if let Some(path) = log_file_path(dir) { + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "{}", line); + } + } + } } -pub fn log_success(command: &[String]) { +pub fn log_start(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("SUCCESS: {}", p)); + append_line(&format!("START: {p}"), base_dir); } -pub fn log_failure(command: &[String], detail: &str) { +pub fn log_success(command: &[String], base_dir: Option<&Path>) { let p = preview(command); - append_line(&format!("FAILURE: {} ({})", p, detail)); + append_line(&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); } // Debug logging helper. Emits only when SBX_DEBUG=1 to avoid noisy logs. -pub fn debug_log(msg: &str) { +pub fn debug_log(msg: &str, base_dir: Option<&Path>) { if std::env::var("SBX_DEBUG").ok().as_deref() == Some("1") { - append_line(&format!("DEBUG: {}", msg)); - eprintln!("{}", msg); + append_line(&format!("DEBUG: {msg}"), base_dir); + eprintln!("{msg}"); } } diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 77c4b8459c..095dcf8b98 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -101,6 +101,7 @@ pub unsafe fn create_process_as_user( argv: &[String], cwd: &Path, env_map: &HashMap, + logs_base_dir: Option<&Path>, ) -> Result<(PROCESS_INFORMATION, STARTUPINFOW)> { let cmdline_str = argv .iter() @@ -142,7 +143,7 @@ pub unsafe fn create_process_as_user( env_block.len(), si.dwFlags, ); - logging::debug_log(&msg); + logging::debug_log(&msg, logs_base_dir); return Err(anyhow!("CreateProcessAsUserW failed: {}", err)); } Ok((pi, si)) diff --git a/docs/example-config.md b/docs/example-config.md new file mode 100644 index 0000000000..531a385600 --- /dev/null +++ b/docs/example-config.md @@ -0,0 +1,374 @@ +# Example config.toml + +Use this example configuration as a starting point. For an explanation of each field and additional context, see [Configuration](./config.md). Copy the snippet below to `~/.codex/config.toml` and adjust values as needed. + +```toml +# Codex example configuration (config.toml) +# +# This file lists all keys Codex reads from config.toml, their default values, +# and concise explanations. Values here mirror the effective defaults compiled +# into the CLI. Adjust as needed. +# +# Notes +# - Root keys must appear before tables in TOML. +# - Optional keys that default to "unset" are shown commented out with notes. +# - MCP servers, profiles, and model providers are examples; remove or edit. + +################################################################################ +# Core Model Selection +################################################################################ + +# Primary model used by Codex. Default differs by OS; non-Windows defaults here. +# Linux/macOS default: "gpt-5-codex"; Windows default: "gpt-5". +model = "gpt-5-codex" + +# Model used by the /review feature (code reviews). Default: "gpt-5-codex". +review_model = "gpt-5-codex" + +# Provider id selected from [model_providers]. Default: "openai". +model_provider = "openai" + +# Optional manual model metadata. When unset, Codex auto-detects from model. +# Uncomment to force values. +# model_context_window = 128000 # tokens; default: auto for model +# model_max_output_tokens = 8192 # tokens; default: auto for model +# model_auto_compact_token_limit = 0 # disable/override auto; default: model family specific + +################################################################################ +# Reasoning & Verbosity (Responses API capable models) +################################################################################ + +# Reasoning effort: minimal | low | medium | high (default: medium) +model_reasoning_effort = "medium" + +# Reasoning summary: auto | concise | detailed | none (default: auto) +model_reasoning_summary = "auto" + +# Text verbosity for GPT-5 family (Responses API): low | medium | high (default: medium) +model_verbosity = "medium" + +# Force-enable reasoning summaries for current model (default: false) +model_supports_reasoning_summaries = false + +# Force reasoning summary format: none | experimental (default: none) +model_reasoning_summary_format = "none" + +################################################################################ +# Instruction Overrides +################################################################################ + +# Additional user instructions appended after AGENTS.md. Default: unset. +# developer_instructions = "" + +# Optional legacy base instructions override (prefer AGENTS.md). Default: unset. +# instructions = "" + +# Inline override for the history compaction prompt. Default: unset. +# compact_prompt = "" + +# Override built-in base instructions with a file path. Default: unset. +# experimental_instructions_file = "/absolute/or/relative/path/to/instructions.txt" + +# Load the compact prompt override from a file. Default: unset. +# experimental_compact_prompt_file = "/absolute/or/relative/path/to/compact_prompt.txt" + +################################################################################ +# Approval & Sandbox +################################################################################ + +# When to ask for command approval: +# - untrusted: only known-safe read-only commands auto-run; others prompt +# - on-failure: auto-run in sandbox; prompt only on failure for escalation +# - on-request: model decides when to ask (default) +# - never: never prompt (risky) +approval_policy = "on-request" + +# Filesystem/network sandbox policy for tool calls: +# - read-only (default) +# - workspace-write +# - danger-full-access (no sandbox; extremely risky) +sandbox_mode = "read-only" + +# Extra settings used only when sandbox_mode = "workspace-write". +[sandbox_workspace_write] +# Additional writable roots beyond the workspace (cwd). Default: [] +writable_roots = [] +# Allow outbound network access inside the sandbox. Default: false +network_access = false +# Exclude $TMPDIR from writable roots. Default: false +exclude_tmpdir_env_var = false +# Exclude /tmp from writable roots. Default: false +exclude_slash_tmp = false + +################################################################################ +# Shell Environment Policy for spawned processes +################################################################################ + +[shell_environment_policy] +# inherit: all (default) | core | none +inherit = "all" +# Skip default excludes for names containing KEY/TOKEN (case-insensitive). Default: false +ignore_default_excludes = false +# Case-insensitive glob patterns to remove (e.g., "AWS_*", "AZURE_*"). Default: [] +exclude = [] +# Explicit key/value overrides (always win). Default: {} +set = {} +# Whitelist; if non-empty, keep only matching vars. Default: [] +include_only = [] +# Experimental: run via user shell profile. Default: false +experimental_use_profile = false + +################################################################################ +# History & File Opener +################################################################################ + +[history] +# save-all (default) | none +persistence = "save-all" +# Maximum bytes for history file (currently not enforced). Example: 5242880 +# max_bytes = 0 + +# URI scheme for clickable citations: vscode (default) | vscode-insiders | windsurf | cursor | none +file_opener = "vscode" + +################################################################################ +# UI, Notifications, and Misc +################################################################################ + +[tui] +# Desktop notifications from the TUI: boolean or filtered list. Default: false +# Examples: true | ["agent-turn-complete", "approval-requested"] +notifications = false + +# Suppress internal reasoning events from output (default: false) +hide_agent_reasoning = false + +# Show raw reasoning content when available (default: false) +show_raw_agent_reasoning = false + +# Disable burst-paste detection in the TUI (default: false) +disable_paste_burst = false + +# Track Windows onboarding acknowledgement (Windows only). Default: false +windows_wsl_setup_acknowledged = false + +# External notifier program (argv array). When unset: disabled. +# Example: notify = ["notify-send", "Codex"] +# notify = [ ] + +# In-product notices (mostly set automatically by Codex). +[notice] +# hide_full_access_warning = true + +################################################################################ +# Authentication & Login +################################################################################ + +# Where to persist CLI login credentials: file (default) | keyring | auto +cli_auth_credentials_store = "file" + +# Base URL for ChatGPT auth flow (not OpenAI API). Default: +chatgpt_base_url = "https://chatgpt.com/backend-api/" + +# Restrict ChatGPT login to a specific workspace id. Default: unset. +# forced_chatgpt_workspace_id = "" + +# Force login mechanism when Codex would normally auto-select. Default: unset. +# Allowed values: chatgpt | api +# forced_login_method = "chatgpt" + +################################################################################ +# Project Documentation Controls +################################################################################ + +# Max bytes from AGENTS.md to embed into first-turn instructions. Default: 32768 +project_doc_max_bytes = 32768 + +# Ordered fallbacks when AGENTS.md is missing at a directory level. Default: [] +project_doc_fallback_filenames = [] + +################################################################################ +# Tools (legacy toggles kept for compatibility) +################################################################################ + +[tools] +# Enable web search tool (alias: web_search_request). Default: false +web_search = false + +# Enable the view_image tool so the agent can attach local images. Default: true +view_image = true + +# (Alias accepted) You can also write: +# web_search_request = false + +################################################################################ +# Centralized Feature Flags (preferred) +################################################################################ + +[features] +# Leave this table empty to accept defaults. Set explicit booleans to opt in/out. +unified_exec = false +streamable_shell = false +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 + +################################################################################ +# Experimental toggles (legacy; prefer [features]) +################################################################################ + +# Use experimental exec command tool (streamable shell). Default: false +experimental_use_exec_command_tool = false + +# Use experimental unified exec tool. Default: false +experimental_use_unified_exec_tool = false + +# Use experimental Rust MCP client (enables OAuth for HTTP MCP). Default: false +experimental_use_rmcp_client = false + +# Include apply_patch via freeform editing path (affects default tool set). Default: false +experimental_use_freeform_apply_patch = false + +# Enable model-based sandbox command assessment. Default: false +experimental_sandbox_command_assessment = false + +################################################################################ +# MCP (Model Context Protocol) servers +################################################################################ + +# Preferred store for MCP OAuth credentials: auto (default) | file | keyring +mcp_oauth_credentials_store = "auto" + +# Define MCP servers under this table. Leave empty to disable. +[mcp_servers] + +# --- Example: STDIO transport --- +# [mcp_servers.docs] +# command = "docs-server" # required +# args = ["--port", "4000"] # optional +# env = { "API_KEY" = "value" } # optional key/value pairs copied as-is +# env_vars = ["ANOTHER_SECRET"] # optional: forward these from the parent env +# cwd = "/path/to/server" # optional working directory override +# startup_timeout_sec = 10.0 # optional; default 10.0 seconds +# # startup_timeout_ms = 10000 # optional alias for startup timeout (milliseconds) +# tool_timeout_sec = 60.0 # optional; default 60.0 seconds +# enabled_tools = ["search", "summarize"] # optional allow-list +# disabled_tools = ["slow-tool"] # optional deny-list (applied after allow-list) + +# --- Example: Streamable HTTP transport --- +# [mcp_servers.github] +# url = "https://github-mcp.example.com/mcp" # required +# bearer_token_env_var = "GITHUB_TOKEN" # optional; Authorization: Bearer +# http_headers = { "X-Example" = "value" } # optional static headers +# env_http_headers = { "X-Auth" = "AUTH_ENV" } # optional headers populated from env vars +# startup_timeout_sec = 10.0 # optional +# tool_timeout_sec = 60.0 # optional +# enabled_tools = ["list_issues"] # optional allow-list + +################################################################################ +# Model Providers (extend/override built-ins) +################################################################################ + +# Built-ins include: +# - openai (Responses API; requires login or OPENAI_API_KEY via auth flow) +# - oss (Chat Completions API; defaults to http://localhost:11434/v1) + +[model_providers] + +# --- Example: override OpenAI with explicit base URL or headers --- +# [model_providers.openai] +# name = "OpenAI" +# base_url = "https://api.openai.com/v1" # default if unset +# wire_api = "responses" # "responses" | "chat" (default varies) +# # requires_openai_auth = true # built-in OpenAI defaults to true +# # request_max_retries = 4 # default 4; max 100 +# # stream_max_retries = 5 # default 5; max 100 +# # stream_idle_timeout_ms = 300000 # default 300_000 (5m) +# # experimental_bearer_token = "sk-example" # optional dev-only direct bearer token +# # http_headers = { "X-Example" = "value" } +# # env_http_headers = { "OpenAI-Organization" = "OPENAI_ORGANIZATION", "OpenAI-Project" = "OPENAI_PROJECT" } + +# --- Example: Azure (Chat/Responses depending on endpoint) --- +# [model_providers.azure] +# name = "Azure" +# base_url = "https://YOUR_PROJECT_NAME.openai.azure.com/openai" +# wire_api = "responses" # or "chat" per endpoint +# query_params = { api-version = "2025-04-01-preview" } +# env_key = "AZURE_OPENAI_API_KEY" +# # env_key_instructions = "Set AZURE_OPENAI_API_KEY in your environment" + +# --- Example: Local OSS (e.g., Ollama-compatible) --- +# [model_providers.ollama] +# name = "Ollama" +# base_url = "http://localhost:11434/v1" +# wire_api = "chat" + +################################################################################ +# Profiles (named presets) +################################################################################ + +# Active profile name. When unset, no profile is applied. +# profile = "default" + +[profiles] + +# [profiles.default] +# model = "gpt-5-codex" +# model_provider = "openai" +# approval_policy = "on-request" +# sandbox_mode = "read-only" +# model_reasoning_effort = "medium" +# model_reasoning_summary = "auto" +# model_verbosity = "medium" +# chatgpt_base_url = "https://chatgpt.com/backend-api/" +# experimental_compact_prompt_file = "compact_prompt.txt" +# include_apply_patch_tool = false +# experimental_use_unified_exec_tool = false +# experimental_use_exec_command_tool = false +# experimental_use_rmcp_client = false +# experimental_use_freeform_apply_patch = false +# experimental_sandbox_command_assessment = false +# tools_web_search = false +# tools_view_image = true +# features = { unified_exec = false } + +################################################################################ +# Projects (trust levels) +################################################################################ + +# Mark specific worktrees as trusted. Only "trusted" is recognized. +[projects] +# [projects."/absolute/path/to/project"] +# trust_level = "trusted" + +################################################################################ +# OpenTelemetry (OTEL) – disabled by default +################################################################################ + +[otel] +# Include user prompt text in logs. Default: false +log_user_prompt = false +# Environment label applied to telemetry. Default: "dev" +environment = "dev" +# Exporter: none (default) | otlp-http | otlp-grpc +exporter = "none" + +# Example OTLP/HTTP exporter configuration +# [otel] +# exporter = { otlp-http = { +# endpoint = "https://otel.example.com/v1/logs", +# protocol = "binary", # "binary" | "json" +# headers = { "x-otlp-api-key" = "${OTLP_TOKEN}" } +# }} + +# Example OTLP/gRPC exporter configuration +# [otel] +# exporter = { otlp-grpc = { +# endpoint = "https://otel.example.com:4317", +# headers = { "x-otlp-meta" = "abc123" } +# }} +``` diff --git a/docs/slash_commands.md b/docs/slash_commands.md new file mode 100644 index 0000000000..4c1a244764 --- /dev/null +++ b/docs/slash_commands.md @@ -0,0 +1,31 @@ +## Slash Commands + +### What are slash commands? + +Slash commands are special commands you can type that start with `/`. + +--- + +### Built-in slash commands + +Control Codex’s behavior during an interactive session with slash commands. + +| Command | Purpose | +| ------------ | ----------------------------------------------------------- | +| `/model` | choose what model and reasoning effort to use | +| `/approvals` | choose what Codex can do without approval | +| `/review` | review my current changes and find issues | +| `/new` | start a new chat during a conversation | +| `/init` | create an AGENTS.md file with instructions for Codex | +| `/compact` | summarize conversation to prevent hitting the context limit | +| `/undo` | ask Codex to undo a turn | +| `/diff` | show git diff (including untracked files) | +| `/mention` | mention a file | +| `/status` | show current session configuration and token usage | +| `/mcp` | list configured MCP tools | +| `/logout` | log out of Codex | +| `/quit` | exit Codex | +| `/exit` | exit Codex | +| `/feedback` | send logs to maintainers | + +---