From a22706dfae2cedfe6056be800ff3d531468e999a Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 26 May 2026 11:12:24 -0700 Subject: [PATCH 001/204] standalone websearch extension (#23823) ## Summary Add the extension-backed standalone `web.run` tool so Codex can call the standalone search endpoint through the `codex-api` search client and return its encrypted output to Responses. - gate the new tool behind `standalone_web_search` - install the extension in the app-server thread registry and hide hosted `web_search` when standalone search is enabled for OpenAI providers so the two paths stay mutually exclusive - build search context from persisted history using a small tail heuristic: previous user message, assistant text between the last two user turns capped at about 1k tokens, and current user message ## Test Plan - `cargo test -p codex-web-search-extension` - `cargo test -p codex-api` - `cargo test -p codex-core hosted_tools_follow_provider_auth_model_and_config_gates` --- codex-rs/Cargo.lock | 22 ++ codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/extensions.rs | 3 + codex-rs/app-server/src/mcp_refresh.rs | 5 +- codex-rs/app-server/src/message_processor.rs | 1 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../app-server/tests/suite/v2/web_search.rs | 216 ++++++++++++++++++ codex-rs/codex-api/Cargo.toml | 1 + codex-rs/codex-api/src/search.rs | 69 ++++-- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/tools/spec_plan.rs | 18 +- codex-rs/core/src/tools/spec_plan_tests.rs | 57 ++++- codex-rs/ext/web-search/BUILD.bazel | 9 + codex-rs/ext/web-search/Cargo.toml | 31 +++ codex-rs/ext/web-search/src/extension.rs | 173 ++++++++++++++ codex-rs/ext/web-search/src/history.rs | 170 ++++++++++++++ codex-rs/ext/web-search/src/lib.rs | 7 + codex-rs/ext/web-search/src/output.rs | 72 ++++++ codex-rs/ext/web-search/src/schema.rs | 36 +++ codex-rs/ext/web-search/src/tool.rs | 113 +++++++++ .../ext/web-search/web_run_description.md | 80 +++++++ codex-rs/features/src/lib.rs | 8 + codex-rs/protocol/src/models.rs | 7 + codex-rs/tools/src/lib.rs | 3 + codex-rs/tools/src/response_history.rs | 149 ++++++++++++ 26 files changed, 1238 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/web_search.rs create mode 100644 codex-rs/ext/web-search/BUILD.bazel create mode 100644 codex-rs/ext/web-search/Cargo.toml create mode 100644 codex-rs/ext/web-search/src/extension.rs create mode 100644 codex-rs/ext/web-search/src/history.rs create mode 100644 codex-rs/ext/web-search/src/lib.rs create mode 100644 codex-rs/ext/web-search/src/output.rs create mode 100644 codex-rs/ext/web-search/src/schema.rs create mode 100644 codex-rs/ext/web-search/src/tool.rs create mode 100644 codex-rs/ext/web-search/web_run_description.md create mode 100644 codex-rs/tools/src/response_history.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 78acd3478d..a1884ba9e2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1859,6 +1859,7 @@ dependencies = [ "pretty_assertions", "regex-lite", "reqwest", + "schemars 0.8.22", "serde", "serde_json", "tempfile", @@ -1927,6 +1928,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "codex-web-search-extension", "core_test_support", "flate2", "futures", @@ -4117,6 +4119,26 @@ dependencies = [ "v8", ] +[[package]] +name = "codex-web-search-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-api", + "codex-core", + "codex-extension-api", + "codex-features", + "codex-login", + "codex-model-provider", + "codex-model-provider-info", + "codex-protocol", + "codex-tools", + "http 1.4.0", + "pretty_assertions", + "schemars 0.8.22", + "serde_json", +] + [[package]] name = "codex-windows-sandbox" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 830e244082..6c9737e4ad 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -48,6 +48,7 @@ members = [ "ext/goal", "ext/guardian", "ext/memories", + "ext/web-search", "external-agent-migration", "external-agent-sessions", "keyring-store", @@ -180,6 +181,7 @@ codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } codex-message-history = { path = "message-history" } codex-memories-extension = { path = "ext/memories" } +codex-web-search-extension = { path = "ext/web-search" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } codex-mcp = { path = "codex-mcp" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 95baac4e9e..17ecd7dc3f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -55,6 +55,7 @@ codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } codex-memories-extension = { workspace = true } +codex-web-search-extension = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 9232cb758b..1246da7b37 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -12,6 +12,7 @@ use codex_extension_api::AgentSpawner; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::Event; @@ -22,6 +23,7 @@ use crate::outgoing_message::OutgoingMessageSender; pub(crate) fn thread_extensions( guardian_agent_spawner: S, event_sink: Arc, + auth_manager: Arc, ) -> Arc> where S: AgentSpawner + 'static, @@ -29,6 +31,7 @@ where let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); + codex_web_search_extension::install(&mut builder, auth_manager); Arc::new(builder.build()) } diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index d65b1d83e0..4ddfa34735 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -184,15 +184,16 @@ mod tests { let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( &good_config, - auth_manager, + auth_manager.clone(), SessionSource::Exec, Arc::new(EnvironmentManager::default_for_tests()), thread_extensions( guardian_agent_spawner(thread_manager.clone()), Arc::new(NoopExtensionEventSink), + auth_manager.clone(), ), /*analytics_events_client*/ None, - thread_store, + Arc::clone(&thread_store), Some(state_db.clone()), "11111111-1111-4111-8111-111111111111".to_string(), /*attestation_provider*/ None, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2d0e8d085f..33b9c56d4a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -314,6 +314,7 @@ impl MessageProcessor { thread_extensions( guardian_agent_spawner(thread_manager.clone()), app_server_extension_event_sink(outgoing.clone()), + auth_manager.clone(), ), Some(analytics_events_client.clone()), Arc::clone(&thread_store), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 92e0f126f3..1605983705 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -68,4 +68,5 @@ mod turn_interrupt; mod turn_start; mod turn_start_zsh_fork; mod turn_steer; +mod web_search; mod windows_sandbox_setup; diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs new file mode 100644 index 0000000000..52c327a93f --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -0,0 +1,216 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { + let call_id = "web-run-1"; + let server = responses::start_mock_server().await; + mount_search_response(&server).await; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "web", + "run", + &json!({ + "search_query": [{"q": "standalone web search"}], + }) + .to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Search the web".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + + let first_response = requests[0].body_json(); + assert!( + requests[0].tool_by_name("web", "run").is_some(), + "web.run should be sent to the model" + ); + assert!( + !has_hosted_web_search(&first_response), + "standalone web search should replace hosted web search" + ); + + let search_body = search_request_body(&server).await?; + assert_eq!( + search_body["commands"], + json!({ + "search_query": [{"q": "standalone web search"}], + }) + ); + assert_eq!( + search_body["input"] + .as_array() + .context("search input should be an array")? + .last(), + Some(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Search the web"}], + })) + ); + + assert_eq!( + requests[1].function_call_output(call_id), + json!({ + "type": "function_call_output", + "call_id": call_id, + "output": [{ + "type": "encrypted_content", + "encrypted_content": "ciphertext", + }], + }) + ); + + Ok(()) +} + +async fn mount_search_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "encrypted_output": "ciphertext", + }))) + .expect(1) + .mount(server) + .await; +} + +fn has_hosted_web_search(body: &Value) -> bool { + body.get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some("web_search")) + }) +} + +async fn search_request_body(server: &MockServer) -> Result { + server + .received_requests() + .await + .context("failed to fetch received requests")? + .into_iter() + .find(|request| request.url.path() == "/api/codex/alpha/search") + .context("expected standalone search request")? + .body_json() + .context("search request body should be JSON") +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "openai-custom" +chatgpt_base_url = "{server_uri}" + +[features] +standalone_web_search = true + +[model_providers.openai-custom] +name = "OpenAI" +base_url = "{server_uri}/api/codex" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = false +requires_openai_auth = true +"# + ), + ) +} diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 08f70cf33c..07d855725a 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -16,6 +16,7 @@ codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } http = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index b841d06a30..8328415f83 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -1,5 +1,6 @@ use crate::common::Reasoning; use codex_protocol::models::ResponseItem; +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -27,75 +28,100 @@ pub enum SearchInput { Items(Vec), } -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)] pub struct SearchCommands { + /// Query the internet search engine for a given list of queries. #[serde(skip_serializing_if = "Option::is_none")] pub search_query: Option>, + /// Query the image search engine for a given list of queries. #[serde(skip_serializing_if = "Option::is_none")] pub image_query: Option>, + /// Open pages by reference id or URL. #[serde(skip_serializing_if = "Option::is_none")] pub open: Option>, + /// Open links from previously opened pages. #[serde(skip_serializing_if = "Option::is_none")] pub click: Option>, + /// Find text patterns in pages. #[serde(skip_serializing_if = "Option::is_none")] pub find: Option>, + /// Take screenshots of PDF pages. #[serde(skip_serializing_if = "Option::is_none")] pub screenshot: Option>, + /// Look up prices for the given stock symbols. #[serde(skip_serializing_if = "Option::is_none")] pub finance: Option>, + /// Look up weather forecasts. #[serde(skip_serializing_if = "Option::is_none")] pub weather: Option>, + /// Look up sports schedules and standings. #[serde(skip_serializing_if = "Option::is_none")] pub sports: Option>, + /// Get time for the given UTC offsets. #[serde(skip_serializing_if = "Option::is_none")] pub time: Option>, + /// Set the length of the response to be returned. #[serde(skip_serializing_if = "Option::is_none")] pub response_length: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct SearchQuery { + /// Search query. pub q: String, + /// Whether to filter by recency, as a number of recent days. #[serde(skip_serializing_if = "Option::is_none")] pub recency: Option, + /// Whether to filter by a specific list of domains. #[serde(skip_serializing_if = "Option::is_none")] pub domains: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct OpenOperation { + /// Reference id or URL to open. pub ref_id: String, + /// Line number to position the page at. #[serde(skip_serializing_if = "Option::is_none")] pub lineno: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct ClickOperation { + /// Reference id containing the numbered link. pub ref_id: String, + /// Numbered link id to open. pub id: u64, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct FindOperation { + /// Reference id or URL to search within. pub ref_id: String, + /// Text pattern to find. pub pattern: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct ScreenshotOperation { + /// Reference id or URL to screenshot. pub ref_id: String, + /// Zero-indexed PDF page number. pub pageno: u64, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct FinanceOperation { + /// Ticker symbol to look up. pub ticker: String, + /// Asset type to look up. pub r#type: FinanceAssetType, + /// ISO 3166-1 alpha-3 country code, "OTC", or "" for cryptocurrency. #[serde(skip_serializing_if = "Option::is_none")] pub market: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum FinanceAssetType { Equity, @@ -104,49 +130,61 @@ pub enum FinanceAssetType { Index, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct WeatherOperation { + /// Location in "Country, Area, City" format. pub location: String, + /// Start date in YYYY-MM-DD format. Defaults to today. #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, + /// Number of days to return. Defaults to 7. #[serde(skip_serializing_if = "Option::is_none")] pub duration: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct SportsOperation { + /// Tool name for sports requests. #[serde(skip_serializing_if = "Option::is_none")] pub tool: Option, + /// Sports function to call. pub r#fn: SportsFunction, + /// League to look up. pub league: SportsLeague, + /// Team to look up, using the common 3 or 4 letter alias used in broadcasts. #[serde(skip_serializing_if = "Option::is_none")] pub team: Option, + /// Opponent to use with `team` when narrowing the lookup. #[serde(skip_serializing_if = "Option::is_none")] pub opponent: Option, + /// Start date in YYYY-MM-DD format. #[serde(skip_serializing_if = "Option::is_none")] pub date_from: Option, + /// End date in YYYY-MM-DD format. #[serde(skip_serializing_if = "Option::is_none")] pub date_to: Option, + /// Number of games to return. #[serde(skip_serializing_if = "Option::is_none")] pub num_games: Option, + /// Locale for the lookup. #[serde(skip_serializing_if = "Option::is_none")] pub locale: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum SportsToolName { Sports, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum SportsFunction { Schedule, Standings, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum SportsLeague { Nba, @@ -160,12 +198,13 @@ pub enum SportsLeague { Ipl, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct TimeOperation { + /// UTC offset formatted like "+03:00". pub utc_offset: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum SearchResponseLength { Short, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f6e15c8063..6af30b4071 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -575,6 +575,9 @@ "sqlite": { "type": "boolean" }, + "standalone_web_search": { + "type": "boolean" + }, "steer": { "type": "boolean" }, @@ -4505,6 +4508,9 @@ "sqlite": { "type": "boolean" }, + "standalone_web_search": { + "type": "boolean" + }, "steer": { "type": "boolean" }, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c3656f2434..2610bee95c 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -238,11 +238,12 @@ fn spec_for_model_request( } } -pub(crate) fn hosted_model_tool_specs(turn_context: &TurnContext) -> Vec { +fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { + let turn_context = context.turn_context; let mut specs = Vec::new(); let provider_capabilities = turn_context.provider.capabilities(); - let web_search_mode = provider_capabilities - .web_search + let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors) + && provider_capabilities.web_search) .then_some(turn_context.config.web_search_mode.value()); let web_search_config = if provider_capabilities.web_search { turn_context.config.web_search_config.as_ref() @@ -504,11 +505,20 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann add_mcp_runtime_tools(context, planned_tools); add_dynamic_tools(context, planned_tools); add_extension_tools(context, planned_tools); - for spec in hosted_model_tool_specs(context.turn_context) { + for spec in hosted_model_tool_specs(context) { planned_tools.add_hosted_spec(spec); } } +fn standalone_web_run_available( + extension_tools: &[Arc>], +) -> bool { + let web_run = ToolName::namespaced("web", "run"); + extension_tools + .iter() + .any(|executor| executor.tool_name() == web_run) +} + fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { let turn_context = context.turn_context; let features = turn_context.features.get(); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 59d6954890..2eb6eea5d2 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -20,8 +20,11 @@ use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ResponsesApiTool; +use codex_tools::ToolCall as ExtensionToolCall; +use codex_tools::ToolExecutor; use codex_tools::ToolExposure; use codex_tools::ToolName; +use codex_tools::ToolOutput; use codex_tools::ToolSpec; use pretty_assertions::assert_eq; use serde_json::json; @@ -37,6 +40,7 @@ struct ToolPlanInputs { mcp_tools: Option>, deferred_mcp_tools: Option>, discoverable_tools: Option>, + extension_tool_executors: Vec>>, dynamic_tools: Vec, } @@ -176,7 +180,7 @@ async fn probe_with( mcp_tools: inputs.mcp_tools, deferred_mcp_tools: inputs.deferred_mcp_tools, discoverable_tools: inputs.discoverable_tools, - extension_tool_executors: Vec::new(), + extension_tool_executors: inputs.extension_tool_executors, dynamic_tools: inputs.dynamic_tools.as_slice(), }, ); @@ -253,6 +257,37 @@ fn use_bedrock_provider(turn: &mut TurnContext) { turn.provider = create_model_provider(provider_info, turn.auth_manager.clone()); } +struct WebRunExtensionTool; + +#[async_trait::async_trait] +impl ToolExecutor for WebRunExtensionTool { + fn tool_name(&self) -> ToolName { + ToolName::namespaced("web", "run") + } + + fn spec(&self) -> ToolSpec { + ToolSpec::Namespace(codex_tools::ResponsesApiNamespace { + name: "web".to_string(), + description: "Test web namespace.".to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: "run".to_string(), + description: "Test standalone web search tool.".to_string(), + strict: false, + defer_loading: None, + parameters: codex_tools::JsonSchema::default(), + output_schema: None, + })], + }) + } + + async fn handle( + &self, + _call: ExtensionToolCall, + ) -> Result, codex_tools::FunctionCallError> { + Ok(Box::new(codex_tools::JsonToolOutput::new(json!({})))) + } +} + fn duplicate_primary_environment(turn: &mut TurnContext) { let mut second_environment = turn.environments.turn_environments[0].clone(); second_environment.environment_id = "secondary".to_string(); @@ -947,6 +982,26 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { } ); + let standalone_web_search_without_web_run = probe(|turn| { + set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); + set_web_search_mode(turn, WebSearchMode::Live); + }) + .await; + standalone_web_search_without_web_run.assert_visible_contains(&["web_search"]); + + let standalone_web_search = probe_with( + |turn| { + set_feature(turn, Feature::StandaloneWebSearch, /*enabled*/ true); + set_web_search_mode(turn, WebSearchMode::Live); + }, + ToolPlanInputs { + extension_tool_executors: vec![Arc::new(WebRunExtensionTool)], + ..Default::default() + }, + ) + .await; + standalone_web_search.assert_visible_lacks(&["web_search"]); + let unsupported_provider = probe(|turn| { set_web_search_mode(turn, WebSearchMode::Live); use_bedrock_provider(turn); diff --git a/codex-rs/ext/web-search/BUILD.bazel b/codex-rs/ext/web-search/BUILD.bazel new file mode 100644 index 0000000000..e8c26644f6 --- /dev/null +++ b/codex-rs/ext/web-search/BUILD.bazel @@ -0,0 +1,9 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "web-search", + crate_name = "codex_web_search_extension", + compile_data = [ + "web_run_description.md", + ], +) diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml new file mode 100644 index 0000000000..978aa4c45d --- /dev/null +++ b/codex-rs/ext/web-search/Cargo.toml @@ -0,0 +1,31 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-web-search-extension" +version.workspace = true + +[lib] +name = "codex_web_search_extension" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +codex-api = { workspace = true } +codex-core = { workspace = true } +codex-extension-api = { workspace = true } +codex-features = { workspace = true } +codex-login = { workspace = true } +codex-model-provider = { workspace = true } +codex-model-provider-info = { workspace = true } +codex-protocol = { workspace = true } +codex-tools = { workspace = true } +http = { workspace = true } +schemars = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs new file mode 100644 index 0000000000..228d77897b --- /dev/null +++ b/codex-rs/ext/web-search/src/extension.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use codex_api::ApproximateLocation; +use codex_api::LocationType; +use codex_api::SearchContextSize; +use codex_api::SearchFilters; +use codex_api::SearchSettings; +use codex_core::config::Config; +use codex_extension_api::ConfigContributor; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadLifecycleContributor; +use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolContributor; +use codex_features::Feature; +use codex_login::AuthManager; +use codex_model_provider::create_model_provider; +use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::config_types::WebSearchContextSize; +use codex_protocol::config_types::WebSearchMode; + +use crate::tool::WebSearchTool; + +#[derive(Clone)] +struct WebSearchExtension { + auth_manager: Arc, +} + +#[derive(Clone)] +struct WebSearchExtensionConfig { + enabled: bool, + provider: ModelProviderInfo, + settings: SearchSettings, +} + +impl From<&Config> for WebSearchExtensionConfig { + fn from(config: &Config) -> Self { + let web_search_mode = config.web_search_mode.value(); + Self { + enabled: config.features.enabled(Feature::StandaloneWebSearch) + && config.model_provider.is_openai() + && web_search_mode != WebSearchMode::Disabled, + provider: config.model_provider.clone(), + settings: search_settings(config, web_search_mode), + } + } +} + +fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSettings { + let web_search_config = config.web_search_config.as_ref(); + SearchSettings { + user_location: web_search_config + .and_then(|config| config.user_location.as_ref()) + .map(|location| ApproximateLocation { + r#type: LocationType::Approximate, + country: location.country.clone(), + region: location.region.clone(), + city: location.city.clone(), + timezone: location.timezone.clone(), + }), + search_context_size: web_search_config + .and_then(|config| config.search_context_size) + .map(|size| match size { + WebSearchContextSize::Low => SearchContextSize::Low, + WebSearchContextSize::Medium => SearchContextSize::Medium, + WebSearchContextSize::High => SearchContextSize::High, + }), + filters: web_search_config + .and_then(|config| config.filters.as_ref()) + .map(|filters| SearchFilters { + allowed_domains: filters.allowed_domains.clone(), + blocked_domains: None, + }), + external_web_access: Some(match web_search_mode { + WebSearchMode::Live => true, + WebSearchMode::Cached | WebSearchMode::Disabled => false, + }), + ..Default::default() + } +} + +#[async_trait::async_trait] +impl ThreadLifecycleContributor for WebSearchExtension { + async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { + input + .thread_store + .insert(WebSearchExtensionConfig::from(input.config)); + } +} + +impl ConfigContributor for WebSearchExtension { + fn on_config_changed( + &self, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + _previous_config: &Config, + new_config: &Config, + ) { + thread_store.insert(WebSearchExtensionConfig::from(new_config)); + } +} + +impl ToolContributor for WebSearchExtension { + fn tools( + &self, + session_store: &ExtensionData, + thread_store: &ExtensionData, + ) -> Vec>> { + let Some(config) = thread_store.get::() else { + return Vec::new(); + }; + if !config.enabled { + return Vec::new(); + } + + vec![Arc::new(WebSearchTool { + session_id: session_store.level_id().to_string(), + provider: create_model_provider( + config.provider.clone(), + Some(self.auth_manager.clone()), + ), + settings: config.settings.clone(), + })] + } +} + +pub fn install(registry: &mut ExtensionRegistryBuilder, auth_manager: Arc) { + let extension = Arc::new(WebSearchExtension { auth_manager }); + registry.thread_lifecycle_contributor(extension.clone()); + registry.config_contributor(extension.clone()); + registry.tool_contributor(extension); +} + +#[cfg(test)] +mod tests { + use codex_extension_api::ExtensionData; + use codex_extension_api::ExtensionRegistryBuilder; + use codex_extension_api::ToolName; + use codex_login::CodexAuth; + use codex_model_provider_info::ModelProviderInfo; + use pretty_assertions::assert_eq; + + use super::AuthManager; + use super::Config; + use super::WebSearchExtensionConfig; + use super::install; + + #[test] + fn installed_extension_contributes_web_run_when_enabled() { + let mut builder = ExtensionRegistryBuilder::::new(); + install( + &mut builder, + AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), + ); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("11111111-1111-4111-8111-111111111111"); + thread_store.insert(WebSearchExtensionConfig { + enabled: true, + provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None), + settings: Default::default(), + }); + + let tool_names = registry + .tool_contributors() + .iter() + .flat_map(|contributor| contributor.tools(&session_store, &thread_store)) + .map(|tool| tool.tool_name()) + .collect::>(); + + assert_eq!(tool_names, vec![ToolName::namespaced("web", "run")]); + } +} diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs new file mode 100644 index 0000000000..b7a26d32cf --- /dev/null +++ b/codex-rs/ext/web-search/src/history.rs @@ -0,0 +1,170 @@ +use codex_api::SearchInput; +use codex_core::parse_turn_item; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_tools::retain_tail_from_last_n_user_messages; +use codex_tools::truncate_assistant_output_text_to_token_budget; + +const ASSISTANT_CONTEXT_TOKEN_LIMIT: usize = 1_000; +const ASSISTANT_ROLE: &str = "assistant"; +const USER_ROLE: &str = "user"; + +/// Builds the conversation tail for standalone web search. +/// +/// The tail keeps the previous user text message, up to 1k tokens of assistant +/// text that followed it, and the current user text message. +pub(crate) fn recent_input(items: &[ResponseItem]) -> Option { + let mut messages = Vec::new(); + for item in items { + push_visible_message(&mut messages, item); + } + + retain_tail_from_last_n_user_messages(&mut messages, /*user_message_count*/ 2); + truncate_assistant_output_text_to_token_budget(&mut messages, ASSISTANT_CONTEXT_TOKEN_LIMIT); + (!messages.is_empty()).then_some(SearchInput::Items(messages)) +} + +fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { + match item { + ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => { + messages.push(item.clone()); + } + ResponseItem::Message { + id, + role, + content, + phase, + } if role == USER_ROLE + && matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) => + { + let content = content + .iter() + .filter(|item| matches!(item, ContentItem::InputText { .. })) + .cloned() + .collect::>(); + if !content.is_empty() { + messages.push(ResponseItem::Message { + id: id.clone(), + role: role.clone(), + content, + phase: phase.clone(), + }); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use codex_api::SearchInput; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use pretty_assertions::assert_eq; + + use super::ASSISTANT_ROLE; + use super::USER_ROLE; + use super::recent_input; + + fn message(role: &str, text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: role.to_string(), + content: vec![if role == ASSISTANT_ROLE { + ContentItem::OutputText { + text: text.to_string(), + } + } else { + ContentItem::InputText { + text: text.to_string(), + } + }], + phase: None, + } + } + + #[test] + fn keeps_current_user_and_previous_visible_turn() { + let items = vec![ + message("system", "system"), + message(USER_ROLE, "old user"), + message(ASSISTANT_ROLE, "old assistant"), + message(USER_ROLE, "previous user"), + ResponseItem::FunctionCall { + id: None, + name: "tool".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + }, + message(ASSISTANT_ROLE, "previous assistant"), + message("developer", "developer"), + message(USER_ROLE, "current user"), + message(ASSISTANT_ROLE, "current commentary"), + ]; + + assert_eq!( + recent_input(&items), + Some(SearchInput::Items(vec![ + message(USER_ROLE, "previous user"), + message(ASSISTANT_ROLE, "previous assistant"), + message(USER_ROLE, "current user"), + ])) + ); + } + + #[test] + fn keeps_only_text_from_recent_user_messages() { + let previous_user = ResponseItem::Message { + id: None, + role: USER_ROLE.to_string(), + content: vec![ + ContentItem::InputText { + text: "previous user".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,image".to_string(), + detail: None, + }, + ], + phase: None, + }; + let items = vec![ + previous_user, + message(ASSISTANT_ROLE, "previous assistant"), + message(USER_ROLE, "current user"), + ]; + + assert_eq!( + recent_input(&items), + Some(SearchInput::Items(vec![ + message(USER_ROLE, "previous user"), + message(ASSISTANT_ROLE, "previous assistant"), + message(USER_ROLE, "current user"), + ])) + ); + } + + #[test] + fn ignores_contextual_user_messages_when_selecting_recent_turns() { + let items = vec![ + message(USER_ROLE, "previous user"), + message(ASSISTANT_ROLE, "previous assistant"), + message( + USER_ROLE, + "\n/tmp\n", + ), + message(USER_ROLE, "current user"), + ]; + + assert_eq!( + recent_input(&items), + Some(SearchInput::Items(vec![ + message(USER_ROLE, "previous user"), + message(ASSISTANT_ROLE, "previous assistant"), + message(USER_ROLE, "current user"), + ])) + ); + } +} diff --git a/codex-rs/ext/web-search/src/lib.rs b/codex-rs/ext/web-search/src/lib.rs new file mode 100644 index 0000000000..1b18039e49 --- /dev/null +++ b/codex-rs/ext/web-search/src/lib.rs @@ -0,0 +1,7 @@ +mod extension; +mod history; +mod output; +mod schema; +mod tool; + +pub use extension::install; diff --git a/codex-rs/ext/web-search/src/output.rs b/codex-rs/ext/web-search/src/output.rs new file mode 100644 index 0000000000..124271c216 --- /dev/null +++ b/codex-rs/ext/web-search/src/output.rs @@ -0,0 +1,72 @@ +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolPayload; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; + +pub(crate) struct EncryptedSearchOutput { + encrypted_output: String, +} + +impl EncryptedSearchOutput { + pub(crate) fn new(encrypted_output: String) -> Self { + Self { encrypted_output } + } +} + +impl ToolOutput for EncryptedSearchOutput { + fn log_preview(&self) -> String { + "[encrypted standalone web search output]".to_string() + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { + // TODO: Make standalone search honor memories.disable_on_external_context, + // as hosted web search does. + ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: self.encrypted_output.clone(), + }, + ]), + } + } +} + +#[cfg(test)] +mod tests { + use codex_extension_api::ToolPayload; + use codex_protocol::models::FunctionCallOutputContentItem; + use codex_protocol::models::FunctionCallOutputPayload; + use codex_protocol::models::ResponseInputItem; + use pretty_assertions::assert_eq; + + use super::EncryptedSearchOutput; + use super::ToolOutput; + + #[test] + fn emits_encrypted_function_call_output() { + let output = EncryptedSearchOutput::new("encrypted-search-output".to_string()); + + assert_eq!( + output.to_response_item( + "call-1", + &ToolPayload::Function { + arguments: "{}".to_string(), + }, + ), + ResponseInputItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::EncryptedContent { + encrypted_content: "encrypted-search-output".to_string(), + }, + ]), + } + ); + } +} diff --git a/codex-rs/ext/web-search/src/schema.rs b/codex-rs/ext/web-search/src/schema.rs new file mode 100644 index 0000000000..2f71f1595c --- /dev/null +++ b/codex-rs/ext/web-search/src/schema.rs @@ -0,0 +1,36 @@ +use codex_api::SearchCommands; +use schemars::r#gen::SchemaSettings; +use serde_json::Map; +use serde_json::Value; + +pub(crate) fn commands_schema() -> Value { + let schema = SchemaSettings::draft2019_09() + .with(|settings| { + settings.inline_subschemas = true; + settings.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::(); + let schema = match serde_json::to_value(schema) { + Ok(schema) => schema, + Err(err) => panic!("search commands schema should serialize: {err}"), + }; + let Value::Object(mut schema) = schema else { + unreachable!("search commands schema must be an object"); + }; + + let mut tool_schema = Map::new(); + for key in [ + "properties", + "required", + "type", + "additionalProperties", + "$defs", + "definitions", + ] { + if let Some(value) = schema.remove(key) { + tool_schema.insert(key.to_string(), value); + } + } + Value::Object(tool_schema) +} diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs new file mode 100644 index 0000000000..1e1db2deca --- /dev/null +++ b/codex-rs/ext/web-search/src/tool.rs @@ -0,0 +1,113 @@ +use codex_api::ReqwestTransport; +use codex_api::SearchClient; +use codex_api::SearchCommands; +use codex_api::SearchRequest; +use codex_api::SearchSettings; +use codex_extension_api::FunctionCallError; +use codex_extension_api::ResponsesApiTool; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolExecutor; +use codex_extension_api::ToolName; +use codex_extension_api::ToolOutput; +use codex_extension_api::ToolSpec; +use codex_extension_api::parse_tool_input_schema; +use codex_login::default_client::build_reqwest_client; +use codex_model_provider::SharedModelProvider; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ToolExposure; +use codex_tools::default_namespace_description; +use http::HeaderMap; + +use crate::history::recent_input; +use crate::output::EncryptedSearchOutput; +use crate::schema::commands_schema; + +const WEB_NAMESPACE: &str = "web"; +const RUN_TOOL_NAME: &str = "run"; +const WEB_RUN_DESCRIPTION: &str = include_str!("../web_run_description.md"); + +pub(crate) struct WebSearchTool { + pub(crate) session_id: String, + pub(crate) provider: SharedModelProvider, + pub(crate) settings: SearchSettings, +} + +#[async_trait::async_trait] +impl ToolExecutor for WebSearchTool { + fn tool_name(&self) -> ToolName { + ToolName::namespaced(WEB_NAMESPACE, RUN_TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + let parameters = match parse_tool_input_schema(&commands_schema()) { + Ok(parameters) => parameters, + Err(err) => panic!("search command schema should parse: {err}"), + }; + + ToolSpec::Namespace(ResponsesApiNamespace { + name: WEB_NAMESPACE.to_string(), + description: default_namespace_description(WEB_NAMESPACE), + tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool { + name: RUN_TOOL_NAME.to_string(), + description: WEB_RUN_DESCRIPTION.to_string(), + strict: false, + parameters, + output_schema: None, + defer_loading: None, + })], + }) + } + + fn exposure(&self) -> ToolExposure { + ToolExposure::DirectModelOnly + } + + async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { + let commands = parse_commands(&call)?; + let provider = self + .provider + .api_provider() + .await + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + let auth = self + .provider + .api_auth() + .await + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + let client = SearchClient::new( + ReqwestTransport::new(build_reqwest_client()), + provider, + auth, + ); + let request = SearchRequest { + id: self.session_id.clone(), + model: None, + reasoning: None, + input: recent_input(call.conversation_history.items()), + commands: Some(commands), + settings: Some(self.settings.clone()), + max_output_tokens: Some( + u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX), + ), + }; + let response = client + .search(&request, HeaderMap::new()) + .await + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + + Ok(Box::new(EncryptedSearchOutput::new( + response.encrypted_output, + ))) + } +} + +fn parse_commands(call: &ToolCall) -> Result { + let arguments = call.function_arguments()?; + if arguments.trim().is_empty() { + return Ok(SearchCommands::default()); + } + + serde_json::from_str(arguments) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} diff --git a/codex-rs/ext/web-search/web_run_description.md b/codex-rs/ext/web-search/web_run_description.md new file mode 100644 index 0000000000..bccc3d81f6 --- /dev/null +++ b/codex-rs/ext/web-search/web_run_description.md @@ -0,0 +1,80 @@ +Tool for accessing the internet. + + +--- + +## Examples of different commands available in this tool + +Examples of different commands available in this tool: +* `search_query`: {"search_query": [{"q": "What is the capital of France?"}, {"q": "What is the capital of belgium?"}]}. Searches the internet for a given query (and optionally with a domain or recency filter) +* `image_query`: {"image_query":[{"q": "waterfalls"}]}. +* `open`: {"open": [{"ref_id": "turn0search0"}, {"ref_id": "https://www.openai.com", "lineno": 120}]} +* `click`: {"click": [{"ref_id": "turn0fetch3", "id": 17}]} +* `find`: {"find": [{"ref_id": "turn0fetch3", "pattern": "Annie Case"}]} +* `screenshot`: {"screenshot": [{"ref_id": "turn1view0", "pageno": 0}, {"ref_id": "turn1view0", "pageno": 3}]} +* `finance`: {"finance":[{"ticker":"AMD","type":"equity","market":"USA"}]}, {"finance":[{"ticker":"BTC","type":"crypto","market":""}]} +* `weather`: {"weather":[{"location":"San Francisco, CA"}]} +* `sports`: {"sports":[{"fn":"standings","league":"nfl"}, {"fn":"schedule","league":"nba","team":"GSW","date_from":"2025-02-24"}]} +* `time`: {"time":[{"utc_offset":"+03:00"}]} + +--- + +## Usage hints +To use this tool efficiently: +* Use multiple commands and queries in one call to get more results faster; e.g. {"search_query": [{"q": "bitcoin news"}], "finance":[{"ticker":"BTC","type":"crypto","market":""}], "find": [{"ref_id": "turn0search0", "pattern": "Annie Case"}, {"ref_id": "turn0search1", "pattern": "John Smith"}]} +* Use "response_length" to control the number of results returned by this tool, omit it if you intend to pass "short" in +* Only write required parameters; do not write empty lists or nulls where they could be omitted. +* `search_query` must have length at most 4 in each call. If it has length > 3, response_length must be medium or long +* If you find yourself in a situation where you accidentally call the `web.run` tool, it's best just to send an empty query: {"search_query": [{"q": ""}]}. + +--- + +## Decision boundary + +If the user makes an explicit request to search the internet, find latest information, look up, etc (or to not do so), you must obey their request. +When you make an assumption, always consider whether it is temporally stable; i.e. whether there's even a small (>10%) chance it has changed. If it is unstable, you must verify with browsing the internet for verification. + + +Below is a list of scenarios where browsing the internet MUST be used. PAY CLOSE ATTENTION: you MUST browse the internet in these cases. If you're unsure or on the fence, you MUST bias towards browsing the internet. +- The information could have changed recently: for example news; prices; laws; schedules; product specs; sports scores; economic indicators; political/public/company figures (e.g. the question relates to 'the president of country A' or 'the CEO of company B', which might change over time); rules; regulations; standards; software libraries that could be updated; exchange rates; recommendations (i.e., recommendations about various topics or things might be informed by what currently exists / is popular / is safe / is unsafe / is in the zeitgeist / etc.); and many many many more categories -- again, if you're on the fence, you MUST browse the internet! + - For news queries, prioritize more recent events, ensuring you compare publish dates and the date that the event happened. +- The user is seeking recommendations that could lead them to spend substantial time or money -- researching products, restaurants, travel plans, etc. +- The user wants (or would benefit from) direct quotes, links, or precise source attribution. +- A specific page, paper, dataset, PDF, or site is referenced and you haven't been given its contents. +- You're unsure about a fact, the topic is niche or emerging, or you suspect there's at least a 10% chance you will incorrectly recall it +- High-stakes accuracy matters (medical, legal, financial guidance). For these you generally should search by default because this information is highly temporally unstable +- The user explicitly says to search, browse, verify, or look it up. + + +--- + +## Special cases +If these conflict with any other instructions, these should take precedence. + + +- When the user asks for information about how to use OpenAI products, (ChatGPT, the OpenAI API, etc.), you should check the code in local env and only browse as fallback, when you browse restrict your sources to official OpenAI websites using the domains filter, unless otherwise requested. +- When using search to answer technical questions, you must only rely on primary sources (research papers, official documentation, etc.) +- Clearly indicate when you are making an inference from sources. + + +--- + +## Word limits +Responses may not excessively quote or draw on a specific source. There are several limits here: +- **Limit on verbatim quotes:** + - You may not quote more than 25 words verbatim from any single non-lyrical source, unless the source is reddit. + - For song lyrics, verbatim quotes must be limited to at most 10 words. + - Long quotes from reddit are allowed, as long as you indicate that those are direct quotes via a markdown blockquote starting with ">", copy verbatim, and link the source. +- **Word limits:** + - Each webpage source in the sources has a word limit label formatted like "[wordlim N]", in which N is the maximum number of words in the whole response that are attributed to that source. If omitted, the word limit is 200 words. + - Non-contiguous words derived from a given source must be counted to the word limit. + - The summarization limit N is a maximum for each source. + - When using multiple sources, their summarization limits add together. However, each article used must be relevant to the response. +- **Copyright compliance:** + - You must avoid providing full articles, long verbatim passages, or extensive direct quotes due to copyright concerns. + - If the user asked for a verbatim quote, the response should provide a short compliant excerpt and then answer with paraphrases and summaries. + - Again, this limit does not apply to reddit content, as long as it's appropriately indicated that those are direct quotes and you link to the source. + +--- + +Make sure to provide links to the sources you used in your response. diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index cd50de4330..391e33e77d 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -103,6 +103,8 @@ pub enum Feature { /// Allow the model to request web searches that fetch cached content. /// Takes precedence over `WebSearchRequest`. WebSearchCached, + /// Expose the extension-backed standalone web search tool. + StandaloneWebSearch, /// Use the legacy Landlock Linux sandbox fallback instead of the default /// bubblewrap pipeline. UseLegacyLandlock, @@ -787,6 +789,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Deprecated, default_enabled: false, }, + FeatureSpec { + id: Feature::StandaloneWebSearch, + key: "standalone_web_search", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SearchTool, key: "search_tool", diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 28a7d6522c..6112ea8a6c 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -902,6 +902,13 @@ pub enum ResponseItem { Other, } +impl ResponseItem { + /// Returns whether this item is an ordinary user-role message. + pub fn is_user_message(&self) -> bool { + matches!(self, Self::Message { role, .. } if role == "user") + } +} + pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md"); /// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI. diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index c141bfb37a..5e8c6fc1bc 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -8,6 +8,7 @@ mod image_detail; mod json_schema; mod mcp_tool; mod request_plugin_install; +mod response_history; mod responses_api; mod tool_call; mod tool_config; @@ -45,6 +46,8 @@ pub use request_plugin_install::RequestPluginInstallResult; pub use request_plugin_install::all_requested_connectors_picked_up; pub use request_plugin_install::build_request_plugin_install_elicitation_request; pub use request_plugin_install::verified_connector_install_completed; +pub use response_history::retain_tail_from_last_n_user_messages; +pub use response_history::truncate_assistant_output_text_to_token_budget; pub use responses_api::FreeformTool; pub use responses_api::FreeformToolFormat; pub use responses_api::LoadableToolSpec; diff --git a/codex-rs/tools/src/response_history.rs b/codex-rs/tools/src/response_history.rs new file mode 100644 index 0000000000..cee6f438cb --- /dev/null +++ b/codex-rs/tools/src/response_history.rs @@ -0,0 +1,149 @@ +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_text; + +/// Retains items from the earliest of the last `user_message_count` user +/// messages through the latest user message. +pub fn retain_tail_from_last_n_user_messages( + items: &mut Vec, + user_message_count: usize, +) { + if user_message_count == 0 { + items.clear(); + return; + } + + let Some(latest_user_idx) = items.iter().rposition(ResponseItem::is_user_message) else { + items.clear(); + return; + }; + items.truncate(latest_user_idx + 1); + + let earliest_retained_user_idx = items + .iter() + .enumerate() + .rev() + .filter(|(_, item)| item.is_user_message()) + .take(user_message_count) + .last() + .map(|(idx, _)| idx) + .unwrap_or(latest_user_idx); + items.drain(..earliest_retained_user_idx); +} + +/// Truncates assistant output text to a shared token budget across items. +pub fn truncate_assistant_output_text_to_token_budget( + items: &mut Vec, + max_tokens: usize, +) { + let mut remaining_budget = max_tokens; + + items.retain_mut(|item| { + let ResponseItem::Message { role, content, .. } = item else { + return true; + }; + if role != "assistant" { + return true; + } + + content.retain_mut(|content_item| { + let ContentItem::OutputText { text } = content_item else { + return true; + }; + if remaining_budget == 0 { + return false; + } + + let token_count = approx_token_count(text); + if token_count <= remaining_budget { + remaining_budget = remaining_budget.saturating_sub(token_count); + return true; + } + + *text = truncate_text(text, TruncationPolicy::Tokens(remaining_budget)); + remaining_budget = 0; + true + }); + !content.is_empty() + }); +} + +#[cfg(test)] +mod tests { + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use codex_utils_output_truncation::TruncationPolicy; + use codex_utils_output_truncation::truncate_text; + use pretty_assertions::assert_eq; + + use super::retain_tail_from_last_n_user_messages; + use super::truncate_assistant_output_text_to_token_budget; + + fn message(role: &str, text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: role.to_string(), + content: vec![if role == "assistant" { + ContentItem::OutputText { + text: text.to_string(), + } + } else { + ContentItem::InputText { + text: text.to_string(), + } + }], + phase: None, + } + } + + #[test] + fn retains_tail_through_latest_user_message() { + let mut items = vec![ + message("system", "system"), + message("user", "old user"), + message("assistant", "old assistant"), + message("user", "previous user"), + message("assistant", "previous assistant"), + message("user", "current user"), + message("assistant", "later assistant"), + ]; + + retain_tail_from_last_n_user_messages(&mut items, /*user_message_count*/ 2); + + assert_eq!( + items, + vec![ + message("user", "previous user"), + message("assistant", "previous assistant"), + message("user", "current user"), + ] + ); + } + + #[test] + fn truncates_assistant_output_text_across_items() { + let long_assistant = "a".repeat(16); + let mut items = vec![ + message("user", "previous user"), + message("assistant", &long_assistant), + message("assistant", "after budget"), + message("user", "current user"), + ]; + + truncate_assistant_output_text_to_token_budget(&mut items, /*max_tokens*/ 2); + + assert_eq!( + items, + vec![ + message("user", "previous user"), + message( + "assistant", + &truncate_text(&long_assistant, TruncationPolicy::Tokens(2)), + ), + message("user", "current user"), + ] + ); + } +} From 833c19ed53a8db3c0d1ceb6b174958e847e7c68c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 26 May 2026 16:08:45 -0300 Subject: [PATCH 002/204] fix(tui): keep raw output above composer in zellij (#24593) ## Why Raw output mode intentionally sends logical source lines to the terminal without Codex-inserted wrapping so copied content retains its original line structure. In Zellij, soft-wrapped continuation rows from those raw lines are not confined by the inline history scroll region. When raw mode replays a long transcript, continuation rows can occupy the composer viewport and are overwritten on the following draw, leaving the transcript visibly truncated underneath the composer. This is specific to the combination of Zellij and raw terminal-wrapped history. Rich output and non-Zellij terminals should continue using the existing insertion behavior. Related context: #20819 introduced raw output mode, and #22214 removed the broad Zellij insertion workaround after the standard rich-output path no longer required it. | Before | After | |---|---| | image | image | ## What Changed - Cache Zellij detection in `Tui` and select a dedicated insertion mode only for `HistoryLineWrapPolicy::Terminal` batches in Zellij. - For that guarded path, clear the existing viewport, append raw source lines through the terminal so its soft wrapping remains selection-friendly, and reserve empty viewport rows before redrawing the composer. - Add snapshot regressions for both an incremental soft-wrapped raw insert and an overflowing raw transcript replay that starts at the top of the cleared terminal. ## How to Test 1. Start Codex inside Zellij with raw output enabled or toggle raw output after a multiline response is in history. 2. Produce or replay output containing long logical lines, such as a fenced shell command with several wrapped lines. 3. Confirm the wrapped history remains visible above the composer and the composer no longer overwrites the end of the response. 4. Toggle back to rich output or run outside Zellij and confirm standard history rendering still behaves normally. Targeted tests run: - `just test -p codex-tui vt100_zellij_raw -- --nocapture` Additional validation notes: - `just test -p codex-tui` was attempted; the two new Zellij raw insertion tests passed, while two existing `app::tests::update_feature_flags_disabling_guardian_*` tests failed outside this history insertion path. - `just argument-comment-lint` was attempted but local Bazel analysis fails before reaching the changed source because the LLVM `compiler-rt` package is missing `include/sanitizer/*.h`. Modified literal callsites were inspected manually. --- codex-rs/tui/src/insert_history.rs | 232 ++++++++++++++---- ...llij_raw_terminal_wrap_above_viewport.snap | 10 + ...terminal_wrap_overflow_above_viewport.snap | 10 + codex-rs/tui/src/tui.rs | 26 +- 4 files changed, 228 insertions(+), 50 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index ede7d03c78..40da6ccbcc 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -40,6 +40,17 @@ pub enum HistoryLineWrapPolicy { Terminal, } +/// Selects the terminal escape strategy used when writing history above the viewport. +/// +/// Raw lines intentionally remain unbroken so terminal selection copies their source faithfully. +/// Zellij does not constrain soft-wrapped continuation rows to Codex's scroll region, so its raw +/// path appends history through the terminal and reserves blank rows for the next viewport draw. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InsertHistoryMode { + Standard, + ZellijRaw, +} + /// Insert `lines` above the viewport using the terminal's backend writer /// (avoids direct stdout references). pub fn insert_history_lines( @@ -57,6 +68,23 @@ pub fn insert_history_lines_with_wrap_policy( lines: Vec, wrap_policy: HistoryLineWrapPolicy, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_lines_with_mode_and_wrap_policy( + terminal, + lines, + InsertHistoryMode::Standard, + wrap_policy, + ) +} + +pub(crate) fn insert_history_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -65,7 +93,6 @@ where let mut area = terminal.viewport_area; let mut should_update_area = false; let last_cursor_pos = terminal.last_known_cursor_pos; - let writer = terminal.backend_mut(); // Pre-wrap lines for terminal scrollback. Three paths: // @@ -102,60 +129,92 @@ where wrapped.extend(line_wrapped); } let wrapped_lines = wrapped_rows as u16; - let cursor_top = if area.bottom() < screen_size.height { - // If the viewport is not at the bottom of the screen, scroll it down to make room. - // Don't scroll it past the bottom of the screen. - let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); + match mode { + InsertHistoryMode::ZellijRaw => { + // The existing viewport is immediately replaced in the same draw pass. Clear it + // before terminal scrolling can move composer contents into scrollback. + terminal.clear_after_position(area.as_position())?; + let writer = terminal.backend_mut(); + queue!(writer, MoveTo(/*x*/ 0, area.top()))?; + for (index, line) in wrapped.iter().enumerate() { + if index > 0 { + queue!(writer, Print("\r\n"))?; + } + write_history_line(writer, line, wrap_width)?; + } - let top_1based = area.top() + 1; - queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; - queue!(writer, MoveTo(/*x*/ 0, area.top()))?; - for _ in 0..scroll_amount { - queue!(writer, Print("\x1bM"))?; + // Writing raw source text through the terminal preserves its soft-wrap metadata. + // Advance through empty rows for the viewport so history ends immediately above the + // composer even when a replay batch is taller than the visible history region. + for _ in 0..area.height { + queue!(writer, Print("\r\n"), Clear(ClearType::UntilNewLine))?; + } + queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; + + let viewport_top = area + .top() + .saturating_add(wrapped_lines) + .min(screen_size.height.saturating_sub(area.height)); + if area.y != viewport_top { + area.y = viewport_top; + should_update_area = true; + } } - queue!(writer, ResetScrollRegion)?; + InsertHistoryMode::Standard => { + let writer = terminal.backend_mut(); + let cursor_top = if area.bottom() < screen_size.height { + // If the viewport is not at the bottom of the screen, scroll it down to make room. + // Don't scroll it past the bottom of the screen. + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); - let cursor_top = area.top().saturating_sub(1); - area.y += scroll_amount; - should_update_area = true; - cursor_top - } else { - area.top().saturating_sub(1) - }; + let top_1based = area.top() + 1; + queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; + queue!(writer, MoveTo(/*x*/ 0, area.top()))?; + for _ in 0..scroll_amount { + queue!(writer, Print("\x1bM"))?; + } + queue!(writer, ResetScrollRegion)?; - // Limit the scroll region to the lines from the top of the screen to the - // top of the viewport. With this in place, when we add lines inside this - // area, only the lines in this area will be scrolled. We place the cursor - // at the end of the scroll region, and add lines starting there. - // - // ┌─Screen───────────────────────┐ - // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ - // │┆ ┆│ - // │┆ ┆│ - // │┆ ┆│ - // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ - // │╭─Viewport───────────────────╮│ - // ││ ││ - // │╰────────────────────────────╯│ - // └──────────────────────────────┘ - queue!(writer, SetScrollRegion(1..area.top()))?; + let cursor_top = area.top().saturating_sub(1); + area.y += scroll_amount; + should_update_area = true; + cursor_top + } else { + area.top().saturating_sub(1) + }; - // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the - // terminal's last_known_cursor_position, which hopefully will still be accurate after we - // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) - queue!(writer, MoveTo(/*x*/ 0, cursor_top))?; + // Limit the scroll region to the lines from the top of the screen to the + // top of the viewport. With this in place, when we add lines inside this + // area, only the lines in this area will be scrolled. We place the cursor + // at the end of the scroll region, and add lines starting there. + // + // ┌─Screen───────────────────────┐ + // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ + // │┆ ┆│ + // │┆ ┆│ + // │┆ ┆│ + // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ + // │╭─Viewport───────────────────╮│ + // ││ ││ + // │╰────────────────────────────╯│ + // └──────────────────────────────┘ + queue!(writer, SetScrollRegion(1..area.top()))?; - for line in &wrapped { - queue!(writer, Print("\r\n"))?; - write_history_line(writer, line, wrap_width)?; + // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the + // terminal's last_known_cursor_position, which hopefully will still be accurate after we + // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) + queue!(writer, MoveTo(/*x*/ 0, cursor_top))?; + + for line in &wrapped { + queue!(writer, Print("\r\n"))?; + write_history_line(writer, line, wrap_width)?; + } + + queue!(writer, ResetScrollRegion)?; + queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; + } } - queue!(writer, ResetScrollRegion)?; - - // Restore the cursor position to where it was before we started. - queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; - - let _ = writer; if should_update_area { terminal.set_viewport_area(area); } @@ -796,6 +855,85 @@ mod tests { ); } + #[test] + fn vt100_zellij_raw_insert_keeps_soft_wrapped_tail_above_viewport() { + let width: u16 = 20; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new( + /*x*/ 0, + /*y*/ height - 2, + /*width*/ width, + /*height*/ 2, + ); + term.set_viewport_area(viewport); + + let line = Line::from("raw-start-aaaaaaaaaaaaaaaaaaaaaaaa-tail-must-remain"); + insert_history_lines_with_mode_and_wrap_policy( + &mut term, + vec![line], + InsertHistoryMode::ZellijRaw, + HistoryLineWrapPolicy::Terminal, + ) + .expect("insert Zellij raw history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + insta::assert_snapshot!("zellij_raw_terminal_wrap_above_viewport", rows.join("\n")); + let history_rows = rows[..usize::from(term.viewport_area.y)] + .iter() + .map(|row| row.trim_end()) + .collect::(); + let viewport_rows = rows[usize::from(term.viewport_area.y)..].join("\n"); + assert!( + history_rows.contains("tail-must-remain"), + "expected wrapped raw tail above the viewport, rows: {rows:?}" + ); + assert!( + !viewport_rows.contains("tail-must-remain"), + "raw tail must not be written through the viewport, rows: {rows:?}" + ); + } + + #[test] + fn vt100_zellij_raw_replay_keeps_overflowing_soft_wrapped_tail_above_viewport() { + let width: u16 = 20; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ width, /*height*/ 2, + )); + + let line = Line::from(format!("raw-start-{}tail-must-remain", "a".repeat(130))); + insert_history_lines_with_mode_and_wrap_policy( + &mut term, + vec![line], + InsertHistoryMode::ZellijRaw, + HistoryLineWrapPolicy::Terminal, + ) + .expect("replay Zellij raw history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + insta::assert_snapshot!( + "zellij_raw_terminal_wrap_overflow_above_viewport", + rows.join("\n") + ); + let history_rows = rows[..usize::from(term.viewport_area.y)] + .iter() + .map(|row| row.trim_end()) + .collect::(); + let viewport_rows = rows[usize::from(term.viewport_area.y)..].join("\n"); + assert!( + history_rows.contains("tail-must-remain"), + "expected overflowing raw tail above the viewport, rows: {rows:?}" + ); + assert!( + !viewport_rows.contains("tail-must-remain"), + "overflowing raw tail must not be written through the viewport, rows: {rows:?}" + ); + } + #[test] fn vt100_unwrapped_url_like_clears_continuation_rows() { let width: u16 = 20; diff --git a/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap new file mode 100644 index 0000000000..812030a9d1 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_above_viewport.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/insert_history.rs +expression: "rows.join(\"\\n\")" +--- + + + +raw-start-aaaaaaaaaa +aaaaaaaaaaaaaa-tail- +must-remain diff --git a/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap new file mode 100644 index 0000000000..091250848a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__insert_history__tests__zellij_raw_terminal_wrap_overflow_above_viewport.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/insert_history.rs +expression: "rows.join(\"\\n\")" +--- +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaa +tail-must-remain diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 49d838e866..ef2f1b07dc 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -41,6 +41,7 @@ pub use self::frame_requester::FrameRequester; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; use crate::insert_history::HistoryLineWrapPolicy; +use crate::insert_history::InsertHistoryMode; use crate::notifications::DesktopNotificationBackend; use crate::notifications::detect_backend; use crate::tui::event_stream::EventBroker; @@ -500,6 +501,8 @@ pub struct Tui { enhanced_keys_supported: bool, notification_backend: Option, notification_condition: NotificationCondition, + // Raw terminal-wrapped history needs a non-scroll-region insertion path in Zellij. + is_zellij: bool, // When false, enter_alt_screen() becomes a no-op. alt_screen_enabled: bool, // Keeps unmanaged process stderr writes out of the inline viewport. @@ -535,6 +538,7 @@ impl Tui { // Cache this to avoid contention with the event reader. supports_color::on_cached(supports_color::Stream::Stdout); let _ = crate::terminal_palette::default_colors(); + let is_zellij = codex_terminal_detection::terminal_info().is_zellij(); Self { frame_requester, @@ -552,6 +556,7 @@ impl Tui { enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), notification_condition: NotificationCondition::default(), + is_zellij, alt_screen_enabled: true, _stderr_guard: stderr_guard, } @@ -797,15 +802,22 @@ impl Tui { fn flush_pending_history_lines( terminal: &mut Terminal, pending_history_lines: &mut Vec, + is_zellij: bool, ) -> Result<()> { if pending_history_lines.is_empty() { return Ok(()); } for batch in pending_history_lines.iter() { - crate::insert_history::insert_history_lines_with_wrap_policy( + let mode = if is_zellij && batch.wrap_policy == HistoryLineWrapPolicy::Terminal { + InsertHistoryMode::ZellijRaw + } else { + InsertHistoryMode::Standard + }; + crate::insert_history::insert_history_lines_with_mode_and_wrap_policy( terminal, batch.lines.clone(), + mode, batch.wrap_policy, )?; } @@ -862,7 +874,11 @@ impl Tui { terminal.set_viewport_area(area); } - Self::flush_pending_history_lines(terminal, &mut self.pending_history_lines)?; + Self::flush_pending_history_lines( + terminal, + &mut self.pending_history_lines, + self.is_zellij, + )?; // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)] @@ -968,7 +984,11 @@ impl Tui { let terminal = &mut self.terminal; let needs_full_repaint = Self::update_inline_viewport_for_resize_reflow(terminal, height)?; - Self::flush_pending_history_lines(terminal, &mut self.pending_history_lines)?; + Self::flush_pending_history_lines( + terminal, + &mut self.pending_history_lines, + self.is_zellij, + )?; if needs_full_repaint { terminal.invalidate_viewport(); From cd934c8bcb1ba50791a980db1d0b38cef7cf84f9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 26 May 2026 12:09:07 -0700 Subject: [PATCH 003/204] tui: keep inaccessible apps out of mentions (#24625) ## Summary Fix the TUI `$` app mention paths so App Directory rows that are not accessible are not treated as usable apps. This includes the core preservation fix from #24104, but expands it to the other app mention paths: - preserve app-server `is_accessible` flags when partial `app/list/updated` snapshots reach the TUI - require apps to be both accessible and enabled when resolving exact `$slug` mentions - require restored/stale `app://...` bindings to point at accessible, enabled apps before emitting structured app mentions - remove the now-unused `codex-chatgpt` dependency from `codex-tui`, which addresses the `cargo shear` failure seen on #24104 ## Root Cause The app server already sends merged app snapshots with accessibility computed. The TUI handled app-server app list updates as partial app loads and re-ran the old accessible-app merge path. That path treated every notification row as accessible, so App Directory entries with `isAccessible=false` could appear in `$` suggestions. Regression source: #22914 routed app-list updates through the app server while reusing the old TUI partial-load handling. Related precursor: #14717 introduced the partial-load path, but #22914 made it user-visible for app-server updates. ## Issues Fixes #24145 Fixes #24205 Fixes #24319 ## Validation - `just fmt` - `git diff --check` - `just bazel-lock-update` - `just bazel-lock-check` - `just argument-comment-lint -p codex-tui` - `just test -p codex-tui chatwidget::tests::popups_and_settings::apps_notification_update_excludes_inaccessible_apps_from_mentions chatwidget::tests::composer_submission::submit_user_message_ignores_inaccessible_app_mentions_from_bindings chatwidget::skills::tests::find_app_mentions_requires_accessible_enabled_apps_for_bound_paths chatwidget::skills::tests::find_app_mentions_requires_accessible_enabled_apps_for_slugs` --- codex-rs/Cargo.lock | 1 - codex-rs/tui/Cargo.toml | 1 - codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/connectors.rs | 7 -- .../tui/src/chatwidget/input_submission.rs | 8 +- codex-rs/tui/src/chatwidget/skills.rs | 81 ++++++++++++++++++- .../chatwidget/tests/composer_submission.rs | 56 +++++++++++++ .../chatwidget/tests/popups_and_settings.rs | 71 ++++++++++++++++ 8 files changed, 212 insertions(+), 15 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a1884ba9e2..044146d9de 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3790,7 +3790,6 @@ dependencies = [ "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", - "codex-chatgpt", "codex-cli", "codex-cloud-requirements", "codex-config", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c213e92bae..ffc5c5a942 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -31,7 +31,6 @@ codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-install-context = { workspace = true } -codex-chatgpt = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } codex-connectors = { workspace = true } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2b86ab3d71..b9c5101187 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -124,7 +124,6 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnPlanStepStatus; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; -use codex_chatgpt::connectors as chatgpt_connectors; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; @@ -362,6 +361,7 @@ mod slash_dispatch; use self::skills::collect_tool_mentions; use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; +use self::skills::is_app_mentionable; mod plugins; use self::plugins::PluginInstallAuthFlowState; use self::plugins::PluginListFetchState; diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index 2df64df7d7..683bff135b 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -303,13 +303,6 @@ impl ChatWidget { match result { Ok(mut snapshot) => { - if !is_final { - snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible( - Vec::new(), - snapshot.connectors, - /*all_connectors_loaded*/ false, - ); - } if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { let enabled_by_id: HashMap<&str, bool> = existing_snapshot .connectors diff --git a/codex-rs/tui/src/chatwidget/input_submission.rs b/codex-rs/tui/src/chatwidget/input_submission.rs index 2c960ee3d5..4700ffc0cc 100644 --- a/codex-rs/tui/src/chatwidget/input_submission.rs +++ b/codex-rs/tui/src/chatwidget/input_submission.rs @@ -263,10 +263,14 @@ impl ChatWidget { else { continue; }; - if !selected_app_ids.insert(app_id.to_string()) { + if selected_app_ids.contains(app_id) { continue; } - if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { + if let Some(app) = apps + .iter() + .find(|app| app.id == app_id && is_app_mentionable(app)) + { + selected_app_ids.insert(app_id.to_string()); items.push(UserInput::Mention { name: app.name.clone(), path: binding.path.clone(), diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 356e191b8e..687ce56416 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -315,12 +315,12 @@ pub(crate) fn find_app_mentions( } let mut slug_counts: HashMap = HashMap::new(); - for app in apps.iter().filter(|app| app.is_enabled) { + for app in apps.iter().filter(|app| is_app_mentionable(app)) { let slug = codex_connectors::metadata::connector_mention_slug(app); *slug_counts.entry(slug).or_insert(0) += 1; } - for app in apps.iter().filter(|app| app.is_enabled) { + for app in apps.iter().filter(|app| is_app_mentionable(app)) { let slug = codex_connectors::metadata::connector_mention_slug(app); let slug_count = slug_counts.get(&slug).copied().unwrap_or(0); if mentions.names.contains(&slug) @@ -333,11 +333,15 @@ pub(crate) fn find_app_mentions( } apps.iter() - .filter(|app| app.is_enabled && selected_ids.contains(&app.id)) + .filter(|app| is_app_mentionable(app) && selected_ids.contains(&app.id)) .cloned() .collect() } +pub(crate) fn is_app_mentionable(app: &AppInfo) -> bool { + app.is_accessible && app.is_enabled +} + pub(crate) struct ToolMentions { names: HashSet, linked_paths: HashMap, @@ -497,3 +501,74 @@ fn app_id_from_path(path: &str) -> Option<&str> { path.strip_prefix("app://") .filter(|value| !value.is_empty()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn app(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + } + } + + #[test] + fn find_app_mentions_requires_accessible_enabled_apps_for_slugs() { + let apps = vec![ + app("google_drive", "Google Drive"), + AppInfo { + is_accessible: false, + ..app("arabica_uae", "% Arabica UAE") + }, + AppInfo { + is_enabled: false, + ..app("linear", "Linear") + }, + ]; + let mentions = collect_tool_mentions("$google-drive $arabica-uae $linear", &HashMap::new()); + + assert_eq!( + find_app_mentions(&mentions, &apps, &HashSet::new()), + vec![apps[0].clone()] + ); + } + + #[test] + fn find_app_mentions_requires_accessible_enabled_apps_for_bound_paths() { + let apps = vec![ + app("google_drive", "Google Drive"), + AppInfo { + is_accessible: false, + ..app("arabica_uae", "% Arabica UAE") + }, + AppInfo { + is_enabled: false, + ..app("linear", "Linear") + }, + ]; + let mention_paths = HashMap::from([ + ("google-drive".to_string(), "app://google_drive".to_string()), + ("arabica-uae".to_string(), "app://arabica_uae".to_string()), + ("linear".to_string(), "app://linear".to_string()), + ]); + let mentions = collect_tool_mentions("$google-drive $arabica-uae $linear", &mention_paths); + + assert_eq!( + find_app_mentions(&mentions, &apps, &HashSet::new()), + vec![apps[0].clone()] + ); + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 70b5c49b0e..bfe563b94c 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app_event::ConnectorsSnapshot; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -1172,6 +1173,61 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { } } +#[tokio::test] +async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + set_chatgpt_auth(&mut chat); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![AppInfo { + id: "arabica_uae".to_string(), + name: "% Arabica UAE".to_string(), + description: Some("Directory-only app".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/arabica".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }], + }), + /*is_final*/ false, + ); + + chat.submit_user_message(UserMessage { + text: "$arabica-uae".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: vec![MentionBinding { + mention: "arabica-uae".to_string(), + path: "app://arabica_uae".to_string(), + }], + }); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Text { + text: "$arabica-uae".to_string(), + text_elements: Vec::new(), + }] + ); +} + #[test] fn user_message_display_from_inputs_matches_flattened_user_message_shape() { let local_image = PathBuf::from("/tmp/local.png"); diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 09d911ad16..3a4bdd1329 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1399,6 +1399,77 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() { ); } +#[tokio::test] +async fn apps_notification_update_excludes_inaccessible_apps_from_mentions() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); + chat.bottom_pane + .set_composer_text("$".to_string(), Vec::new(), Vec::new()); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![ + AppInfo { + id: "google_drive".to_string(), + name: "Google Drive".to_string(), + description: Some("Connected files".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/google-drive".to_string()), + is_accessible: true, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "arabica_uae".to_string(), + name: "% Arabica UAE".to_string(), + description: Some("Directory-only app".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://example.test/arabica".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ], + }), + /*is_final*/ false, + ); + + assert_matches!( + &chat.connectors.partial_snapshot, + Some(snapshot) + if snapshot + .connectors + .iter() + .find(|connector| connector.id == "arabica_uae") + .is_some_and(|connector| !connector.is_accessible) + ); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!( + popup.contains("Google Drive"), + "expected accessible apps to appear in the mention popup, got:\n{popup}" + ); + assert!( + !popup.contains("% Arabica UAE"), + "did not expect an inaccessible directory app in the mention popup, got:\n{popup}" + ); +} + #[tokio::test] async fn apps_refresh_failure_keeps_existing_full_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 768848ab6fc6f05f19c447786fd76fd233fa1d69 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 26 May 2026 13:02:34 -0700 Subject: [PATCH 004/204] Add experimental turn additional context (#24154) ## Summary Adds experimental `additionalContext` support to `turn/start` and `turn/steer` so clients can provide ephemeral external context, such as browser or automation state, without turning that plumbing into a visible user prompt or triggering user-prompt lifecycle behavior. ## API Shape The parameter shape is: ```ts additionalContext?: Record | null ``` Example: ```json { "additionalContext": { "browser_info": { "value": "Active tab is CI failures.", "kind": "untrusted" }, "automation_info": { "value": "CI rerun is in progress.", "kind": "application" } } } ``` The keys are opaque and caller-defined. ## Context Injection When provided, accepted entries are inserted into model context as hidden contextual message items, not as visible thread user-message items. `kind: "untrusted"` entries are inserted with role `user`: ```text ${value} ``` `kind: "application"` entries are inserted with role `developer`: ```text <${key}>${value} ``` Values are not escaped. Each value is truncated to 1k approximate tokens before wrapping. For `turn/start`, accepted additional context is inserted before normal user input. For `turn/steer`, additional context is merged only when the steer includes non-empty user input; context-only steers still reject as empty input. ## Dedupe Strategy `AdditionalContextStore` lives on session state and stores the latest complete additional-context map. Each `turn/start` or non-empty `turn/steer` treats its `additionalContext` as the current complete set of values. Entries are injected only when the key is new or the exact entry for that key changed, including `value` or `kind`. After merging, the store is replaced with the provided map, so omitted keys are removed from the retained set and can be injected again later if reintroduced. Omitting `additionalContext`, passing `null`, or passing an empty object resets the store to empty and injects nothing. ## What Changed - Threads experimental v2 `additionalContext` through app-server into core turn start and steer handling. - Adds separate contextual fragment types for untrusted user-role context and application developer-role context. - Uses pending response input items so additional context can be combined with normal user input without treating it as prompt text. - Adds integration coverage for start/steer flow, role routing, dedupe/reset behavior, deletion/re-add behavior, hook-blocked input behavior, empty context-only steer rejection, external-fragment marker matching, and truncation. --- .../analytics/src/analytics_client_tests.rs | 1 + codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/ClientRequest.json | 22 + .../codex_app_server_protocol.schemas.json | 22 + .../codex_app_server_protocol.v2.schemas.json | 22 + .../schema/json/v2/TurnStartParams.json | 22 + .../schema/json/v2/TurnSteerParams.json | 22 + .../typescript/v2/AdditionalContextEntry.ts | 6 + .../typescript/v2/AdditionalContextKind.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/tests.rs | 1 + .../src/protocol/v2/turn.rs | 25 + .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 3 + .../src/request_processors/turn_processor.rs | 29 + .../tests/suite/v2/client_metadata.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 80 +++ .../app-server/tests/suite/v2/turn_steer.rs | 123 ++++ codex-rs/core/src/agent/control_tests.rs | 3 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_thread.rs | 10 +- .../src/context/contextual_user_message.rs | 4 + codex-rs/core/src/context/fragment.rs | 14 + codex-rs/core/src/context/fragments.rs | 92 +++ codex-rs/core/src/context/goal_context.rs | 13 - codex-rs/core/src/context/mod.rs | 3 + codex-rs/core/src/goals.rs | 1 + codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/session/handlers.rs | 16 +- codex-rs/core/src/session/input_queue.rs | 6 +- codex-rs/core/src/session/mod.rs | 26 +- codex-rs/core/src/session/review.rs | 4 +- codex-rs/core/src/session/tests.rs | 62 +- codex-rs/core/src/session/turn.rs | 8 +- codex-rs/core/src/state/additional_context.rs | 37 ++ codex-rs/core/src/state/mod.rs | 2 + codex-rs/core/src/state/session.rs | 3 + codex-rs/core/src/tasks/mod.rs | 11 +- .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/suite/abort_tasks.rs | 5 + .../core/tests/suite/additional_context.rs | 581 ++++++++++++++++++ codex-rs/core/tests/suite/apply_patch_cli.rs | 1 + codex-rs/core/tests/suite/approvals.rs | 2 + codex-rs/core/tests/suite/client.rs | 34 + .../core/tests/suite/client_websockets.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 1 + .../tests/suite/collaboration_instructions.rs | 17 + codex-rs/core/tests/suite/compact.rs | 32 + codex-rs/core/tests/suite/compact_remote.rs | 55 ++ .../core/tests/suite/compact_remote_parity.rs | 1 + .../core/tests/suite/compact_resume_fork.rs | 1 + codex-rs/core/tests/suite/exec_policy.rs | 2 + codex-rs/core/tests/suite/fork_thread.rs | 2 + codex-rs/core/tests/suite/hooks.rs | 2 + codex-rs/core/tests/suite/image_rollout.rs | 2 + codex-rs/core/tests/suite/items.rs | 10 + codex-rs/core/tests/suite/json_result.rs | 1 + .../core/tests/suite/mcp_turn_metadata.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_switching.rs | 1 + .../core/tests/suite/model_visible_layout.rs | 8 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + .../core/tests/suite/models_etag_responses.rs | 1 + codex-rs/core/tests/suite/otel.rs | 22 + codex-rs/core/tests/suite/pending_input.rs | 5 + .../core/tests/suite/permissions_messages.rs | 15 + codex-rs/core/tests/suite/personality.rs | 1 + codex-rs/core/tests/suite/plugins.rs | 3 + codex-rs/core/tests/suite/prompt_caching.rs | 15 + codex-rs/core/tests/suite/quota_exceeded.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 3 + codex-rs/core/tests/suite/remote_env.rs | 1 + codex-rs/core/tests/suite/remote_models.rs | 7 + .../core/tests/suite/request_compression.rs | 2 + .../core/tests/suite/request_permissions.rs | 1 + .../tests/suite/request_permissions_tool.rs | 1 + .../core/tests/suite/request_user_input.rs | 3 + .../suite/responses_api_proxy_headers.rs | 1 + codex-rs/core/tests/suite/resume.rs | 7 + codex-rs/core/tests/suite/review.rs | 1 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/safety_check_downgrade.rs | 1 + codex-rs/core/tests/suite/search_tool.rs | 4 + codex-rs/core/tests/suite/shell_snapshot.rs | 4 + codex-rs/core/tests/suite/skill_approval.rs | 1 + codex-rs/core/tests/suite/skills.rs | 1 + ...text__additional_context_simple_input.snap | 11 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 2 + .../core/tests/suite/stream_no_completed.rs | 1 + .../tests/suite/subagent_notifications.rs | 1 + codex-rs/core/tests/suite/tool_harness.rs | 5 + codex-rs/core/tests/suite/tool_parallelism.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 7 + .../core/tests/suite/user_notification.rs | 1 + codex-rs/core/tests/suite/user_shell_cmd.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + .../core/tests/suite/websocket_fallback.rs | 1 + codex-rs/core/tests/suite/window_headers.rs | 1 + codex-rs/exec/src/lib.rs | 1 + codex-rs/ext/goal/src/steering.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 2 + codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/protocol/src/protocol.rs | 24 + codex-rs/thread-manager-sample/src/main.rs | 1 + codex-rs/tui/src/app_server_session.rs | 2 + 108 files changed, 1583 insertions(+), 57 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts create mode 100644 codex-rs/core/src/context/fragments.rs create mode 100644 codex-rs/core/src/state/additional_context.rs create mode 100644 codex-rs/core/tests/suite/additional_context.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__additional_context__additional_context_simple_input.snap diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index cabea80886..26daadf5f5 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -401,6 +401,7 @@ fn sample_turn_steer_request( }, ], responsesapi_client_metadata: None, + additional_context: None, }, } } diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index bfb224d899..c6a928afa3 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -103,6 +103,7 @@ fn sample_turn_steer_request() -> ClientRequest { expected_turn_id: "turn-1".to_string(), input: Vec::new(), responsesapi_client_metadata: None, + additional_context: None, }, } } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d2bec3d2ed..552defad1b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -12,6 +12,28 @@ ], "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4c6d5a6eee..ab19f3e770 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5768,6 +5768,28 @@ ], "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/v2/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e74323745e..b12b0eedd8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -164,6 +164,28 @@ ], "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index ecea8f1997..ffc130dcc9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -5,6 +5,28 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index 1b7cfbf400..ef05b27673 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -1,6 +1,28 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts new file mode 100644 index 0000000000..8d959269d4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdditionalContextKind } from "./AdditionalContextKind"; + +export type AdditionalContextEntry = { value: string, kind: AdditionalContextKind, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts new file mode 100644 index 0000000000..cd60bd7a8d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AdditionalContextKind = "untrusted" | "application"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 5b4f2ed283..2a4220cb60 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -7,6 +7,8 @@ export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; +export type { AdditionalContextEntry } from "./AdditionalContextEntry"; +export type { AdditionalContextKind } from "./AdditionalContextKind"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 8f6824f397..c2b4f24b51 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3428,6 +3428,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { thread_id: "thread_123".to_string(), input: vec![], responsesapi_client_metadata: None, + additional_context: None, environments: None, cwd: None, runtime_workspace_roots: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 50660af1d7..ab5e59a464 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -41,6 +41,23 @@ pub struct TurnEnvironmentParams { pub cwd: AbsolutePathBuf, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum AdditionalContextKind { + Untrusted, + Application, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalContextEntry { + pub value: String, + pub kind: AdditionalContextKind, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -53,6 +70,10 @@ pub struct TurnStartParams { #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional client-provided context fragments keyed by an opaque source identifier. + #[experimental("turn/start.additionalContext")] + #[ts(optional = nullable)] + pub additional_context: Option>, /// Optional turn-scoped environments. /// /// Omitted uses the thread sticky environments. Empty disables @@ -141,6 +162,10 @@ pub struct TurnSteerParams { #[experimental("turn/steer.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional client-provided context fragments keyed by an opaque source identifier. + #[experimental("turn/steer.additionalContext")] + #[ts(optional = nullable)] + pub additional_context: Option>, /// Required active turn id precondition. The request fails when it does not /// match the currently active turn. pub expected_turn_id: String, diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index a9625d3086..97c497b877 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -658,6 +658,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, cwd: None, runtime_workspace_roots: None, approval_policy: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index d0958e80ab..66ab8e3a1d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -25,6 +25,8 @@ use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; +use codex_app_server_protocol::AdditionalContextEntry; +use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; @@ -421,6 +423,7 @@ use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::io::Error as IoError; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index ff2825be67..975ff22d07 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,4 +1,6 @@ use super::*; +use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; +use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; #[derive(Clone)] pub(crate) struct TurnRequestProcessor { @@ -30,6 +32,29 @@ fn resolve_runtime_workspace_roots( resolved_roots } +fn map_additional_context( + additional_context: Option>, +) -> BTreeMap { + additional_context + .unwrap_or_default() + .into_iter() + .map(|(key, entry)| { + ( + key, + CoreAdditionalContextEntry { + value: entry.value, + kind: match entry.kind { + AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted, + AdditionalContextKind::Application => { + CoreAdditionalContextKind::Application + } + }, + }, + ) + }) + .collect() +} + struct ThreadSettingsBuildParams { method: &'static str, cwd: Option, @@ -391,6 +416,7 @@ impl TurnRequestProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); let thread_settings = self .build_thread_settings_overrides( @@ -419,6 +445,7 @@ impl TurnRequestProcessor { environments: environment_selections, final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, + additional_context, thread_settings, }; let turn_id = self @@ -746,10 +773,12 @@ impl TurnRequestProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let additional_context = map_additional_context(params.additional_context); let turn_id = thread .steer_input( mapped_items, + additional_context, Some(¶ms.expected_turn_id), params.responsesapi_client_metadata, ) diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 8d68888e7e..80f41d7b11 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -179,6 +179,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() text_elements: Vec::new(), }], responsesapi_client_metadata: Some(steer_metadata.clone()), + additional_context: None, expected_turn_id: turn_id.clone(), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 236d57a0ee..88596b4639 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -16,6 +16,8 @@ use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use app_test_support::write_models_cache; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::AdditionalContextEntry; +use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::ByteRange; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::CollabAgentStatus; @@ -301,6 +303,82 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "inspect tab".to_string(), + text_elements: Vec::new(), + }], + additional_context: Some(HashMap::from([( + "custom_source".to_string(), + AdditionalContextEntry { + value: "source value".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + )])), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let request = requests + .iter() + .find(|request| request.url.path().ends_with("/responses")) + .context("expected model request")?; + let body = request + .body_json::() + .context("request body should be JSON")?; + assert!( + body.to_string() + .contains("source value") + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_sends_originator_header() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -2069,6 +2147,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, cwd: Some(first_cwd.clone()), runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), @@ -2111,6 +2190,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, cwd: Some(second_cwd.clone()), runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a92b2db528..66ac33972b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -1,5 +1,6 @@ #![cfg(unix)] +use anyhow::Context; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_sequence; @@ -9,6 +10,8 @@ use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::AdditionalContextEntry; +use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -21,6 +24,8 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use serde_json::Value; +use std::collections::HashMap; use tempfile::TempDir; use tokio::time::timeout; @@ -67,6 +72,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: "turn-does-not-exist".to_string(), }) .await?; @@ -176,6 +182,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: turn.id.clone(), }) .await?; @@ -284,6 +291,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: turn.id.clone(), }) .await?; @@ -312,3 +320,118 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn turn_steer_rejects_context_only_input_without_merging_context() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_shell_command_sse_response( + vec!["sleep".to_string(), "1".to_string()], + Some(&working_directory), + Some(10_000), + "call_sleep", + )?, + app_test_support::create_final_assistant_message_sse_response("Done")?, + ]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, &codex_home).await?; + + let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let additional_context = Some(HashMap::from([( + "browser_info".to_string(), + AdditionalContextEntry { + value: "tab one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + )])); + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + input: Vec::new(), + responsesapi_client_metadata: None, + additional_context, + expected_turn_id: turn.id, + }) + .await?; + let steer_error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(steer_req)), + ) + .await??; + assert_eq!(steer_error.error.code, -32600); + assert_eq!(steer_error.error.message, "input must not be empty"); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let response_requests = requests + .iter() + .filter(|request| request.url.path().ends_with("/responses")) + .collect::>(); + assert_eq!(response_requests.len(), 2); + let body = response_requests[1] + .body_json::() + .context("request body should be JSON")?; + assert!( + !body + .to_string() + .contains("tab one") + ); + + Ok(()) +} diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index ed39df1548..8209272f10 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -444,6 +444,7 @@ async fn send_input_submits_user_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }, ); @@ -598,6 +599,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }, ); @@ -770,6 +772,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }, ); diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 4ed6b2368c..b27cf745c9 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -192,6 +192,7 @@ pub(crate) async fn run_codex_thread_one_shot( items: input, final_output_json_schema, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 4b77efdac9..1113a12c8a 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -21,6 +21,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::Op; @@ -41,6 +42,7 @@ use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::ReadResourceRequestParams; +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -236,11 +238,17 @@ impl CodexThread { pub async fn steer_input( &self, input: Vec, + additional_context: BTreeMap, expected_turn_id: Option<&str>, responsesapi_client_metadata: Option>, ) -> Result { self.codex - .steer_input(input, expected_turn_id, responsesapi_client_metadata) + .steer_input( + input, + additional_context, + expected_turn_id, + responsesapi_client_metadata, + ) .await } diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index c7ada2317f..940a938fce 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -2,6 +2,7 @@ use codex_protocol::items::HookPromptItem; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; +use super::AdditionalContextUserFragment; use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; @@ -19,6 +20,8 @@ static USER_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static SKILL_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static USER_SHELL_COMMAND_REGISTRATION: FragmentRegistrationProxy = @@ -42,6 +45,7 @@ static LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION: FragmentRegistrationProxy< static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_INSTRUCTIONS_REGISTRATION, &ENVIRONMENT_CONTEXT_REGISTRATION, + &ADDITIONAL_CONTEXT_REGISTRATION, &SKILL_INSTRUCTIONS_REGISTRATION, &USER_SHELL_COMMAND_REGISTRATION, &TURN_ABORTED_REGISTRATION, diff --git a/codex-rs/core/src/context/fragment.rs b/codex-rs/core/src/context/fragment.rs index 4a9e284d12..5c500fe8ef 100644 --- a/codex-rs/core/src/context/fragment.rs +++ b/codex-rs/core/src/context/fragment.rs @@ -1,4 +1,5 @@ use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use std::marker::PhantomData; @@ -81,6 +82,19 @@ pub trait ContextualUserFragment { phase: None, } } + + fn into_response_input_item(self) -> ResponseInputItem + where + Self: Sized, + { + ResponseInputItem::Message { + role: Self::role().to_string(), + content: vec![ContentItem::InputText { + text: self.render(), + }], + phase: None, + } + } } fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool { diff --git a/codex-rs/core/src/context/fragments.rs b/codex-rs/core/src/context/fragments.rs new file mode 100644 index 0000000000..e23f96f59f --- /dev/null +++ b/codex-rs/core/src/context/fragments.rs @@ -0,0 +1,92 @@ +use super::ContextualUserFragment; +use codex_utils_string::truncate_middle_with_token_budget; + +const MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS: usize = 1_000; +const ADDITIONAL_CONTEXT_END_MARKER_SUFFIX: &str = ">"; +const ADDITIONAL_CONTEXT_START_MARKER_PREFIX: &str = " Self { + Self { key, value } + } +} + +impl ContextualUserFragment for AdditionalContextUserFragment { + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + ADDITIONAL_CONTEXT_START_MARKER_PREFIX, + ADDITIONAL_CONTEXT_END_MARKER_SUFFIX, + ) + } + + fn matches_text(text: &str) -> bool { + let trimmed = text.trim(); + let Some(rest) = trimmed.strip_prefix(ADDITIONAL_CONTEXT_START_MARKER_PREFIX) else { + return false; + }; + let Some((key, value_and_close)) = rest.split_once(ADDITIONAL_CONTEXT_END_MARKER_SUFFIX) + else { + return false; + }; + + value_and_close.ends_with(&format!("")) + } + + fn body(&self) -> String { + additional_context_body(&self.key, &self.value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AdditionalContextDeveloperFragment { + key: String, + value: String, +} + +impl AdditionalContextDeveloperFragment { + pub(crate) fn new(key: String, value: String) -> Self { + Self { key, value } + } +} + +impl ContextualUserFragment for AdditionalContextDeveloperFragment { + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + additional_context_developer_body(&self.key, &self.value) + } +} + +fn additional_context_body(key: &str, value: &str) -> String { + let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0; + format!("{key}>{value} String { + let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0; + format!("<{key}>{value}") +} diff --git a/codex-rs/core/src/context/goal_context.rs b/codex-rs/core/src/context/goal_context.rs index 13892bf466..ad4c47ef06 100644 --- a/codex-rs/core/src/context/goal_context.rs +++ b/codex-rs/core/src/context/goal_context.rs @@ -1,8 +1,6 @@ //! Hidden user-context fragment for runtime-owned goal steering prompts. use super::ContextualUserFragment; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseInputItem; /// Hidden runtime-owned goal steering context injected into model input. #[derive(Debug, Clone, PartialEq)] @@ -17,17 +15,6 @@ impl GoalContext { prompt: prompt.into(), } } - - /// Converts the registered fragment into an active-turn injectable item. - pub fn into_response_input_item(self) -> ResponseInputItem { - ResponseInputItem::Message { - role: ::role().to_string(), - content: vec![ContentItem::InputText { - text: self.render(), - }], - phase: None, - } - } } impl ContextualUserFragment for GoalContext { diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 99575f6309..6d88df87e2 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -8,6 +8,7 @@ mod collaboration_mode_instructions; mod contextual_user_message; mod environment_context; mod fragment; +mod fragments; mod goal_context; mod guardian_followup_review_reminder; mod hook_additional_context; @@ -40,6 +41,8 @@ pub(crate) use environment_context::EnvironmentContext; pub use fragment::ContextualUserFragment; pub(crate) use fragment::FragmentRegistration; pub(crate) use fragment::FragmentRegistrationProxy; +pub(crate) use fragments::AdditionalContextDeveloperFragment; +pub(crate) use fragments::AdditionalContextUserFragment; pub use goal_context::GoalContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 75b84093e7..6df681d4b0 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -5,6 +5,7 @@ //! events, and owns helper hooks used by goal lifecycle behavior. use crate::StateDbHandle; +use crate::context::ContextualUserFragment; use crate::context::GoalContext; use crate::session::TurnInput; use crate::session::session::Session; diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 15d6c142ca..e56d5ce170 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -714,6 +714,7 @@ async fn run_review_on_session( environments: None, final_output_json_schema: Some(params.schema.clone()), responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { #[allow(deprecated)] cwd: Some(params.parent_turn.cwd.to_path_buf()), diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index b1e36b0347..cf46ecba9a 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -10,6 +10,7 @@ use tracing::debug_span; use tracing::info_span; use crate::session::SteerInputError; +use crate::session::TurnInput; use crate::session::session::Session; use crate::session::session::SessionSettingsUpdate; @@ -194,6 +195,7 @@ pub(super) async fn user_input_or_turn_inner( environments, final_output_json_schema, responsesapi_client_metadata, + additional_context, thread_settings, } = op else { @@ -224,6 +226,7 @@ pub(super) async fn user_input_or_turn_inner( let accepted_items = match sess .steer_input( items.clone(), + additional_context.clone(), /*expected_turn_id*/ None, responsesapi_client_metadata.clone(), ) @@ -246,9 +249,20 @@ pub(super) async fn user_input_or_turn_inner( ) .await; let accepted_items = items.clone(); + let additional_context_input = { + let mut state = sess.state.lock().await; + state.additional_context.merge(additional_context) + }; + let mut task_input = additional_context_input + .into_iter() + .map(TurnInput::ResponseInputItem) + .collect::>(); + if !items.is_empty() { + task_input.push(TurnInput::UserInput(items)); + } sess.spawn_task( Arc::clone(¤t_context), - items, + task_input, crate::tasks::RegularTask::new(), ) .await; diff --git a/codex-rs/core/src/session/input_queue.rs b/codex-rs/core/src/session/input_queue.rs index 98e912c7e5..620c410eb8 100644 --- a/codex-rs/core/src/session/input_queue.rs +++ b/codex-rs/core/src/session/input_queue.rs @@ -156,13 +156,13 @@ impl InputQueue { .accept_mailbox_delivery_for_current_turn(); } - pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state( + pub(super) async fn extend_pending_input_and_accept_mailbox_delivery_for_turn_state( &self, turn_state: &Mutex, - input: TurnInput, + input: Vec, ) { let mut turn_state = turn_state.lock().await; - turn_state.pending_input.items.push(input); + turn_state.pending_input.items.extend(input); turn_state.accept_mailbox_delivery_for_current_turn(); } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 67cc5484fc..1876cbf980 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Debug; @@ -97,6 +98,7 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; use codex_protocol::protocol::InterAgentCommunication; @@ -742,11 +744,17 @@ impl Codex { pub async fn steer_input( &self, input: Vec, + additional_context: BTreeMap, expected_turn_id: Option<&str>, responsesapi_client_metadata: Option>, ) -> Result { self.session - .steer_input(input, expected_turn_id, responsesapi_client_metadata) + .steer_input( + input, + additional_context, + expected_turn_id, + responsesapi_client_metadata, + ) .await } @@ -1086,6 +1094,7 @@ impl Session { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }, /*mirror_user_text_to_realtime*/ None, @@ -3152,6 +3161,7 @@ impl Session { pub async fn steer_input( &self, input: Vec, + additional_context: BTreeMap, expected_turn_id: Option<&str>, responsesapi_client_metadata: Option>, ) -> Result { @@ -3192,6 +3202,11 @@ impl Session { return Err(SteerInputError::EmptyInput); } + let additional_context_input = { + let mut state = self.state.lock().await; + state.additional_context.merge(additional_context) + }; + if let Some(responsesapi_client_metadata) = responsesapi_client_metadata { active_task .turn_context @@ -3199,10 +3214,15 @@ impl Session { .set_responsesapi_client_metadata(responsesapi_client_metadata); } + let mut pending_input = additional_context_input + .into_iter() + .map(TurnInput::ResponseInputItem) + .collect::>(); + pending_input.push(TurnInput::UserInput(input)); self.input_queue - .push_pending_input_and_accept_mailbox_delivery_for_turn_state( + .extend_pending_input_and_accept_mailbox_delivery_for_turn_state( active_turn.turn_state.as_ref(), - TurnInput::UserInput(input), + pending_input, ) .await; Ok(active_turn_id.clone()) diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 059dc8abbd..32122873cb 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -132,11 +132,11 @@ pub(super) async fn spawn_review_thread( }; // Seed the child task with the review prompt as the initial user message. - let input: Vec = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: review_prompt, // Review prompt is synthesized; no UI element ranges to preserve. text_elements: Vec::new(), - }]; + }])]; let tc = Arc::new(review_turn_context); tc.turn_metadata_state.spawn_git_enrichment_task(); // TODO(ccunningham): Review turns currently rely on `spawn_task` for TurnComplete but do not diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 428e762244..0638168422 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2287,6 +2287,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2333,6 +2334,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), collaboration_mode: Some(collaboration_mode), @@ -5430,6 +5432,7 @@ fn op_kind_for_input_and_context_ops() { items: vec![], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), } .kind(), @@ -5460,6 +5463,7 @@ async fn user_turn_updates_approvals_reviewer() { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(config.cwd.to_path_buf()), approval_policy: Some(config.permissions.approval_policy.value()), @@ -5781,10 +5785,10 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() { async { sess.spawn_task( Arc::clone(&tc), - vec![UserInput::Text { + vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }], + }])], TraceCaptureTask { captured_trace: Arc::clone(&captured_trace), }, @@ -6592,10 +6596,10 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task let (sess, tc, _rx) = make_session_and_context_with_rx().await; sess.set_previous_turn_settings(/*previous_turn_settings*/ None) .await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), @@ -7861,10 +7865,10 @@ impl SessionTask for GuardianDeniedApprovalTask { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_auto_review_interrupts_after_three_consecutive_denials() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "trigger guardian denials".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task(Arc::clone(&tc), input, GuardianDeniedApprovalTask) .await; @@ -7892,10 +7896,10 @@ async fn guardian_auto_review_interrupts_after_three_consecutive_denials() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "keep turn active for helper reviews".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), input, @@ -7952,10 +7956,10 @@ async fn guardian_helper_review_interrupts_after_three_consecutive_denials() { #[test_log::test] async fn abort_regular_task_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), input, @@ -7985,10 +7989,10 @@ async fn abort_regular_task_emits_turn_aborted_only() { #[tokio::test] async fn abort_gracefully_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), input, @@ -8018,10 +8022,10 @@ async fn abort_gracefully_emits_turn_aborted_only() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), input, @@ -8044,6 +8048,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() }]; sess.steer_input( pending_user_input.clone(), + /*additional_context*/ Default::default(), Some(&tc.sub_id), /*responsesapi_client_metadata*/ None, ) @@ -8140,7 +8145,10 @@ async fn steer_input_requires_active_turn() { let err = sess .steer_input( - input, /*expected_turn_id*/ None, /*responsesapi_client_metadata*/ None, + input, + /*additional_context*/ Default::default(), + /*expected_turn_id*/ None, + /*responsesapi_client_metadata*/ None, ) .await .expect_err("steering without active turn should fail"); @@ -8151,10 +8159,10 @@ async fn steer_input_requires_active_turn() { #[tokio::test] async fn steer_input_enforces_expected_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), input, @@ -8172,6 +8180,7 @@ async fn steer_input_enforces_expected_turn_id() { let err = sess .steer_input( steer_input, + /*additional_context*/ Default::default(), Some("different-turn-id"), /*responsesapi_client_metadata*/ None, ) @@ -8196,10 +8205,10 @@ async fn steer_input_rejects_non_regular_turns() { (TaskKind::Compact, NonSteerableTurnKind::Compact), ] { let (sess, _tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await; sess.spawn_task( turn_context, @@ -8218,6 +8227,7 @@ async fn steer_input_rejects_non_regular_turns() { let err = sess .steer_input( steer_input, + /*additional_context*/ Default::default(), /*expected_turn_id*/ None, /*responsesapi_client_metadata*/ None, ) @@ -8233,10 +8243,10 @@ async fn steer_input_rejects_non_regular_turns() { #[tokio::test] async fn steer_input_returns_active_turn_id() { let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task( Arc::clone(&tc), input, @@ -8254,6 +8264,7 @@ async fn steer_input_returns_active_turn_id() { let turn_id = sess .steer_input( steer_input, + /*additional_context*/ Default::default(), Some(&tc.sub_id), /*responsesapi_client_metadata*/ None, ) @@ -8478,6 +8489,7 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -8583,6 +8595,7 @@ async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -9130,6 +9143,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -9296,6 +9310,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() { text: "follow up".to_string(), text_elements: Vec::new(), }], + /*additional_context*/ Default::default(), Some(&tc.sub_id), /*responsesapi_client_metadata*/ None, ) @@ -9345,6 +9360,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() { text: "follow up".to_string(), text_elements: Vec::new(), }], + /*additional_context*/ Default::default(), Some(&tc.sub_id), /*responsesapi_client_metadata*/ None, ) @@ -9426,10 +9442,10 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx().await; - let input = vec![UserInput::Text { + let input = vec![TurnInput::UserInput(vec![UserInput::Text { text: "start review".to_string(), text_elements: Vec::new(), - }]; + }])]; sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new()) .await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index a9e4c8463f..82fa35f108 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -407,14 +407,16 @@ async fn run_hooks_and_record_inputs( input: &[TurnInput], ) -> bool { let mut blocked_input = false; - let mut accepted_input = false; + let mut accepted_user_input = false; for input_item in input { let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await; if hook_outcome.should_stop { blocked_input = true; record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await; } else { - accepted_input = true; + if matches!(input_item, TurnInput::UserInput(items) if !items.is_empty()) { + accepted_user_input = true; + } record_pending_input( sess, turn_context, @@ -424,7 +426,7 @@ async fn run_hooks_and_record_inputs( .await; } } - blocked_input && !accepted_input + blocked_input && !accepted_user_input } #[expect( diff --git a/codex-rs/core/src/state/additional_context.rs b/codex-rs/core/src/state/additional_context.rs new file mode 100644 index 0000000000..eb5727ac23 --- /dev/null +++ b/codex-rs/core/src/state/additional_context.rs @@ -0,0 +1,37 @@ +use std::collections::BTreeMap; + +use crate::context::AdditionalContextDeveloperFragment; +use crate::context::AdditionalContextUserFragment; +use crate::context::ContextualUserFragment; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::AdditionalContextEntry; +use codex_protocol::protocol::AdditionalContextKind; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct AdditionalContextStore { + values: BTreeMap, +} + +impl AdditionalContextStore { + pub(crate) fn merge( + &mut self, + values: BTreeMap, + ) -> Vec { + let fragments = values + .iter() + .filter(|(key, value)| self.values.get(*key) != Some(*value)) + .map(|(key, entry)| match entry.kind { + AdditionalContextKind::Untrusted => { + AdditionalContextUserFragment::new(key.clone(), entry.value.clone()) + .into_response_input_item() + } + AdditionalContextKind::Application => { + AdditionalContextDeveloperFragment::new(key.clone(), entry.value.clone()) + .into_response_input_item() + } + }) + .collect(); + self.values = values; + fragments + } +} diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 3122ec5f25..c693221bed 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -1,8 +1,10 @@ +mod additional_context; mod auto_compact_window; mod service; mod session; mod turn; +pub(crate) use additional_context::AdditionalContextStore; pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index baeaddbbf9..ac59cb32d5 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -6,6 +6,7 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashSet; use std::collections::VecDeque; +use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; use super::auto_compact_window::AutoCompactWindowSnapshot; use crate::context_manager::ContextManager; @@ -25,6 +26,7 @@ pub(crate) struct SessionState { pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, pub(crate) mcp_dependency_prompted: HashSet, + pub(crate) additional_context: AdditionalContextStore, /// Settings used by the latest regular user turn, used for turn-to-turn /// model/realtime handling on subsequent regular turns (including full-context /// reinjection after resume or `/compact`). @@ -49,6 +51,7 @@ impl SessionState { latest_rate_limits: None, server_reasoning_included: false, mcp_dependency_prompted: HashSet::new(), + additional_context: AdditionalContextStore::default(), previous_turn_settings: None, auto_compact_window: AutoCompactWindow::new(), startup_prewarm: None, diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index e6e7663e50..aa161c1274 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -50,7 +50,6 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::WarningEvent; -use codex_protocol::user_input::UserInput; use codex_features::Feature; use codex_protocol::models::ContentItem; @@ -303,7 +302,7 @@ impl Session { pub async fn spawn_task( self: &Arc, turn_context: Arc, - input: Vec, + input: Vec, task: T, ) { self.abort_all_tasks(TurnAbortReason::Replaced).await; @@ -314,7 +313,7 @@ impl Session { pub(crate) async fn start_task( self: &Arc, turn_context: Arc, - input: Vec, + input: Vec, task: T, ) { let task: Arc = Arc::new(task); @@ -382,11 +381,7 @@ impl Session { )); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); - let task_input = if input.is_empty() { - Vec::new() - } else { - vec![TurnInput::UserInput(input)] - }; + let task_input = input; let task_cancellation_token = cancellation_token.child_token(); // Task-owned turn spans keep a core-owned span open for the // full task lifecycle after the submission dispatch span ends. diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index a582805ed2..68b19adea9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2592,6 +2592,7 @@ async fn send_input_accepts_structured_items() { ], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }; let captured = manager diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index a398758291..73e9e0ece8 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -767,6 +767,7 @@ impl TestCodex { environments, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(self.config.cwd.to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index c41502a9dc..82bb8c8798 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -53,6 +53,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -110,6 +111,7 @@ async fn interrupt_tool_records_history_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -131,6 +133,7 @@ async fn interrupt_tool_records_history_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -214,6 +217,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -235,6 +239,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/additional_context.rs b/codex-rs/core/tests/suite/additional_context.rs new file mode 100644 index 0000000000..f9bfdb966b --- /dev/null +++ b/codex-rs/core/tests/suite/additional_context.rs @@ -0,0 +1,581 @@ +use anyhow::Result; +use codex_protocol::items::TurnItem; +use codex_protocol::protocol::AdditionalContextEntry; +use codex_protocol::protocol::AdditionalContextKind; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::ContextSnapshotRenderMode; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build(&server) + .await?; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "inspect the active tab".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::from([ + ( + "browser_info".to_string(), + AdditionalContextEntry { + value: "tab one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ( + "automation_info".to_string(), + AdditionalContextEntry { + value: "run one".to_string(), + kind: AdditionalContextKind::Application, + }, + ), + ]), + thread_settings: Default::default(), + }) + .await?; + + let user_item = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::UserMessage(item), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; + assert_eq!( + user_item.content, + vec![UserInput::Text { + text: "inspect the active tab".to_string(), + text_elements: Vec::new(), + }] + ); + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + let request = request.single_request(); + insta::assert_snapshot!( + "additional_context_simple_input", + context_snapshot::format_labeled_requests_snapshot( + "additional context is inserted before the user turn input.", + &[("Request", &request)], + &ContextSnapshotOptions::default() + .strip_capability_instructions() + .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 160 }), + ) + ); + let developer_context_texts = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(); + assert_eq!( + developer_context_texts, + vec!["run one"] + ); + assert_eq!( + request.message_input_texts("user"), + vec![ + "tab one", + "inspect the active tab", + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_context_like_user_text_remains_a_user_message_item() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build(&server) + .await?; + let user_input = UserInput::Text { + text: "".to_string(), + text_elements: Vec::new(), + }; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![user_input.clone()], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::new(), + thread_settings: Default::default(), + }) + .await?; + + let user_item = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::UserMessage(item), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; + assert_eq!(user_item.content, vec![user_input]); + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + let request = request.single_request(); + assert_eq!(request.message_input_texts("user"), vec![""]); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn additional_context_trust_controls_message_role() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build(&server) + .await?; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "inspect context".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::from([ + ( + "browser_info".to_string(), + AdditionalContextEntry { + value: "tab one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ( + "automation_info".to_string(), + AdditionalContextEntry { + value: "run one".to_string(), + kind: AdditionalContextKind::Application, + }, + ), + ]), + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + let request = request.single_request(); + let developer_context_texts = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(); + assert_eq!( + developer_context_texts, + vec!["run one"] + ); + assert_eq!( + request.message_input_texts("user"), + vec![ + "tab one", + "inspect context", + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn additional_context_is_deduplicated_between_turns_while_retained() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let first_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let second_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build(&server) + .await?; + let additional_context = BTreeMap::from([( + "browser_info".to_string(), + AdditionalContextEntry { + value: "same tab".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + )]); + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: additional_context.clone(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context, + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + assert_eq!( + first_request.single_request().message_input_texts("user"), + vec![ + "same tab", + "first turn", + ] + ); + assert_eq!( + second_request.single_request().message_input_texts("user"), + vec![ + "same tab", + "first turn", + "second turn", + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn additional_context_removes_one_value_while_adding_another() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let first_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let second_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + let third_request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]), + ) + .await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build(&server) + .await?; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::from([ + ( + "automation_info".to_string(), + AdditionalContextEntry { + value: "run one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ( + "browser_info".to_string(), + AdditionalContextEntry { + value: "tab one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ]), + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::from([ + ( + "automation_info".to_string(), + AdditionalContextEntry { + value: "run one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ( + "terminal_info".to_string(), + AdditionalContextEntry { + value: "pty one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ]), + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "third turn".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::from([ + ( + "automation_info".to_string(), + AdditionalContextEntry { + value: "run one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ( + "browser_info".to_string(), + AdditionalContextEntry { + value: "tab one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ( + "terminal_info".to_string(), + AdditionalContextEntry { + value: "pty one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ]), + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + assert_eq!( + first_request.single_request().message_input_texts("user"), + vec![ + "run one", + "tab one", + "first turn", + ] + ); + assert_eq!( + second_request.single_request().message_input_texts("user"), + vec![ + "run one", + "tab one", + "first turn", + "pty one", + "second turn", + ] + ); + assert_eq!( + third_request.single_request().message_input_texts("user"), + vec![ + "run one", + "tab one", + "first turn", + "pty one", + "second turn", + "tab one", + "third turn", + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn additional_context_values_are_truncated_before_model_input() -> Result<()> { + skip_if_no_network!(Ok(())); + + const MAX_EXPECTED_EXTERNAL_CONTEXT_TEXT_BYTES: usize = 5 * 1024; + + let server = start_mock_server().await; + let request = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| config.include_environment_context = false) + .build(&server) + .await?; + let long_browser_value = format!("browser-head-{}browser-tail", "b".repeat(40_000)); + let long_automation_value = format!("automation-head-{}automation-tail", "a".repeat(40_000)); + let untruncated_browser_fragment = + format!("{long_browser_value}"); + let untruncated_automation_fragment = + format!("{long_automation_value}"); + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "summarize context".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: BTreeMap::from([ + ( + "automation_info".to_string(), + AdditionalContextEntry { + value: long_automation_value.clone(), + kind: AdditionalContextKind::Application, + }, + ), + ( + "browser_info".to_string(), + AdditionalContextEntry { + value: long_browser_value.clone(), + kind: AdditionalContextKind::Untrusted, + }, + ), + ]), + thread_settings: Default::default(), + }) + .await?; + wait_for_event_match(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)).then_some(()) + }) + .await; + + let request = request.single_request(); + let developer_texts = request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with("")) + .collect::>(); + let [automation_text] = developer_texts.as_slice() else { + panic!("expected application additional context, got {developer_texts:?}"); + }; + assert!(automation_text.starts_with(&format!( + "automation-head-{}", + "a".repeat(1024) + ))); + assert!(automation_text.contains("tokens truncated")); + assert!(automation_text.ends_with("automation-tail")); + assert!(automation_text.len() < untruncated_automation_fragment.len()); + assert!( + automation_text.len() <= MAX_EXPECTED_EXTERNAL_CONTEXT_TEXT_BYTES, + "application additional context was not capped before model input: {} bytes", + automation_text.len() + ); + + let user_texts = request.message_input_texts("user"); + let [external_text, user_text] = user_texts.as_slice() else { + panic!("expected external context plus user input, got {user_texts:?}"); + }; + assert_eq!(user_text, "summarize context"); + assert!(external_text.starts_with(&format!( + "browser-head-{}", + "b".repeat(1024) + ))); + assert!(external_text.contains("tokens truncated")); + assert!(external_text.ends_with("browser-tail")); + assert!(external_text.len() < untruncated_browser_fragment.len()); + assert!( + external_text.len() <= MAX_EXPECTED_EXTERNAL_CONTEXT_TEXT_BYTES, + "untrusted additional context was not capped before model input: {} bytes", + external_text.len() + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index f5a35003f8..da138851f5 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -91,6 +91,7 @@ async fn submit_without_wait_with_turn_permissions( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(harness.cwd().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index fe3fd4ac7d..e16a4a14a2 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -653,6 +653,7 @@ async fn submit_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(approval_policy), @@ -2598,6 +2599,7 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f6731a0ea4..7234c59c0f 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -392,6 +392,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -758,6 +759,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -969,6 +971,7 @@ async fn includes_base_instructions_override_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1026,6 +1029,7 @@ async fn chatgpt_auth_sends_correct_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1150,6 +1154,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1189,6 +1194,7 @@ async fn includes_user_instructions_message_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1277,6 +1283,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1340,6 +1347,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1399,6 +1407,7 @@ async fn omits_apps_guidance_when_configured_off() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1441,6 +1450,7 @@ async fn omits_environment_context_when_configured_off() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1498,6 +1508,7 @@ async fn skills_append_to_developer_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1581,6 +1592,7 @@ async fn skills_use_aliases_in_developer_message_under_budget_pressure() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1642,6 +1654,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1684,6 +1697,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1727,6 +1741,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1778,6 +1793,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(config.cwd.to_path_buf()), approval_policy: Some(config.permissions.approval_policy.value()), @@ -1834,6 +1850,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1899,6 +1916,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(config.cwd.to_path_buf()), approval_policy: Some(config.permissions.approval_policy.value()), @@ -1959,6 +1977,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2018,6 +2037,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2057,6 +2077,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2105,6 +2126,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2152,6 +2174,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2204,6 +2227,7 @@ async fn includes_developer_instructions_message_in_request() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2500,6 +2524,7 @@ async fn token_count_includes_rate_limits_snapshot() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2639,6 +2664,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2716,6 +2742,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2731,6 +2758,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2816,6 +2844,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2927,6 +2956,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3016,6 +3046,7 @@ async fn env_var_overrides_loaded_auth() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3073,6 +3104,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3089,6 +3121,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3105,6 +3138,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 74eb053325..09d0093f99 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1319,6 +1319,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1408,6 +1409,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 3cd5c67249..fb9e7fb420 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2991,6 +2991,7 @@ text( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 0f9bee9431..bbbb0f1aa2 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -79,6 +79,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -133,6 +134,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -170,6 +172,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(test.config.permissions.approval_policy.value()), @@ -220,6 +223,7 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(test.config.permissions.approval_policy.value()), @@ -279,6 +283,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -327,6 +332,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(test.config.permissions.approval_policy.value()), @@ -391,6 +397,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -414,6 +421,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -466,6 +474,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -489,6 +498,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -543,6 +553,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -569,6 +580,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -624,6 +636,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -650,6 +663,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -708,6 +722,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -724,6 +739,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -776,6 +792,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 140b4ec2b1..be6d30c6fa 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -97,6 +97,7 @@ fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: S environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), @@ -408,6 +409,7 @@ async fn summarize_context_three_requests_and_instructions() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -433,6 +435,7 @@ async fn summarize_context_three_requests_and_instructions() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -610,6 +613,7 @@ async fn manual_pre_compact_block_decision_does_not_block_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -683,6 +687,7 @@ async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -753,6 +758,7 @@ async fn manual_compact_uses_custom_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -900,6 +906,7 @@ async fn manual_compact_emits_context_compaction_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1066,6 +1073,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1538,6 +1546,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1554,6 +1563,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1570,6 +1580,7 @@ async fn auto_compact_runs_after_token_limit_hit() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1741,6 +1752,7 @@ async fn auto_compact_emits_context_compaction_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1822,6 +1834,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1837,6 +1850,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1852,6 +1866,7 @@ async fn auto_compact_starts_after_turn_started() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2380,6 +2395,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2395,6 +2411,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2410,6 +2427,7 @@ async fn auto_compact_persists_rollout_entries() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2499,6 +2517,7 @@ async fn manual_compact_retries_after_context_window_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2603,6 +2622,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2698,6 +2718,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2716,6 +2737,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2734,6 +2756,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -2898,6 +2921,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3003,6 +3027,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3434,6 +3459,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3553,6 +3579,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3615,6 +3642,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3647,6 +3675,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3836,6 +3865,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3851,6 +3881,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -3924,6 +3955,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index b614c09d3a..d8779bfb05 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -326,6 +326,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -343,6 +344,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -518,6 +520,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -538,6 +541,7 @@ async fn assert_remote_manual_compact_request_parity( ], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -552,6 +556,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -572,6 +577,7 @@ async fn assert_remote_manual_compact_request_parity( ], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -586,6 +592,7 @@ async fn assert_remote_manual_compact_request_parity( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -754,6 +761,7 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -771,6 +779,7 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -874,6 +883,7 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -891,6 +901,7 @@ async fn remote_compact_v2_retries_failures_with_stream_retry_budget() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -977,6 +988,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -994,6 +1006,7 @@ async fn remote_compact_v2_accepts_additional_output_items_before_compaction() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1085,6 +1098,7 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1157,6 +1171,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1238,6 +1253,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1252,6 +1268,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1370,6 +1387,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1384,6 +1402,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1404,6 +1423,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1504,6 +1524,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1518,6 +1539,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1612,6 +1634,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1629,6 +1652,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1720,6 +1744,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1737,6 +1762,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1808,6 +1834,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1889,6 +1916,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1973,6 +2001,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2116,6 +2145,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2134,6 +2164,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2159,6 +2190,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2255,6 +2287,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2272,6 +2305,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2344,6 +2378,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2358,6 +2393,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2425,6 +2461,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2486,6 +2523,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2502,6 +2540,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2577,6 +2616,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2594,6 +2634,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2677,6 +2718,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2693,6 +2735,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2784,6 +2827,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2814,6 +2858,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2903,6 +2948,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2990,6 +3036,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3012,6 +3059,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3131,6 +3179,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3145,6 +3194,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3230,6 +3280,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3308,6 +3359,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3394,6 +3446,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3411,6 +3464,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3493,6 +3547,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index ccbd427b1f..8979b2cced 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -606,6 +606,7 @@ async fn submit_user_input(codex: &codex_core::CodexThread, items: Vec, text: &str) { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index f1e01c22ce..9e350d2773 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -54,6 +54,7 @@ async fn submit_user_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(approval_policy), @@ -141,6 +142,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 68a167f790..544c530aa0 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -57,6 +57,7 @@ async fn fork_thread_twice_drops_to_first_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -181,6 +182,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 3791746551..a535eb4b8e 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1850,6 +1850,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1869,6 +1870,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 085ca96f37..67a79d603f 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -127,6 +127,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -223,6 +224,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 82e01e2451..a56b2ab95f 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -58,6 +58,7 @@ fn disabled_plan_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), @@ -119,6 +120,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { items: vec![expected_input.clone()], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -178,6 +180,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -239,6 +242,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -301,6 +305,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -381,6 +386,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -468,6 +474,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -524,6 +531,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1108,6 +1116,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1163,6 +1172,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index e81d7aa82a..67275d3146 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -83,6 +83,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { environments: None, final_output_json_schema: Some(serde_json::from_str(SCHEMA)?), responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 16e6cbe37d..897d862162 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -76,6 +76,7 @@ async fn submit_user_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 87772aa279..f47de672bd 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -29,6 +29,7 @@ pub static CODEX_ALIASES_TEMP_DIR: Option = { #[cfg(not(target_os = "windows"))] mod abort_tasks; +mod additional_context; mod agent_jobs; mod agent_websocket; mod agents_md; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 7e77fb79d3..fd237a8d5c 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -48,6 +48,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 17bc0f6703..e02e1ffaef 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -125,6 +125,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(first_turn_cwd), approval_policy: Some(AskForApproval::Never), @@ -160,6 +161,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(preturn_context_diff_cwd), approval_policy: Some(AskForApproval::OnRequest), @@ -249,6 +251,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_one.clone()), approval_policy: Some(AskForApproval::Never), @@ -282,6 +285,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_two), approval_policy: Some(AskForApproval::Never), @@ -365,6 +369,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -406,6 +411,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(resume_override_cwd), approval_policy: Some(AskForApproval::Never), @@ -479,6 +485,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -520,6 +527,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 6d11b47cba..fcd90cbaea 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -100,6 +100,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index 49f34d8644..cbd73fffe9 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -112,6 +112,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 9d2df4e60e..7f5fa260cd 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -122,6 +122,7 @@ async fn responses_api_emits_api_request_event() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -168,6 +169,7 @@ async fn process_sse_emits_tracing_for_output_item() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -214,6 +216,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -261,6 +264,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -328,6 +332,7 @@ async fn process_sse_failed_event_records_response_error_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -393,6 +398,7 @@ async fn process_sse_failed_event_logs_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -445,6 +451,7 @@ async fn process_sse_failed_event_logs_missing_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -506,6 +513,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -561,6 +569,7 @@ async fn process_sse_emits_completed_telemetry() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -640,6 +649,7 @@ async fn turn_and_completed_response_spans_record_token_usage() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -728,6 +738,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -822,6 +833,7 @@ async fn record_responses_sets_span_fields_for_response_events() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -911,6 +923,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -987,6 +1000,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1064,6 +1078,7 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1173,6 +1188,7 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1228,6 +1244,7 @@ async fn handle_shell_command_user_approved_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1298,6 +1315,7 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1368,6 +1386,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1438,6 +1457,7 @@ async fn handle_shell_command_user_denies_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1508,6 +1528,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -1579,6 +1600,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index b057b86171..c8eec9bc8e 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -103,6 +103,7 @@ async fn submit_user_input(codex: &CodexThread, text: &str) { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -121,6 +122,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -148,6 +150,7 @@ async fn steer_user_input(codex: &CodexThread, text: &str) { text: text.to_string(), text_elements: Vec::new(), }], + /*additional_context*/ Default::default(), /*expected_turn_id*/ None, /*responsesapi_client_metadata*/ None, ) @@ -291,6 +294,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -310,6 +314,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index b77e65c0f5..45d2e32549 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -58,6 +58,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -98,6 +99,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -121,6 +123,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -167,6 +170,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -181,6 +185,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -227,6 +232,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -250,6 +256,7 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -309,6 +316,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -333,6 +341,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -349,6 +358,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -409,6 +419,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -433,6 +444,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -455,6 +467,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -491,6 +504,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -552,6 +566,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 96695769c9..06781dc11d 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -68,6 +68,7 @@ fn read_only_text_turn_with_personality( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 917ab08554..b89b611a83 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -227,6 +227,7 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -306,6 +307,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -388,6 +390,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 4ce137a557..836761961a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -153,6 +153,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -167,6 +168,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -250,6 +252,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -264,6 +267,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -329,6 +333,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -343,6 +348,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -426,6 +432,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -464,6 +471,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -545,6 +553,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -699,6 +708,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -724,6 +734,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(new_cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -838,6 +849,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(default_cwd.to_path_buf()), approval_policy: Some(default_approval_policy), @@ -866,6 +878,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(default_cwd.to_path_buf()), approval_policy: Some(default_approval_policy), @@ -977,6 +990,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(default_cwd.to_path_buf()), approval_policy: Some(default_approval_policy), @@ -1007,6 +1021,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(default_cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 413855f641..904c116cfb 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -48,6 +48,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 232d2380b0..05428799ad 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2168,6 +2168,7 @@ async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -2303,6 +2304,7 @@ async fn conversation_user_text_turn_is_capped_when_mirrored_to_realtime() -> Re }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -3499,6 +3501,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 873796ff6c..34cd119f66 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -75,6 +75,7 @@ async fn submit_turn_with_approval_and_environments( environments: Some(environments), final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::OnRequest), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 18ebd732b7..b7b16f9c29 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -160,6 +160,7 @@ async fn remote_models_config_context_window_override_clamps_to_max_context_wind environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -227,6 +228,7 @@ async fn remote_models_config_override_above_max_uses_max_context_window() -> Re environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -293,6 +295,7 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -372,6 +375,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -422,6 +426,7 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning() environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -575,6 +580,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), @@ -798,6 +804,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 2d66389d53..fe9cb1e5f8 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -47,6 +47,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -97,6 +98,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 9800104685..a78e4a016e 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -198,6 +198,7 @@ async fn submit_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 84e101e73a..a122cc399b 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -150,6 +150,7 @@ async fn submit_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index e0ec684797..d09071d72d 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -144,6 +144,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -287,6 +288,7 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -391,6 +393,7 @@ where environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index c18b431984..809a8bb701 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -143,6 +143,7 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::OnRequest), diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index fc7a23215f..0dc7eaddcb 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -93,6 +93,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -179,6 +180,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -269,6 +271,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -312,6 +315,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -330,6 +334,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -403,6 +408,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -441,6 +447,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 05ec967d09..802035d7dc 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -684,6 +684,7 @@ async fn review_history_surfaces_in_parent_session() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 260edb3f64..2c86cb393c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -131,6 +131,7 @@ fn user_turn_with_permission_profile( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index ab985ce0f7..3d4cb93b9c 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -45,6 +45,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index bbf16b3657..e9ffeb62ef 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -452,6 +452,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -865,6 +866,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() - }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1173,6 +1175,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; @@ -1493,6 +1496,7 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 14241ffafb..69db30b21b 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -167,6 +167,7 @@ async fn run_snapshot_command_with_options( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), @@ -267,6 +268,7 @@ async fn run_shell_command_snapshot_with_options( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), @@ -347,6 +349,7 @@ async fn run_tool_turn_on_harness( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), @@ -590,6 +593,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.clone()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index ad8ad6a91a..2335ba6415 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -54,6 +54,7 @@ async fn submit_turn_with_policies( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd_path().to_path_buf()), approval_policy: Some(approval_policy), diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 9173f60c4a..e21668fa84 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -88,6 +88,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__additional_context__additional_context_simple_input.snap b/codex-rs/core/tests/suite/snapshots/all__suite__additional_context__additional_context_simple_input.snap new file mode 100644 index 0000000000..fefdfde60c --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__additional_context__additional_context_simple_input.snap @@ -0,0 +1,11 @@ +--- +source: core/tests/suite/additional_context.rs +expression: "context_snapshot::format_labeled_requests_snapshot(\"additional context is inserted before the user turn input.\",\n&[(\"Request\", &request)], &additional_context_snapshot_options(),)" +--- +Scenario: additional context is inserted before the user turn input. + +## Request +00:message/developer: +01:message/developer:run one +02:message/user:tab one +03:message/user:inspect the active tab diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 6f7a51069a..99b7fb0ec6 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -413,6 +413,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 5709cdd12b..d82692c26c 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -101,6 +101,7 @@ async fn continue_after_stream_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await @@ -123,6 +124,7 @@ async fn continue_after_stream_error() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index 40763960f4..471c60db5a 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -83,6 +83,7 @@ async fn retries_on_early_close() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index d3c07a1153..8891f6ffec 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -771,6 +771,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index 2227e5b34e..a6f808b9d7 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -110,6 +110,7 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), @@ -191,6 +192,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), @@ -282,6 +284,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), @@ -383,6 +386,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), @@ -521,6 +525,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd_path), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 6663991e02..76bb27c423 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -45,6 +45,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -371,6 +372,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 04b59a3331..fe078977ff 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -523,6 +523,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(fixture.cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 9eb831b084..ba754b025f 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -200,6 +200,7 @@ async fn submit_unified_exec_turn( environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), @@ -291,6 +292,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), @@ -2146,6 +2148,7 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(turn_cwd), approval_policy: Some(AskForApproval::Never), @@ -2249,6 +2252,7 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(turn_cwd), approval_policy: Some(AskForApproval::Never), @@ -2721,6 +2725,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(turn_cwd), approval_policy: Some(AskForApproval::Never), @@ -2843,6 +2848,7 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(turn_cwd), approval_policy: Some(AskForApproval::Never), @@ -2981,6 +2987,7 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(turn_cwd), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index 343afd8d53..054d926b2b 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -64,6 +64,7 @@ mv "${tmp_path}" "${payload_path}""#, }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 536ee7b495..3e2263705a 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -182,6 +182,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 867ba10858..e6320ba1a7 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -77,6 +77,7 @@ fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.config.cwd.to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 32a6e30caf..be33467d6a 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -160,6 +160,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(cwd.path().to_path_buf()), approval_policy: Some(AskForApproval::Never), diff --git a/codex-rs/core/tests/suite/window_headers.rs b/codex-rs/core/tests/suite/window_headers.rs index 76fcacf7a9..953b075f65 100644 --- a/codex-rs/core/tests/suite/window_headers.rs +++ b/codex-rs/core/tests/suite/window_headers.rs @@ -112,6 +112,7 @@ async fn submit_user_turn(codex: &Arc, text: &str) -> Result<()> { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 9061227b6b..2df97b5bd7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -781,6 +781,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { thread_id: primary_thread_id_for_span.clone(), input: items.into_iter().map(Into::into).collect(), responsesapi_client_metadata: None, + additional_context: None, environments: None, cwd: Some(default_cwd), runtime_workspace_roots: None, diff --git a/codex-rs/ext/goal/src/steering.rs b/codex-rs/ext/goal/src/steering.rs index e08c47ae26..83f41ea538 100644 --- a/codex-rs/ext/goal/src/steering.rs +++ b/codex-rs/ext/goal/src/steering.rs @@ -1,3 +1,4 @@ +use codex_core::context::ContextualUserFragment; use codex_core::context::GoalContext; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::ThreadGoal; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 167d56da5b..78714d1eaa 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -116,6 +116,7 @@ pub async fn run_codex_tool_session( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }, trace: None, @@ -166,6 +167,7 @@ pub async fn run_codex_tool_session_reply( }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index b1ffb2d215..7bf2fe159d 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -261,6 +261,7 @@ impl MemoryStartupContext { environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d3b8a9e820..a675362b62 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3,6 +3,7 @@ //! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate //! between user and agent. +use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt; use std::ops::Mul; @@ -471,6 +472,21 @@ pub struct ThreadSettingsOverrides { pub personality: Option, } +/// Source classification for client-supplied context. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AdditionalContextKind { + Untrusted, + Application, +} + +/// Client-supplied context keyed by an opaque source identifier. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +pub struct AdditionalContextEntry { + pub value: String, + pub kind: AdditionalContextKind, +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -513,6 +529,9 @@ pub enum Op { /// Optional turn-scoped Responses API `client_metadata`. #[serde(default, skip_serializing_if = "Option::is_none")] responsesapi_client_metadata: Option>, + /// Client-supplied context fragments keyed by an opaque source identifier. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + additional_context: BTreeMap, /// Persistent thread-settings overrides to apply before the input. #[serde(default, flatten)] @@ -655,6 +674,7 @@ impl From> for Op { items: value, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: ThreadSettingsOverrides::default(), } } @@ -4954,6 +4974,7 @@ mod tests { items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }; @@ -4974,6 +4995,7 @@ mod tests { items: Vec::new(), final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), } ); @@ -4996,6 +5018,7 @@ mod tests { items: Vec::new(), final_output_json_schema: Some(schema.clone()), responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }; @@ -5022,6 +5045,7 @@ mod tests { "fiber_run_id".to_string(), "fiber-123".to_string(), )])), + additional_context: Default::default(), thread_settings: Default::default(), }; diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 56938f01e4..fb407de3e9 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -297,6 +297,7 @@ async fn run_turn(thread: &CodexThread, thread_id: &str, prompt: String) -> anyh environments: None, final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: Default::default(), }) .await diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 91ed9176ff..aa2d5a645b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -670,6 +670,7 @@ impl AppServerSession { thread_id: thread_id.to_string(), input: items, responsesapi_client_metadata: None, + additional_context: None, environments: None, cwd: Some(cwd), runtime_workspace_roots: Some( @@ -733,6 +734,7 @@ impl AppServerSession { thread_id: thread_id.to_string(), input: items, responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: turn_id, }, }) From 3da89d4831251b704ac40d1357d26ad518abaf1d Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Tue, 26 May 2026 13:17:58 -0700 Subject: [PATCH 005/204] fix(remote-control): surface websocket task stalls (#24473) ## Why When the app-server remote-control websocket path stalls during connection setup or teardown, the existing logs do not show where the task stopped, and several awaits can keep the task from returning promptly. That makes offline or stale-host incidents hard to distinguish from expected shutdown or disable flow. Issue: N/A (internal incident investigation) ## What Changed Added structured lifecycle and status logging around remote-control enable/disable requests, websocket task startup and exit, connection cycles, enrollment context, and status/environment transitions. Bound websocket connect, transport-event forwarding, and connection-worker shutdown waits. On timeout, the code logs the stalled operation and stops or aborts workers so the loop can reconnect or exit instead of waiting indefinitely. Ping sends now also observe shutdown cancellation. --- .../remote_control/client_tracker.rs | 412 +++++++++++++++++- .../src/transport/remote_control/mod.rs | 101 ++++- .../src/transport/remote_control/websocket.rs | 321 ++++++++++++-- 3 files changed, 764 insertions(+), 70 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs index 4639942b08..22e5e74eda 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs @@ -15,13 +15,21 @@ use codex_app_server_protocol::JSONRPCMessage; use std::collections::HashMap; use tokio::sync::mpsc; use tokio::sync::watch; +use tokio::task::JoinHandle; use tokio::task::JoinSet; use tokio::time::Duration; use tokio::time::Instant; +use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use tracing::info; +use tracing::warn; const REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); pub(crate) const REMOTE_CONTROL_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(30); +#[cfg(not(test))] +const REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(test)] +const REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT: Duration = Duration::from_millis(10); #[derive(Debug)] pub(crate) struct Stopped; @@ -138,9 +146,6 @@ impl ClientTracker { if let Some(connection_id) = self.clients.get_mut(&client_key).map(|client| { client.last_activity_at = Instant::now(); - if let Some(seq_id) = seq_id { - client.last_inbound_seq_id = Some(seq_id); - } client.connection_id }) { self.send_transport_event(TransportEvent::IncomingMessage { @@ -148,6 +153,7 @@ impl ClientTracker { message, }) .await?; + self.record_inbound_message_delivery(&client_key, seq_id); return Ok(()); } @@ -177,23 +183,37 @@ impl ClientTracker { disconnect_token.clone(), )); self.clients.insert( - client_key, + client_key.clone(), ClientState { connection_id, disconnect_token, last_activity_at: Instant::now(), - last_inbound_seq_id: if is_legacy_stream_id { None } else { seq_id }, + last_inbound_seq_id: None, status_tx, }, ); if is_legacy_stream_id { self.legacy_stream_ids.insert(client_id.clone(), stream_id); } - self.send_transport_event(TransportEvent::IncomingMessage { - connection_id, - message, - }) - .await + if let Err(err) = self + .send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await + { + if let Some(client) = self.remove_client(&client_key) { + client.disconnect_token.cancel(); + // The initialize send already timed out on this queue; preserve close + // delivery without blocking reconnect on the same backpressure. + drop(self.spawn_connection_closed(client.connection_id)); + } + return Err(err); + } + if !is_legacy_stream_id { + self.record_inbound_message_delivery(&client_key, seq_id); + } + Ok(()) } ClientEvent::ClientMessageChunk { .. } | ClientEvent::Ack { .. } => Ok(()), ClientEvent::Ping => { @@ -290,16 +310,9 @@ impl ClientTracker { &mut self, client_key: &(ClientId, StreamId), ) -> Result<(), Stopped> { - let Some(client) = self.clients.remove(client_key) else { + let Some(client) = self.remove_client(client_key) else { return Ok(()); }; - if self - .legacy_stream_ids - .get(&client_key.0) - .is_some_and(|stream_id| stream_id == &client_key.1) - { - self.legacy_stream_ids.remove(&client_key.0); - } client.disconnect_token.cancel(); self.send_transport_event(TransportEvent::ConnectionClosed { connection_id: client.connection_id, @@ -307,11 +320,109 @@ impl ClientTracker { .await } + fn remove_client(&mut self, client_key: &(ClientId, StreamId)) -> Option { + let client = self.clients.remove(client_key)?; + if self + .legacy_stream_ids + .get(&client_key.0) + .is_some_and(|stream_id| stream_id == &client_key.1) + { + self.legacy_stream_ids.remove(&client_key.0); + } + Some(client) + } + async fn send_transport_event(&self, event: TransportEvent) -> Result<(), Stopped> { - self.transport_event_tx - .send(event) - .await - .map_err(|_| Stopped) + let event = match event { + TransportEvent::ConnectionClosed { connection_id } => { + return self.send_connection_closed(connection_id).await; + } + event => event, + }; + + let event_name = transport_event_name(&event); + match timeout( + REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT, + self.transport_event_tx.send(event), + ) + .await + { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => { + warn!( + transport_event = event_name, + "remote control transport event receiver dropped" + ); + Err(Stopped) + } + Err(_) => { + warn!( + transport_event = event_name, + timeout = ?REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT, + "timed out forwarding remote control transport event" + ); + Err(Stopped) + } + } + } + + fn record_inbound_message_delivery( + &mut self, + client_key: &(ClientId, StreamId), + seq_id: Option, + ) { + // Timed forwarding can fail, so only dedupe retries after app-server receives it. + if let Some(seq_id) = seq_id + && let Some(client) = self.clients.get_mut(client_key) + { + client.last_inbound_seq_id = Some(seq_id); + } + } + + async fn send_connection_closed(&self, connection_id: ConnectionId) -> Result<(), Stopped> { + // Worker shutdown can abort the caller; detach the cleanup event before awaiting it. + match self.spawn_connection_closed(connection_id).await { + Ok(result) => result, + Err(err) => { + warn!( + transport_event = "connection_closed", + ?err, + "remote control transport event forwarding task failed" + ); + Err(Stopped) + } + } + } + + fn spawn_connection_closed( + &self, + connection_id: ConnectionId, + ) -> JoinHandle> { + info!( + connection_id = ?connection_id, + "forwarding remote control connection closed transport event" + ); + let transport_event_tx = self.transport_event_tx.clone(); + tokio::spawn(async move { + transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await + .map_err(|_| { + warn!( + transport_event = "connection_closed", + "remote control transport event receiver dropped" + ); + Stopped + }) + }) + } +} + +fn transport_event_name(event: &TransportEvent) -> &'static str { + match event { + TransportEvent::ConnectionOpened { .. } => "connection_opened", + TransportEvent::ConnectionClosed { .. } => "connection_closed", + TransportEvent::IncomingMessage { .. } => "incoming_message", } } @@ -370,6 +481,13 @@ mod tests { } } + fn initialized_notification() -> JSONRPCMessage { + JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }) + } + #[tokio::test] async fn cancelled_outbound_task_emits_connection_closed() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); @@ -487,6 +605,256 @@ mod tests { .expect("shutdown should not hang on blocked server forwarding"); } + #[tokio::test] + async fn non_close_transport_event_send_times_out_when_queue_stays_full() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, _transport_event_rx) = mpsc::channel(1); + let shutdown_token = CancellationToken::new(); + let client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx.clone(), &shutdown_token); + + transport_event_tx + .send(TransportEvent::ConnectionClosed { + connection_id: next_connection_id(), + }) + .await + .expect("transport event queue should accept prefill"); + + let send_result = client_tracker + .send_transport_event(TransportEvent::IncomingMessage { + connection_id: next_connection_id(), + message: initialized_notification(), + }) + .await; + + assert!(send_result.is_err()); + } + + #[tokio::test] + async fn incoming_message_timeout_does_not_advance_seq_id() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx.clone(), &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + for _ in 0..2 { + transport_event_tx + .send(TransportEvent::ConnectionClosed { + connection_id: next_connection_id(), + }) + .await + .expect("transport event queue should accept prefill"); + } + + let retry_envelope = ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialized_notification(), + }, + client_id: ClientId("client-1".to_string()), + stream_id: Some(StreamId("stream-1".to_string())), + seq_id: Some(1), + cursor: None, + }; + assert!( + client_tracker + .handle_message(retry_envelope.clone()) + .await + .is_err() + ); + for _ in 0..2 { + let _ = transport_event_rx.recv().await.expect("prefilled event"); + } + + client_tracker + .handle_message(retry_envelope) + .await + .expect("retry should forward after timeout"); + match transport_event_rx.recv().await.expect("retried event") { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + .. + } => assert_eq!(queued_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } + + #[tokio::test] + async fn initialize_timeout_closes_open_connection() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); + let shutdown_token = CancellationToken::new(); + let client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + let mut handle_message = tokio::spawn(async move { + let mut client_tracker = client_tracker; + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + }); + + assert!( + timeout(Duration::from_millis(50), &mut handle_message) + .await + .expect("initialize timeout rollback should not wait for close delivery") + .expect("handle message task should not panic") + .is_err() + ); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + + match transport_event_rx.recv().await.expect("close event") { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn close_client_waits_for_transport_event_queue_capacity() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + for _ in 0..2 { + client_tracker + .transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: initialized_notification(), + }) + .await + .expect("transport event queue should accept prefill"); + } + + let client_key = ( + ClientId("client-1".to_string()), + StreamId("stream-1".to_string()), + ); + let close_client = client_tracker.close_client(&client_key); + tokio::pin!(close_client); + assert!( + timeout(Duration::from_millis(20), &mut close_client) + .await + .is_err() + ); + + for _ in 0..2 { + match transport_event_rx.recv().await.expect("prefilled event") { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + .. + } => assert_eq!(queued_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } + + close_client + .await + .expect("close should forward after queue drains"); + match transport_event_rx.recv().await.expect("close event") { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn close_client_keeps_forwarding_after_caller_is_aborted() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + for _ in 0..2 { + client_tracker + .transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: initialized_notification(), + }) + .await + .expect("transport event queue should accept prefill"); + } + + let client_key = ( + ClientId("client-1".to_string()), + StreamId("stream-1".to_string()), + ); + let mut close_client = + tokio::spawn(async move { client_tracker.close_client(&client_key).await }); + assert!( + timeout(Duration::from_millis(20), &mut close_client) + .await + .is_err() + ); + close_client.abort(); + let _ = close_client.await; + + for _ in 0..2 { + let _ = transport_event_rx.recv().await.expect("prefilled event"); + } + match timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("close should be delivered") + .expect("close event") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + #[tokio::test] async fn initialize_with_new_stream_id_opens_new_connection_for_same_client() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index ae6d79462c..357a98a30d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -19,16 +19,20 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use futures::FutureExt; use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; +use std::panic::AssertUnwindSafe; use std::sync::Arc; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; use tracing::warn; pub struct RemoteControlStartConfig { @@ -73,13 +77,21 @@ impl RemoteControlHandle { return Err(RemoteControlUnavailable); } - self.enabled_tx.send_if_modified(|state| { + let enabled_changed = self.enabled_tx.send_if_modified(|state| { let changed = !*state; *state = true; changed }); let status = self.status(); + info!( + enabled_changed, + current_status = ?status.status, + environment_id = ?status.environment_id, + installation_id = %status.installation_id, + server_name = %status.server_name, + "remote control enable requested" + ); if matches!( status.status, RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting @@ -91,12 +103,21 @@ impl RemoteControlHandle { } pub fn disable(&self) -> RemoteControlStatusChangedNotification { - self.enabled_tx.send_if_modified(|state| { + let enabled_changed = self.enabled_tx.send_if_modified(|state| { let changed = *state; *state = false; changed }); + let status = self.status(); + info!( + enabled_changed, + current_status = ?status.status, + environment_id = ?status.environment_id, + installation_id = %status.installation_id, + server_name = %status.server_name, + "remote control disable requested" + ); self.publish_status(RemoteControlConnectionStatus::Disabled) } @@ -112,6 +133,7 @@ impl RemoteControlHandle { &self, connection_status: RemoteControlConnectionStatus, ) -> RemoteControlStatusChangedNotification { + let mut status_change = None; self.status_tx.send_if_modified(|status| { let next_status = remote_control_status_with_connection_status(status, connection_status); @@ -119,9 +141,21 @@ impl RemoteControlHandle { return false; } + status_change = Some((status.clone(), next_status.clone())); *status = next_status; true }); + if let Some((previous_status, next_status)) = status_change { + info!( + previous_status = ?previous_status.status, + next_status = ?next_status.status, + previous_environment_id = ?previous_status.environment_id, + next_environment_id = ?next_status.environment_id, + installation_id = %next_status.installation_id, + server_name = %next_status.server_name, + "remote control handle status changed" + ); + } self.status() } } @@ -165,6 +199,8 @@ pub async fn start_remote_control( let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); let server_name = gethostname().to_string_lossy().trim().to_string(); + let remote_control_url = config.remote_control_url; + let installation_id = config.installation_id; let initial_status = RemoteControlStatusChangedNotification { status: if initial_enabled { RemoteControlConnectionStatus::Connecting @@ -172,16 +208,35 @@ pub async fn start_remote_control( RemoteControlConnectionStatus::Disabled }, server_name: server_name.clone(), - installation_id: config.installation_id.clone(), + installation_id: installation_id.clone(), environment_id: None, }; let (status_tx, _status_rx) = watch::channel(initial_status); let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone()); + info!( + remote_control_url = %remote_control_url, + installation_id = %installation_id, + server_name = %server_name, + state_db_available, + initial_enabled, + "starting app-server remote control websocket task" + ); + let remote_control_url_for_log = remote_control_url.clone(); + let installation_id_for_log = installation_id.clone(); + let server_name_for_log = server_name.clone(); + let shutdown_token_for_log = shutdown_token.clone(); let join_handle = tokio::spawn(async move { - RemoteControlWebsocket::new( + info!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + initial_enabled, + "app-server remote control websocket task started" + ); + let websocket_task = RemoteControlWebsocket::new( websocket::RemoteControlWebsocketConfig { - remote_control_url: config.remote_control_url, - installation_id: config.installation_id, + remote_control_url, + installation_id, remote_control_target, server_name, }, @@ -194,8 +249,38 @@ pub async fn start_remote_control( shutdown_token, enabled_rx, ) - .run(app_server_client_name_rx) - .await; + .run(app_server_client_name_rx); + match AssertUnwindSafe(websocket_task).catch_unwind().await { + Ok(()) => { + let shutdown_requested = shutdown_token_for_log.is_cancelled(); + if shutdown_requested { + info!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + shutdown_requested, + "app-server remote control websocket task exited" + ); + } else { + warn!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + shutdown_requested, + "app-server remote control websocket task exited without shutdown" + ); + } + } + Err(panic) => { + error!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + "app-server remote control websocket task panicked" + ); + std::panic::resume_unwind(panic); + } + } }); Ok(( diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 1e696c2e46..6358d592e4 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -66,6 +66,10 @@ const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); const REMOTE_CONTROL_RECONNECT_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(30); +const REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(30); +const REMOTE_CONTROL_CONNECTION_SHUTDOWN_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(5); struct BoundedOutboundBuffer { buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque>, @@ -179,14 +183,32 @@ impl WebsocketState { return ClientSegmentObservation::Dropped; } - let observation = self.client_segment_reassembler.observe(client_envelope); - if matches!(observation, ClientSegmentObservation::Forward(_)) - && let Some((key, seq_id)) = client_message_key - { + self.client_segment_reassembler.observe(client_envelope) + } + + fn record_client_message_delivery( + &mut self, + client_envelope: &ClientEnvelope, + client_message_key: Option<((ClientId, Option), u64)>, + ) { + if let Some(cursor) = client_envelope.cursor.as_deref() { + self.subscribe_cursor = Some(cursor.to_string()); + } + if let Some((key, seq_id)) = client_message_key { self.last_completed_client_chunk_seq_id_by_stream .insert(key, seq_id); } - observation + if let ClientEvent::Ack { segment_id } = &client_envelope.event + && let Some(acked_seq_id) = client_envelope.seq_id + && let Some(stream_id) = client_envelope.stream_id.as_ref() + { + self.outbound_buffer.ack( + &client_envelope.client_id, + stream_id, + acked_seq_id, + *segment_id, + ); + } } fn invalidate_client_message_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) { @@ -255,6 +277,14 @@ enum ConnectOutcome { Shutdown, } +#[derive(Debug, Clone, Copy)] +enum ConnectionEndReason { + Shutdown, + Disabled, + EnabledWatchClosed, + ConnectionWorkerStopped, +} + pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, @@ -270,7 +300,12 @@ impl RemoteControlStatusPublisher { Self { tx } } + fn status(&self) -> RemoteControlStatusChangedNotification { + self.tx.borrow().clone() + } + fn publish_status(&self, connection_status: RemoteControlConnectionStatus) { + let mut status_change = None; self.tx.send_if_modified(|status| { let next_status = remote_control_status_with_connection_status(status, connection_status); @@ -278,12 +313,25 @@ impl RemoteControlStatusPublisher { return false; } + status_change = Some((status.clone(), next_status.clone())); *status = next_status; true }); + if let Some((previous_status, next_status)) = status_change { + info!( + previous_status = ?previous_status.status, + next_status = ?next_status.status, + previous_environment_id = ?previous_status.environment_id, + next_environment_id = ?next_status.environment_id, + installation_id = %next_status.installation_id, + server_name = %next_status.server_name, + "remote control websocket status changed" + ); + } } fn publish_environment_id(&self, environment_id: Option) { + let mut status_change = None; self.tx.send_if_modified(|status| { if status.status == RemoteControlConnectionStatus::Disabled { return false; @@ -298,9 +346,20 @@ impl RemoteControlStatusPublisher { return false; } + status_change = Some((status.clone(), next_status.clone())); *status = next_status; true }); + if let Some((previous_status, next_status)) = status_change { + info!( + status = ?next_status.status, + previous_environment_id = ?previous_status.environment_id, + next_environment_id = ?next_status.environment_id, + installation_id = %next_status.installation_id, + server_name = %next_status.server_name, + "remote control websocket environment changed" + ); + } } } @@ -367,12 +426,26 @@ impl RemoteControlWebsocket { mut self, app_server_client_name_rx: Option>, ) { + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + initial_enabled = *self.enabled_rx.borrow(), + "app-server remote control websocket loop started" + ); let app_server_client_name = match self .wait_for_app_server_client_name(app_server_client_name_rx) .await { Ok(app_server_client_name) => app_server_client_name, Err(_) => { + warn!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + shutdown_requested = self.shutdown_token.is_cancelled(), + "app-server remote control websocket loop stopped before client name was ready" + ); self.client_tracker.lock().await.shutdown().await; return; } @@ -380,9 +453,27 @@ impl RemoteControlWebsocket { loop { if !self.wait_until_enabled().await { + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + shutdown_requested = self.shutdown_token.is_cancelled(), + current_status = ?self.status_publisher.status().status, + "app-server remote control websocket loop exiting while waiting for enablement" + ); break; } + let status = self.status_publisher.status(); + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + reconnect_attempt = self.reconnect_attempt.saturating_add(1), + current_status = ?status.status, + environment_id = ?status.environment_id, + "starting app-server remote control websocket connection cycle" + ); let shutdown_token = self.shutdown_token.child_token(); let websocket_connection = match self .connect(&shutdown_token, app_server_client_name.as_deref()) @@ -397,11 +488,30 @@ impl RemoteControlWebsocket { ConnectOutcome::Shutdown => break, }; - self.run_connection(websocket_connection, shutdown_token) + let connection_end_reason = self + .run_connection(websocket_connection, shutdown_token) .await; + let status = self.status_publisher.status(); + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + connection_end_reason = ?connection_end_reason, + current_status = ?status.status, + environment_id = ?status.environment_id, + enabled = *self.enabled_rx.borrow(), + "app-server remote control websocket connection cycle ended" + ); } self.client_tracker.lock().await.shutdown().await; + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + shutdown_requested = self.shutdown_token.is_cancelled(), + "app-server remote control websocket loop exited" + ); } async fn wait_for_app_server_client_name( @@ -462,6 +572,19 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); + let enrollment = self.enrollment.as_ref(); + info!( + websocket_url = %remote_control_target.websocket_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + reconnect_attempt = self.reconnect_attempt.saturating_add(1), + has_enrollment = enrollment.is_some(), + server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + subscribe_cursor_present = subscribe_cursor.is_some(), + app_server_client_name = ?app_server_client_name, + "connecting to app-server remote control websocket" + ); let connect_options = RemoteControlConnectOptions { installation_id: &self.installation_id, server_name: &self.server_name, @@ -500,10 +623,16 @@ impl RemoteControlWebsocket { self.auth_recovery = self.auth_manager.unauthorized_recovery(); self.status_publisher .publish_status(RemoteControlConnectionStatus::Connected); + let enrollment = self.enrollment.as_ref(); info!( - "connected to app-server remote control websocket: {}, {}", - remote_control_target.websocket_url, - format_headers(response.headers()) + websocket_url = %remote_control_target.websocket_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + subscribe_cursor_present = subscribe_cursor.is_some(), + response_headers = %format_headers(response.headers()), + "connected to app-server remote control websocket" ); return ConnectOutcome::Connected(Box::new(websocket_connection)); } @@ -519,12 +648,20 @@ impl RemoteControlWebsocket { let reconnect_attempt = self.reconnect_attempt.saturating_add(1); let (reconnect_delay, reconnect_backoff_reset) = next_reconnect_delay(&mut self.reconnect_attempt); + let enrollment = self.enrollment.as_ref(); warn!( websocket_url = %remote_control_target.websocket_url, + installation_id = %self.installation_id, + server_name = %self.server_name, error = %err, + error_kind = ?err.kind(), reconnect_attempt, reconnect_delay = ?reconnect_delay, reconnect_backoff_reset, + has_enrollment = enrollment.is_some(), + server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + subscribe_cursor_present = subscribe_cursor.is_some(), "failed to connect to app-server remote control websocket" ); if reconnect_backoff_reset { @@ -562,7 +699,7 @@ impl RemoteControlWebsocket { &self, websocket_connection: WebSocketStream>, shutdown_token: CancellationToken, - ) { + ) -> ConnectionEndReason { let (websocket_writer, websocket_reader) = websocket_connection.split(); let mut join_set = tokio::task::JoinSet::new(); @@ -583,19 +720,48 @@ impl RemoteControlWebsocket { )); let mut enabled_rx = self.enabled_rx.clone(); - tokio::select! { - _ = shutdown_token.cancelled() => {} + let connection_end_reason = tokio::select! { + _ = shutdown_token.cancelled() => ConnectionEndReason::Shutdown, changed = enabled_rx.wait_for(|enabled| !*enabled) => { if changed.is_ok() { self.status_publisher .publish_status(RemoteControlConnectionStatus::Disabled); + ConnectionEndReason::Disabled + } else { + ConnectionEndReason::EnabledWatchClosed } } - _ = join_set.join_next() => {} + _ = join_set.join_next() => ConnectionEndReason::ConnectionWorkerStopped, }; shutdown_token.cancel(); - join_set.join_all().await; + Self::join_connection_workers(&mut join_set, REMOTE_CONTROL_CONNECTION_SHUTDOWN_TIMEOUT) + .await; + connection_end_reason + } + + async fn join_connection_workers( + join_set: &mut tokio::task::JoinSet<()>, + shutdown_timeout: std::time::Duration, + ) { + if tokio::time::timeout(shutdown_timeout, Self::drain_join_set(join_set)) + .await + .is_ok() + { + return; + } + + warn!( + shutdown_timeout = ?shutdown_timeout, + remaining_workers = join_set.len(), + "timed out waiting for remote control connection workers to stop; aborting" + ); + join_set.abort_all(); + Self::drain_join_set(join_set).await; + } + + async fn drain_join_set(join_set: &mut tokio::task::JoinSet<()>) { + while join_set.join_next().await.is_some() {} } async fn run_server_writer( @@ -675,12 +841,14 @@ impl RemoteControlWebsocket { let queued_server_envelope = tokio::select! { _ = shutdown_token.cancelled() => return Ok(()), _ = ping_interval.tick() => { - if let Err(err) = websocket_writer - .send(tungstenite::Message::Ping(Vec::new().into())) - .await - { - return Err(io::Error::other(err)); - } + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Ping(Vec::new().into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; continue; } wait_result = used_rx.changed(), if !outbound_has_capacity => @@ -886,6 +1054,7 @@ impl RemoteControlWebsocket { } }; + let client_message_key = WebsocketState::client_message_key(&client_envelope); let observation = { let mut websocket_state = state.lock().await; websocket_state.observe_client_message(client_envelope, wire_size_bytes) @@ -895,24 +1064,6 @@ impl RemoteControlWebsocket { ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => continue, }; - { - let mut websocket_state = state.lock().await; - if let Some(cursor) = client_envelope.cursor.as_deref() { - websocket_state.subscribe_cursor = Some(cursor.to_string()); - } - if let ClientEvent::Ack { segment_id } = &client_envelope.event - && let Some(acked_seq_id) = client_envelope.seq_id - && let Some(stream_id) = client_envelope.stream_id.as_ref() - { - websocket_state.outbound_buffer.ack( - &client_envelope.client_id, - stream_id, - acked_seq_id, - *segment_id, - ); - } - } - let closed_client = matches!(&client_envelope.event, ClientEvent::ClientClosed).then(|| { ( @@ -920,6 +1071,7 @@ impl RemoteControlWebsocket { client_envelope.stream_id.clone(), ) }); + let delivered_client_envelope = client_envelope.clone(); if client_tracker .handle_message(client_envelope) .await @@ -927,6 +1079,10 @@ impl RemoteControlWebsocket { { return Ok(()); } + state + .lock() + .await + .record_client_message_delivery(&delivered_client_envelope, client_message_key); if let Some((client_id, stream_id)) = closed_client { let mut websocket_state = state.lock().await; if let Some(stream_id) = stream_id { @@ -1190,7 +1346,22 @@ pub(super) async fn connect_remote_control_websocket( connect_options.subscribe_cursor, )?; - match connect_async(request).await { + let websocket_connect_result = tokio::time::timeout( + REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, + connect_async(request), + ) + .await + .map_err(|_| { + io::Error::new( + ErrorKind::TimedOut, + format!( + "timed out connecting to remote control websocket at `{}` after {:?}", + remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT + ), + ) + })?; + + match websocket_connect_result { Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), Err(err) => { match &err { @@ -2013,6 +2184,17 @@ mod tests { .expect("writer should stop cleanly"); } + #[tokio::test] + async fn join_connection_workers_aborts_stuck_worker_after_timeout() { + let mut join_set = tokio::task::JoinSet::new(); + join_set.spawn(futures::future::pending::<()>()); + + RemoteControlWebsocket::join_connection_workers(&mut join_set, Duration::from_millis(10)) + .await; + + assert!(join_set.is_empty()); + } + #[tokio::test] async fn run_server_writer_inner_assigns_contiguous_seq_ids_per_stream() { let (client_stream, mut server_stream) = connected_websocket_pair().await; @@ -2350,6 +2532,65 @@ mod tests { &raw[split..], ); + assert!(matches!( + observe_client_message(&mut state, first_chunk.clone()), + ClientSegmentObservation::Pending + )); + let completed_envelope = match observe_client_message(&mut state, second_chunk) { + ClientSegmentObservation::Forward(client_envelope) => *client_envelope, + _ => panic!("expected completed client message"), + }; + state.record_client_message_delivery( + &completed_envelope, + Some(( + ( + ClientId("client-1".to_string()), + Some(StreamId("stream-1".to_string())), + ), + 4, + )), + ); + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Dropped + )); + } + + #[test] + fn websocket_state_allows_replay_before_completed_chunk_delivery() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let first_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 4, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let second_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 4, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + ); + assert!(matches!( observe_client_message(&mut state, first_chunk.clone()), ClientSegmentObservation::Pending @@ -2360,7 +2601,7 @@ mod tests { )); assert!(matches!( observe_client_message(&mut state, first_chunk), - ClientSegmentObservation::Dropped + ClientSegmentObservation::Pending )); } From 5cd9b8086a0b1d46121019506634abb6910a7617 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 26 May 2026 13:50:44 -0700 Subject: [PATCH 006/204] Respect resume cwd overrides for idle cached threads (#24528) Fixes #24186. ## Why When the TUI resumes a thread through the local app-server daemon with a selected workspace, `thread/resume` can hit an already-loaded but idle cached thread. That path previously rejoined the cached `CodexThread`, so cwd/config overrides in `ThreadResumeParams` were ignored and the resumed session kept using the old cwd. ## What changed App-server now treats a loaded-but-idle thread with no subscribers as a cache entry when resume overrides differ: it unloads that cached thread and lets the normal resume path rebuild it with the requested cwd/config. Threads that still have subscribers, or active runtime work, continue to rejoin the existing loaded thread so in-flight state remains observable. The existing thread teardown helper was generalized from archive-specific cleanup to shared unload cleanup for this path. --- .../request_processors/thread_processor.rs | 70 ++++++++++++++----- .../tests/suite/v2/thread_resume.rs | 35 +++++++++- .../tests/suite/v2/thread_unsubscribe.rs | 1 + 3 files changed, 87 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index c9a42d8359..5a2e1a4de8 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2787,15 +2787,20 @@ impl ThreadRequestProcessor { /*include_history*/ true, ) .await?; - if source_thread.thread_id != existing_thread_id { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} from source thread {}", - source_thread.thread_id - ))); - } Some((existing_thread_id, existing_thread, source_thread)) } else { - None + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + params.path.as_ref(), + /*include_history*/ true, + ) + .await?; + let existing_thread_id = source_thread.thread_id; + match self.thread_manager.get_thread(existing_thread_id).await { + Ok(existing_thread) => Some((existing_thread_id, existing_thread, source_thread)), + Err(_) => None, + } }; if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { @@ -2812,6 +2817,48 @@ impl ThreadRequestProcessor { active_path.display() ))); } + let config_snapshot = existing_thread.config_snapshot().await; + let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); + if !mismatch_details.is_empty() { + let has_subscribers = !self + .thread_state_manager + .subscribed_connection_ids(existing_thread_id) + .await + .is_empty(); + let loaded_status = self + .thread_watch_manager + .loaded_status_for_thread(&existing_thread_id.to_string()) + .await; + let is_running = + matches!(existing_thread.agent_status().await, AgentStatus::Running); + + if !has_subscribers && matches!(loaded_status, ThreadStatus::Idle) && !is_running { + // A loaded idle thread is only a cache entry. Shut it down + // before removing it so cold resume cannot duplicate a + // thread that timed out during shutdown. + match wait_for_thread_shutdown(&existing_thread).await { + ThreadShutdownResult::Complete => { + self.thread_manager.remove_thread(&existing_thread_id).await; + self.finalize_thread_teardown(existing_thread_id).await; + return Ok(false); + } + ThreadShutdownResult::SubmitFailed => { + warn!("failed to submit Shutdown to thread {existing_thread_id}"); + } + ThreadShutdownResult::TimedOut => { + warn!("thread {existing_thread_id} shutdown timed out"); + } + } + } + + // Preserve rejoin semantics when another client can still observe + // the loaded thread or shutdown did not complete. + tracing::warn!( + "thread/resume overrides ignored for loaded thread {}: {}", + existing_thread_id, + mismatch_details.join("; ") + ); + } let redact_resume_payloads = should_redact_thread_resume_payloads(app_server_client_name.as_deref()); let history_items = source_thread @@ -2841,15 +2888,6 @@ impl ThreadRequestProcessor { ) .await?; - let config_snapshot = existing_thread.config_snapshot().await; - let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); - if !mismatch_details.is_empty() { - tracing::warn!( - "thread/resume overrides ignored for running thread {}: {}", - existing_thread_id, - mismatch_details.join("; ") - ); - } let mut summary_source_thread = source_thread; summary_source_thread.history = None; let mut thread_summary = self.stored_thread_to_api_thread( diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 6416499663..8e60c90a01 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -43,6 +43,7 @@ use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -1929,7 +1930,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is } #[tokio::test] -async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() -> Result<()> { +async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; @@ -1973,6 +1974,33 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() - let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; assert_eq!(after_modified, rollout.before_modified); + let unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unsubscribe_id)), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(normalized_existing_path(&rollout.rollout_file_path)?), + cwd: Some(codex_home.path().to_string_lossy().to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { cwd, .. } = to_response::(resume_resp)?; + assert_eq!(cwd, AbsolutePathBuf::from_absolute_path(codex_home.path())?); + let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id, @@ -2291,9 +2319,10 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result ) .await??; - let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id); + let stale_thread_id = Uuid::new_v4().to_string(); + let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; - let thread_uuid = Uuid::parse_str(&thread_id)?; + let thread_uuid = Uuid::parse_str(&stale_thread_id)?; let mut stale_file = std::fs::File::create(&stale_path)?; let stale_meta = json!({ "timestamp": "2025-01-01T00:00:00Z", diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index c0188add8c..45437b7ee1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -317,6 +317,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id, + cwd: Some(codex_home.path().to_string_lossy().to_string()), ..Default::default() }) .await?; From 1911021c0eae972e622c8445d136ae04545a530f Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 26 May 2026 14:05:28 -0700 Subject: [PATCH 007/204] Add forked_from_thread_id turn metadata (#24160) ## Why When Codex calls responsesapi, we currently send `session_id`, `thread_id`, and `turn_id` among other things as `client_metadata["x-codex-turn-metadata"]`. This PR adds `forked_from_thread_id` which helps explain the "lineage" of a forked thread. ## What's changed - Track the immediate history source copied into a forked thread through thread/session creation, including subagent and review turn metadata paths. - Include `forked_from_thread_id` in Codex turn metadata while preventing turn-scoped Responses API client metadata from overwriting Codex-owned lineage fields. - Add coverage for fork lineage in turn metadata and the app-server Responses API request path. --- .../tests/suite/v2/client_metadata.rs | 204 ++++++++++++++++++ codex-rs/core/src/agent/control.rs | 3 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/review.rs | 5 + codex-rs/core/src/session/session.rs | 7 +- codex-rs/core/src/session/tests.rs | 8 + .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/thread_manager.rs | 34 ++- codex-rs/core/src/turn_metadata.rs | 101 ++++----- codex-rs/core/src/turn_metadata_tests.rs | 63 +++++- .../suite/responses_api_proxy_headers.rs | 9 + codex-rs/core/tests/suite/review.rs | 33 ++- 14 files changed, 404 insertions(+), 69 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 80f41d7b11..3a00a433d1 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -1,8 +1,16 @@ use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewDelivery; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -60,6 +68,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result let client_metadata = HashMap::from([ ("fiber_run_id".to_string(), "fiber-start-123".to_string()), ("origin".to_string(), "gaas".to_string()), + ("thread_source".to_string(), "client-supplied".to_string()), ]); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -93,12 +102,188 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); Ok(()) } +#[tokio::test] +async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let source_thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let ThreadForkResponse { thread, .. } = + fork_fake_rollout_thread(&mut mcp, source_thread_id.clone()).await?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Continue".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!( + metadata["forked_from_thread_id"].as_str(), + Some(source_thread_id.as_str()) + ); + assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); + assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); + + Ok(()) +} + +#[tokio::test] +async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let review_payload = serde_json::json!({ + "findings": [], + "overall_correctness": "good", + "overall_explanation": "Done", + "overall_confidence_score": 0.5 + }) + .to_string(); + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", &review_payload), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let source_thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let ThreadForkResponse { thread, .. } = + fork_fake_rollout_thread(&mut mcp, source_thread_id.clone()).await?; + + let review_req = mcp + .send_review_start_request(ReviewStartParams { + thread_id: thread.id.clone(), + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::Custom { + instructions: "Review the fork".to_string(), + }, + }) + .await?; + let review_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(review_req)), + ) + .await??; + let ReviewStartResponse { + review_thread_id, .. + } = to_response::(review_resp)?; + assert_eq!(review_thread_id, thread.id); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!( + request.header("x-openai-subagent").as_deref(), + Some("review") + ); + assert_eq!( + metadata["forked_from_thread_id"].as_str(), + Some(review_thread_id.as_str()) + ); + let review_request_thread_id = metadata["thread_id"] + .as_str() + .unwrap_or_else(|| panic!("missing review request thread_id")); + assert!(review_request_thread_id != review_thread_id.as_str()); + assert_eq!( + request + .header("x-codex-window-id") + .as_deref() + .and_then(|window_id| window_id.split_once(':').map(|(thread_id, _)| thread_id)), + Some(review_request_thread_id) + ); + assert!(metadata["turn_id"].as_str().is_some()); + + Ok(()) +} + #[tokio::test] async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() -> Result<()> { skip_if_no_network!(Ok(())); @@ -345,6 +530,25 @@ supports_websockets = {supports_websockets} ) } +async fn fork_fake_rollout_thread( + mcp: &mut McpProcess, + source_thread_id: String, +) -> Result { + let fork_req = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: source_thread_id, + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_req)), + ) + .await??; + to_response::(fork_resp) +} + fn parse_json_header(value: &str) -> serde_json::Value { match serde_json::from_str(value) { Ok(value) => value, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a9a90be54a..4706a455ff 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -262,10 +262,12 @@ impl AgentControl { .await? } (Some(session_source), None) => { + let forked_from_thread_id = thread_spawn_parent_thread_id(&session_source); Box::pin(state.spawn_new_thread_with_source( config.clone(), self.clone(), session_source, + forked_from_thread_id, /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -487,6 +489,7 @@ impl AgentControl { self.clone(), session_source, /*thread_source*/ Some(ThreadSource::Subagent), + /*forked_from_thread_id*/ Some(parent_thread_id), /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b27cf745c9..dd88e9238f 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -86,6 +86,7 @@ pub(crate) async fn run_codex_thread_interactive( extensions: Arc::clone(&parent_session.services.extensions), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), + forked_from_thread_id: Some(parent_session.conversation_id), thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1876cbf980..5ea30e5bb5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -400,6 +400,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) extensions: Arc>, pub(crate) conversation_history: InitialHistory, pub(crate) session_source: SessionSource, + pub(crate) forked_from_thread_id: Option, pub(crate) thread_source: Option, pub(crate) agent_control: AgentControl, pub(crate) dynamic_tools: Vec, @@ -464,6 +465,7 @@ impl Codex { extensions, conversation_history, session_source, + forked_from_thread_id, thread_source, agent_control, dynamic_tools, @@ -619,6 +621,7 @@ impl Codex { app_server_client_name: None, app_server_client_version: None, session_source, + forked_from_thread_id, thread_source, dynamic_tools, persist_extended_history, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 32122873cb..762ebc1458 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -69,12 +69,17 @@ pub(super) async fn spawn_review_thread( .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); let session_source = parent_turn_context.session_source.clone(); + let forked_from_thread_id = { + let state = sess.state.lock().await; + state.session_configuration.forked_from_thread_id + }; let per_turn_config = Arc::new(per_turn_config); let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( sess.session_id().to_string(), sess.thread_id().to_string(), + forked_from_thread_id, parent_turn_context.thread_source, review_turn_id.clone(), #[allow(deprecated)] diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 75eb7d0653..0823e93666 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -95,6 +95,8 @@ pub(crate) struct SessionConfiguration { pub(super) app_server_client_version: Option, /// Source of the session (cli, vscode, exec, mcp, ...) pub(super) session_source: SessionSource, + /// Immediate history source copied into this thread, when this thread was forked. + pub(super) forked_from_thread_id: Option, /// Optional analytics source classification for this thread. pub(super) thread_source: Option, pub(super) dynamic_tools: Vec, @@ -505,7 +507,10 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); - let forked_from_id = initial_history.forked_from_id(); + let forked_from_id = session_configuration + .forked_from_thread_id + .or_else(|| initial_history.forked_from_id()); + session_configuration.forked_from_thread_id = forked_from_id; let event_persistence_mode = if session_configuration.persist_extended_history { ThreadEventPersistenceMode::Extended diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0638168422..5cb8f438ab 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3001,6 +3001,7 @@ async fn set_rate_limits_retains_previous_credits() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -3105,6 +3106,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -3632,6 +3634,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4375,6 +4378,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4484,6 +4488,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4718,6 +4723,7 @@ async fn make_session_with_config_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -4821,6 +4827,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( app_server_client_name: None, app_server_client_version: None, session_source: session_source.clone(), + forked_from_thread_id: None, thread_source: None, dynamic_tools: Vec::new(), persist_extended_history: false, @@ -6316,6 +6323,7 @@ where app_server_client_name: None, app_server_client_version: None, session_source: SessionSource::Exec, + forked_from_thread_id: None, thread_source: None, dynamic_tools, persist_extended_history: false, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5db4aab985..dabf9c360e 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -692,6 +692,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { session_source: SessionSource::SubAgent(SubAgentSource::Other( GUARDIAN_REVIEWER_NAME.to_string(), )), + forked_from_thread_id: None, thread_source: None, agent_control: AgentControl::default(), dynamic_tools: Vec::new(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index de38083135..df1502274b 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -482,6 +482,7 @@ impl Session { let turn_metadata_state = Arc::new(TurnMetadataState::new( session_id.to_string(), thread_id.to_string(), + session_configuration.forked_from_thread_id, session_configuration.thread_source, sub_id.clone(), cwd.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 232f12ca71..d209026b62 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -581,6 +581,15 @@ impl ThreadManager { pub async fn start_thread_with_options( &self, options: StartThreadOptions, + ) -> CodexResult { + self.start_thread_with_options_and_fork_source(options, /*forked_from_thread_id*/ None) + .await + } + + async fn start_thread_with_options_and_fork_source( + &self, + options: StartThreadOptions, + forked_from_thread_id: Option, ) -> CodexResult { let session_source = options .session_source @@ -594,6 +603,7 @@ impl ThreadManager { Arc::clone(&self.state.auth_manager), self.agent_control(), session_source, + forked_from_thread_id, thread_source, options.dynamic_tools, options.persist_extended_history, @@ -634,7 +644,8 @@ impl ThreadManager { history, InterruptedTurnHistoryMarker::from_config(&options.config), ); - self.start_thread_with_options(options).await + self.start_thread_with_options_and_fork_source(options, Some(forked_from_thread_id)) + .await } pub async fn resume_thread_from_rollout( @@ -673,6 +684,7 @@ impl ThreadManager { initial_history, auth_manager, self.agent_control(), + /*forked_from_thread_id*/ None, thread_source, Vec::new(), persist_extended_history, @@ -698,6 +710,7 @@ impl ThreadManager { InitialHistory::New, Arc::clone(&self.state.auth_manager), self.agent_control(), + /*forked_from_thread_id*/ None, /*thread_source*/ None, Vec::new(), /*persist_extended_history*/ false, @@ -727,6 +740,7 @@ impl ThreadManager { initial_history, auth_manager, self.agent_control(), + /*forked_from_thread_id*/ None, thread_source, Vec::new(), /*persist_extended_history*/ false, @@ -876,6 +890,13 @@ impl ThreadManager { persist_extended_history: bool, parent_trace: Option, ) -> CodexResult { + // `forked_from_id()` describes this history's existing lineage. When + // forking a resumed thread, the child copies the resumed thread itself. + let forked_from_thread_id = match &history { + InitialHistory::Resumed(resumed) => Some(resumed.conversation_id), + InitialHistory::Forked(_) => history.forked_from_id(), + InitialHistory::New | InitialHistory::Cleared => None, + }; let interrupted_marker = InterruptedTurnHistoryMarker::from_config(&config); let history = fork_history_from_snapshot(snapshot, history, interrupted_marker); let environments = default_thread_environment_selections( @@ -887,6 +908,7 @@ impl ThreadManager { history, Arc::clone(&self.state.auth_manager), self.agent_control(), + forked_from_thread_id, thread_source, Vec::new(), persist_extended_history, @@ -1020,6 +1042,7 @@ impl ThreadManagerState { config, agent_control, self.session_source.clone(), + /*forked_from_thread_id*/ None, /*thread_source*/ None, /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -1036,6 +1059,7 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, session_source: SessionSource, + forked_from_thread_id: Option, thread_source: Option, persist_extended_history: bool, metrics_service_name: Option, @@ -1052,6 +1076,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + forked_from_thread_id, thread_source, Vec::new(), persist_extended_history, @@ -1086,6 +1111,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + /*forked_from_thread_id*/ None, thread_source, Vec::new(), /*persist_extended_history*/ false, @@ -1107,6 +1133,7 @@ impl ThreadManagerState { agent_control: AgentControl, session_source: SessionSource, thread_source: Option, + forked_from_thread_id: Option, persist_extended_history: bool, inherited_shell_snapshot: Option>, inherited_exec_policy: Option>, @@ -1121,6 +1148,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + forked_from_thread_id, thread_source, Vec::new(), persist_extended_history, @@ -1142,6 +1170,7 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + forked_from_thread_id: Option, thread_source: Option, dynamic_tools: Vec, persist_extended_history: bool, @@ -1156,6 +1185,7 @@ impl ThreadManagerState { auth_manager, agent_control, self.session_source.clone(), + forked_from_thread_id, thread_source, dynamic_tools, persist_extended_history, @@ -1177,6 +1207,7 @@ impl ThreadManagerState { auth_manager: Arc, agent_control: AgentControl, session_source: SessionSource, + forked_from_thread_id: Option, thread_source: Option, dynamic_tools: Vec, persist_extended_history: bool, @@ -1229,6 +1260,7 @@ impl ThreadManagerState { extensions: Arc::clone(&self.extensions), conversation_history: initial_history, session_source, + forked_from_thread_id, thread_source, agent_control, dynamic_tools, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 1b2c6b4b12..52a714960a 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -16,6 +16,7 @@ use codex_git_utils::get_git_remote_urls_assume_git_repo; use codex_git_utils::get_git_repo_root; use codex_git_utils::get_has_changes; use codex_git_utils::get_head_commit_hash; +use codex_protocol::ThreadId; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -74,6 +75,8 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + forked_from_thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, @@ -107,7 +110,14 @@ fn merge_turn_metadata( } if let Some(responsesapi_client_metadata) = responsesapi_client_metadata { for (key, value) in responsesapi_client_metadata { - if key == TURN_STARTED_AT_UNIX_MS_KEY { + if matches!( + key.as_str(), + "session_id" + | "thread_id" + | "turn_id" + | TURN_STARTED_AT_UNIX_MS_KEY + | "forked_from_thread_id" + ) { continue; } metadata @@ -118,32 +128,6 @@ fn merge_turn_metadata( to_ascii_json_string(&metadata).ok() } -fn build_turn_metadata_bag( - session_id: Option, - thread_id: Option, - thread_source: Option, - turn_id: Option, - sandbox: Option, - repo_root: Option, - workspace_git_metadata: Option, -) -> TurnMetadataBag { - let mut workspaces = BTreeMap::new(); - if let (Some(repo_root), Some(workspace_git_metadata)) = (repo_root, workspace_git_metadata) - && !workspace_git_metadata.is_empty() - { - workspaces.insert(repo_root, workspace_git_metadata.into()); - } - - TurnMetadataBag { - session_id, - thread_id, - thread_source, - turn_id, - workspaces, - sandbox, - } -} - pub async fn build_turn_metadata_header( cwd: &AbsolutePathBuf, sandbox: Option<&str>, @@ -164,20 +148,24 @@ pub async fn build_turn_metadata_header( return None; } - build_turn_metadata_bag( - /*session_id*/ None, - /*thread_id*/ None, - /*thread_source*/ None, - /*turn_id*/ None, - sandbox.map(ToString::to_string), - repo_root, - Some(WorkspaceGitMetadata { - associated_remote_urls, - latest_git_commit_hash, - has_changes, - }), - ) - .to_header_value() + let workspace_git_metadata = WorkspaceGitMetadata { + associated_remote_urls, + latest_git_commit_hash, + has_changes, + }; + let mut metadata = TurnMetadataBag { + sandbox: sandbox.map(ToString::to_string), + ..Default::default() + }; + if let Some(repo_root) = repo_root + && !workspace_git_metadata.is_empty() + { + metadata + .workspaces + .insert(repo_root, workspace_git_metadata.into()); + } + + metadata.to_header_value() } #[derive(Clone, Debug)] @@ -198,6 +186,7 @@ impl TurnMetadataState { pub(crate) fn new( session_id: String, thread_id: String, + forked_from_thread_id: Option, thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, @@ -214,15 +203,15 @@ impl TurnMetadataState { ) .to_string(), ); - let base_metadata = build_turn_metadata_bag( - Some(session_id), - Some(thread_id), + let base_metadata = TurnMetadataBag { + session_id: Some(session_id), + thread_id: Some(thread_id), + forked_from_thread_id, thread_source, - Some(turn_id), + turn_id: Some(turn_id), sandbox, - /*repo_root*/ None, - /*workspace_git_metadata*/ None, - ); + ..Default::default() + }; let base_header = base_metadata .to_header_value() .unwrap_or_else(|| "{}".to_string()); @@ -346,19 +335,13 @@ impl TurnMetadataState { let Some(repo_root) = state.repo_root.clone() else { return; }; - - let enriched_metadata = build_turn_metadata_bag( - state.base_metadata.session_id.clone(), - state.base_metadata.thread_id.clone(), - state.base_metadata.thread_source, - state.base_metadata.turn_id.clone(), - state.base_metadata.sandbox.clone(), - Some(repo_root), - Some(workspace_git_metadata), - ); - if enriched_metadata.workspaces.is_empty() { + if workspace_git_metadata.is_empty() { return; } + let mut enriched_metadata = state.base_metadata.clone(); + enriched_metadata + .workspaces + .insert(repo_root, workspace_git_metadata.into()); if let Some(header_value) = enriched_metadata.to_header_value() { *state diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 2f79927103..a874b0db9b 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -93,6 +93,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + /*forked_from_thread_id*/ None, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -128,6 +129,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + /*forked_from_thread_id*/ None, Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, @@ -143,6 +145,35 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { assert!(json.get("session_source").is_none()); } +#[test] +fn turn_metadata_state_includes_root_fork_lineage() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + let source_thread_id = + ThreadId::from_string("11111111-1111-4111-8111-111111111111").expect("thread id"); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + Some(source_thread_id), + Some(ThreadSource::User), + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); + + assert_eq!( + json["forked_from_thread_id"].as_str(), + Some("11111111-1111-4111-8111-111111111111") + ); +} + #[test] fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let temp_dir = TempDir::new().expect("temp dir"); @@ -152,6 +183,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + /*forked_from_thread_id*/ None, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -179,6 +211,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + /*forked_from_thread_id*/ None, /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -224,6 +257,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + /*forked_from_thread_id*/ None, /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -266,7 +300,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque } #[test] -fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { +fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); @@ -274,6 +308,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + /*forked_from_thread_id*/ None, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -281,15 +316,22 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, ); - state.set_responsesapi_client_metadata(HashMap::from([( - "turn_started_at_unix_ms".to_string(), - "client-supplied".to_string(), - )])); + state.set_responsesapi_client_metadata(HashMap::from([ + ( + "turn_started_at_unix_ms".to_string(), + "client-supplied".to_string(), + ), + ( + "forked_from_thread_id".to_string(), + "client-supplied".to_string(), + ), + ])); let header = state.current_header_value().expect("header"); let json: Value = serde_json::from_str(&header).expect("json"); assert!(json.get("turn_started_at_unix_ms").is_none()); + assert!(json.get("forked_from_thread_id").is_none()); } #[test] @@ -297,10 +339,13 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); + let source_thread_id = + ThreadId::from_string("44444444-4444-4444-8444-444444444444").expect("thread id"); let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), + Some(source_thread_id), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -318,6 +363,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ), ("session_id".to_string(), "client-supplied".to_string()), ("thread_id".to_string(), "client-supplied".to_string()), + ( + "forked_from_thread_id".to_string(), + "client-supplied".to_string(), + ), ("thread_source".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), @@ -337,6 +386,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); assert_eq!(json["thread_id"].as_str(), Some("thread-a")); + assert_eq!( + json["forked_from_thread_id"].as_str(), + Some("44444444-4444-4444-8444-444444444444") + ); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert_eq!( diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 809a8bb701..0c401d05fc 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -125,6 +125,15 @@ async fn responses_api_parent_and_subagent_requests_include_identity_headers() - child.header("x-codex-parent-thread-id").as_deref(), Some(parent_thread_id) ); + let child_turn_metadata: serde_json::Value = serde_json::from_str( + &child + .header("x-codex-turn-metadata") + .ok_or_else(|| anyhow!("child request missing x-codex-turn-metadata"))?, + )?; + assert_eq!( + child_turn_metadata["forked_from_thread_id"].as_str(), + Some(parent_thread_id) + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 802035d7dc..9efaf6c2d1 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -61,7 +61,7 @@ async fn review_op_emits_lifecycle_and_review_output() { "overall_confidence_score": 0.8 }) .to_string(); - let (server, _request_log) = start_responses_server_with_sse( + let (server, request_log) = start_responses_server_with_sse( assistant_message_sse(&review_json), /*expected_requests*/ 1, ) @@ -111,11 +111,38 @@ async fn review_op_emits_lifecycle_and_review_output() { assert_eq!(expected, review); let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - // Also verify that a user message with the header and a formatted finding - // was recorded back in the parent session's rollout. let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); + let parent_thread_id = text + .lines() + .filter(|line| !line.trim().is_empty()) + .find_map(|line| { + let rollout_line: RolloutLine = serde_json::from_str(line).expect("rollout line"); + match rollout_line.item { + RolloutItem::SessionMeta(session_meta) => Some(session_meta.meta.id.to_string()), + _ => None, + } + }) + .expect("parent session meta"); + let request = request_log.single_request(); + assert_eq!( + request.header("x-openai-subagent").as_deref(), + Some("review") + ); + let turn_metadata: serde_json::Value = serde_json::from_str( + &request + .header("x-codex-turn-metadata") + .expect("review request turn metadata"), + ) + .expect("review request turn metadata json"); + assert_eq!( + turn_metadata["forked_from_thread_id"].as_str(), + Some(parent_thread_id.as_str()) + ); + + // Also verify that a user message with the header and a formatted finding + // was recorded back in the parent session's rollout. let mut saw_header = false; let mut saw_finding_line = false; let expected_assistant_text = render_review_output_text(&expected); From 66ff8b0f5467ea4b77e30b5b4f186d9145fef636 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 26 May 2026 14:05:40 -0700 Subject: [PATCH 008/204] make direct only allowed caller for standalone websearch (#24646) only allow `Direct` callers of the standalone websearch tool because its not supported in codemode --- codex-rs/app-server/tests/suite/v2/web_search.rs | 4 ++++ codex-rs/ext/web-search/src/extension.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 52c327a93f..58b2ff91a8 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -128,6 +128,10 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { "search_query": [{"q": "standalone web search"}], }) ); + assert_eq!( + search_body["settings"]["allowed_callers"], + json!(["direct"]) + ); assert_eq!( search_body["input"] .as_array() diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 228d77897b..b7b6c83951 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use codex_api::AllowedCaller; use codex_api::ApproximateLocation; use codex_api::LocationType; use codex_api::SearchContextSize; @@ -71,6 +72,7 @@ fn search_settings(config: &Config, web_search_mode: WebSearchMode) -> SearchSet allowed_domains: filters.allowed_domains.clone(), blocked_domains: None, }), + allowed_callers: Some(vec![AllowedCaller::Direct]), external_web_access: Some(match web_search_mode { WebSearchMode::Live => true, WebSearchMode::Cached | WebSearchMode::Disabled => false, From 675cb1afbda5de4585345371019756b107a5812e Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Tue, 26 May 2026 14:17:43 -0700 Subject: [PATCH 009/204] Clarify view_image tool description (#23949) --- codex-rs/core/src/tools/handlers/view_image_spec.rs | 2 +- codex-rs/core/tests/suite/code_mode.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/view_image_spec.rs b/codex-rs/core/src/tools/handlers/view_image_spec.rs index 662cd639a6..ed0b1021a5 100644 --- a/codex-rs/core/src/tools/handlers/view_image_spec.rs +++ b/codex-rs/core/src/tools/handlers/view_image_spec.rs @@ -40,7 +40,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: VIEW_IMAGE_TOOL_NAME.to_string(), - description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags)." + description: "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk." .to_string(), strict: false, defer_loading: None, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index fb9e7fb420..90daf21963 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2861,7 +2861,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); From 22e45014a2820a8c5a7037d15dab903ccd3eb994 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 26 May 2026 14:34:02 -0700 Subject: [PATCH 010/204] TUI config cleanup: plugin mentions (#24266) ## Summary TUI plugin mention refresh still joined app-server plugin inventory with client-local plugin config, which can diverge once plugin state is owned by the app server. This changes the TUI to mirror the GUI client: `plugin/list` is the autocomplete source, and mention candidates are plugin-level entries filtered to installed, enabled, and not disabled by admin. The TUI no longer reads local plugin config or calls `plugin/read` while refreshing plugin mention candidates. ## API shape and limitations The current app-server API does not expose effective per-session plugin capability summaries for mention autocomplete. As in the GUI, autocomplete now trusts `plugin/list` metadata rather than proving which plugin capabilities are loaded in the active session. That avoids stale client-local reads and the cwd/remote detail gaps in `plugin/read`, but intentionally accepts the same list-level tradeoff as the app: if `plugin/list` reports a remote plugin before its local bundle is materialized, the plugin can still appear as a mention candidate. --- codex-rs/tui/src/app/background_requests.rs | 2 +- codex-rs/tui/src/app/plugin_mentions.rs | 192 ++++++++++---------- 2 files changed, 97 insertions(+), 97 deletions(-) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index f81f8b071e..4b8b5f8920 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -409,7 +409,7 @@ impl App { } tokio::spawn(async move { - match fetch_plugin_mentions(request_handle, config).await { + match fetch_plugin_mentions(request_handle, config.cwd.to_path_buf()).await { Ok(plugins) => { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: Some(plugins), diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index ef300896bc..5d94f51c08 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -1,125 +1,62 @@ //! Plugin mention capability enrichment for the TUI. //! -//! Mention inventory comes from app-server `plugin/list`, while mention eligibility still reuses -//! the older local bulk capability summaries. That keeps the feature app-server-shaped without -//! paying for a `plugin/read` per plugin. +//! Mention inventory comes from app-server `plugin/list`, matching the GUI +//! client. The current API exposes plugin-level mention metadata there, but not +//! effective per-session capability summaries. use super::background_requests::request_plugin_list; use super::*; +use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginListResponse; -use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginSummary; -use codex_core_plugins::PluginsManager; use codex_plugin::PluginCapabilitySummary; -use std::collections::HashMap; - -#[derive(Debug, Clone)] -struct PluginMentionEntry { - config_name: String, - display_name: String, - description: Option, -} - -impl PluginMentionEntry { - fn capability_summary( - self, - capabilities_by_config_name: &HashMap, - ) -> Option { - let capabilities = capabilities_by_config_name.get(&self.config_name)?; - Some(PluginCapabilitySummary { - config_name: self.config_name, - display_name: self.display_name, - description: self.description, - has_skills: capabilities.has_skills, - mcp_server_names: capabilities.mcp_server_names.clone(), - app_connector_ids: capabilities.app_connector_ids.clone(), - }) - } -} pub(super) async fn fetch_plugin_mentions( request_handle: AppServerRequestHandle, - config: crate::legacy_core::config::Config, + cwd: PathBuf, ) -> Result> { - let response = request_plugin_list(request_handle, config.cwd.to_path_buf()).await?; - let mention_entries = plugin_mention_entries_from_list_response(response); - let capabilities_by_config_name = load_plugin_mention_capabilities(&config).await; - - Ok(mention_entries - .into_iter() - .filter_map(|entry| entry.capability_summary(&capabilities_by_config_name)) - .collect()) + let response = request_plugin_list(request_handle, cwd).await?; + Ok(plugin_mentions_from_list_response(response)) } -async fn load_plugin_mention_capabilities( - config: &crate::legacy_core::config::Config, -) -> HashMap { - let plugins_input = config.plugins_config_input(); - PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&plugins_input) - .await - .capability_summaries() - .iter() - .cloned() - .map(|summary| (summary.config_name.clone(), summary)) - .collect() -} - -fn plugin_mention_entries_from_list_response( +fn plugin_mentions_from_list_response( response: PluginListResponse, -) -> Vec { +) -> Vec { response .marketplaces .into_iter() - .flat_map(plugin_mention_entries_from_marketplace) + .flat_map(|marketplace| { + let marketplace_name = marketplace.name; + marketplace + .plugins + .into_iter() + .filter_map(move |plugin| plugin_mention_from_summary(&marketplace_name, plugin)) + }) .collect() } -fn plugin_mention_entries_from_marketplace( - marketplace: PluginMarketplaceEntry, -) -> Vec { - let marketplace_name = marketplace.name; - marketplace - .plugins - .into_iter() - .filter_map(|plugin| plugin_mention_entry(&marketplace_name, plugin)) - .collect() +fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { + plugin.installed && plugin.enabled && plugin.availability != PluginAvailability::DisabledByAdmin } -fn plugin_mention_entry( +fn plugin_mention_from_summary( marketplace_name: &str, plugin: PluginSummary, -) -> Option { +) -> Option { if !plugin_is_eligible_for_mentions(&plugin) { return None; } - let config_name = plugin_mention_config_name(marketplace_name, &plugin)?; - Some(PluginMentionEntry { - config_name, + Some(PluginCapabilitySummary { + config_name: plugin.id.clone(), display_name: plugin_mention_display_name(&plugin), - description: plugin_mention_description(&plugin), + description: plugin_mention_description(marketplace_name, &plugin), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), }) } -fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { - plugin.installed && plugin.enabled -} - -fn plugin_mention_config_name(marketplace_name: &str, plugin: &PluginSummary) -> Option { - codex_plugin::PluginId::new(plugin.name.clone(), marketplace_name.to_string()) - .map(|plugin_id| plugin_id.as_key()) - .map_err(|err| { - tracing::warn!( - plugin_name = plugin.name, - marketplace_name, - error = %err, - "skipping plugin mention with invalid identity" - ); - }) - .ok() -} - fn plugin_mention_display_name(plugin: &PluginSummary) -> String { plugin .interface @@ -131,17 +68,80 @@ fn plugin_mention_display_name(plugin: &PluginSummary) -> String { .unwrap_or_else(|| plugin.name.clone()) } -fn plugin_mention_description(plugin: &PluginSummary) -> Option { +fn plugin_mention_description(marketplace_name: &str, plugin: &PluginSummary) -> Option { plugin .interface .as_ref() - .and_then(|interface| { - interface - .short_description - .as_deref() - .or(interface.long_description.as_deref()) - }) + .and_then(|interface| interface.short_description.as_deref()) .map(str::trim) .filter(|description| !description.is_empty()) .map(str::to_string) + .or_else(|| { + let marketplace_name = marketplace_name.trim(); + (!marketplace_name.is_empty()).then(|| marketplace_name.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::PluginAuthPolicy; + use codex_app_server_protocol::PluginAvailability; + use codex_app_server_protocol::PluginInstallPolicy; + use codex_app_server_protocol::PluginListResponse; + use codex_app_server_protocol::PluginMarketplaceEntry; + use codex_app_server_protocol::PluginSource; + use pretty_assertions::assert_eq; + + #[test] + fn plugin_mentions_use_plugin_list_summaries_and_gui_eligibility() { + let active = plugin_summary("active"); + let mut disabled_by_admin = plugin_summary("disabled-by-admin"); + disabled_by_admin.availability = PluginAvailability::DisabledByAdmin; + let mut disabled = plugin_summary("disabled"); + disabled.enabled = false; + let mut uninstalled = plugin_summary("uninstalled"); + uninstalled.installed = false; + + let response = PluginListResponse { + marketplaces: vec![PluginMarketplaceEntry { + name: "server-marketplace".to_string(), + path: None, + interface: None, + plugins: vec![active, disabled_by_admin, disabled, uninstalled], + }], + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }; + + assert_eq!( + plugin_mentions_from_list_response(response), + vec![PluginCapabilitySummary { + config_name: "active@server-marketplace".to_string(), + display_name: "active".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); + } + + fn plugin_summary(name: &str) -> PluginSummary { + PluginSummary { + id: format!("{name}@server-marketplace"), + remote_plugin_id: Some(format!("plugins~{name}")), + local_version: None, + name: name.to_string(), + share_context: None, + source: PluginSource::Remote, + installed: true, + enabled: true, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + availability: PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + } + } } From 414561294c3955ed6e2020873758cb3262287fb9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 26 May 2026 14:40:06 -0700 Subject: [PATCH 011/204] Avoid repeated marketplace upgrades for alternate layouts (#24320) Fixes #24249. ## Why Codex already supports discovering marketplaces under both `.agents/plugins/marketplace.json` and `.claude-plugin/marketplace.json`. The Git marketplace auto-upgrade no-op check only looked for the `.agents` layout. That meant an installed `.claude-plugin` marketplace with matching revision metadata still looked absent, so plugin list/startup upgrade work could stage and re-activate the same marketplace again. That matches the failure shape in #24249: the report called out repeated marketplace sync/cache refresh logs and a large recently-touched `.tmp/marketplaces/.staging` directory. This change makes the auto-upgrade path recognize the installed `.claude-plugin` marketplace as already current, which should remove that staging/activation feedback loop. ## What changed `codex-rs/core-plugins/src/marketplace_upgrade.rs` now uses the existing supported marketplace manifest discovery helper when deciding whether an installed Git marketplace is already current. Existing local plugin source validation is unchanged; `source: "./"` still remains invalid. ## Confidence Confidence is high that this fixes the repeated marketplace upgrade path: the old hardcoded layout check was definitely wrong for installed `.claude-plugin` marketplaces, and the reported staging churn points directly at that path. Confidence is not 100% because we do not have a CPU profile or a fully re-run reporter repro. A malformed marketplace entry can still be logged as invalid if another caller repeatedly lists plugins; this PR fixes the staging/upgrade feedback loop that likely made the failure pathological, not every possible source of repeated marketplace resolution. --- codex-rs/core-plugins/src/marketplace_upgrade.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/codex-rs/core-plugins/src/marketplace_upgrade.rs b/codex-rs/core-plugins/src/marketplace_upgrade.rs index 9dda8a9a3e..010694c8b3 100644 --- a/codex-rs/core-plugins/src/marketplace_upgrade.rs +++ b/codex-rs/core-plugins/src/marketplace_upgrade.rs @@ -6,6 +6,7 @@ use self::activation::installed_marketplace_metadata_matches; use self::activation::write_installed_marketplace_metadata; use self::git::clone_git_source; use self::git::git_remote_revision; +use crate::marketplace::find_marketplace_manifest_path; use crate::marketplace::validate_marketplace_root; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerStack; @@ -176,9 +177,7 @@ fn upgrade_configured_git_marketplace( MARKETPLACE_UPGRADE_GIT_TIMEOUT, )?; let destination = install_root.join(&marketplace.name); - if destination - .join(".agents/plugins/marketplace.json") - .is_file() + if find_marketplace_manifest_path(&destination).is_some() && marketplace.last_revision.as_deref() == Some(remote_revision.as_str()) && installed_marketplace_metadata_matches(&destination, marketplace, &remote_revision) { From 0a6bc4e68759f0086434fd5b4d87c7f5a597d6d1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 26 May 2026 14:56:27 -0700 Subject: [PATCH 012/204] windows-sandbox: remove SandboxPolicy runner plumbing (#23813) ## Why The Windows sandbox runner still carried the old `SandboxPolicy` compatibility path even though core now computes `PermissionProfile`. That meant Windows command-runner execution could only see the legacy projection, so profile-only filesystem rules such as deny globs were not part of the runner input. ## What Changed - Removed the Windows-local `SandboxPolicy` parser/export and deleted `windows-sandbox-rs/src/policy.rs`. - Changed restricted-token capture/session setup, elevated setup, world-writable audit, read-root grant, and command-runner session APIs to accept `PermissionProfile` plus the profile cwd. - Bumped the elevated command-runner IPC protocol to version 2 because `SpawnRequest` now carries `permission_profile` / `permission_profile_cwd` instead of the legacy `policy_json_or_preset` / `sandbox_policy_cwd` fields. - Updated core exec, unified exec, debug-sandbox, TUI setup/grant flows, and app-server setup to pass the actual effective `PermissionProfile`. - Left regression coverage asserting the old IPC policy fields are absent and the runner serializes tagged `PermissionProfile` JSON. ## Verification - `cargo test -p codex-windows-sandbox` - `cargo test -p codex-core windows_sandbox` - `cargo test -p codex-app-server request_processors::windows_sandbox_processor` - `just fix -p codex-windows-sandbox -p codex-core -p codex-app-server -p codex-cli -p codex-tui` - `just fix -p codex-cli -p codex-tui` - `just fix -p codex-windows-sandbox -p codex-tui` - `rg "\\bSandboxPolicy\\b" codex-rs/windows-sandbox-rs` returned no matches. Note: `cargo test -p codex-cli` was attempted but did not reach crate tests because local disk filled while compiling dependencies (`No space left on device`). The targeted clippy pass compiled the affected CLI/TUI surfaces afterward. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/23813). * #24108 * __->__ #23813 --- .../windows_sandbox_processor.rs | 6 +- codex-rs/cli/src/debug_sandbox.rs | 36 ++-- codex-rs/core/src/exec.rs | 12 +- .../core/src/unified_exec/process_manager.rs | 8 +- codex-rs/core/src/windows_sandbox.rs | 57 ++--- .../core/src/windows_sandbox_read_grants.rs | 10 +- .../src/windows_sandbox_read_grants_tests.rs | 12 +- codex-rs/tui/src/app/event_dispatch.rs | 57 ++--- codex-rs/tui/src/app/platform_actions.rs | 23 +- .../src/chatwidget/windows_sandbox_prompts.rs | 14 +- codex-rs/windows-sandbox-rs/src/allow.rs | 188 ++++++++++------- codex-rs/windows-sandbox-rs/src/audit.rs | 19 -- .../windows-sandbox-rs/src/elevated_impl.rs | 104 --------- codex-rs/windows-sandbox-rs/src/lib.rs | 158 +++++++------- codex-rs/windows-sandbox-rs/src/policy.rs | 61 ------ .../src/resolved_permissions.rs | 37 ---- codex-rs/windows-sandbox-rs/src/setup.rs | 197 +++++++++++------- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 194 ++++++++--------- .../src/unified_exec/backends/elevated.rs | 42 ---- .../src/unified_exec/backends/legacy.rs | 22 +- .../src/unified_exec/mod.rs | 46 +--- .../src/unified_exec/tests.rs | 22 +- 22 files changed, 537 insertions(+), 788 deletions(-) delete mode 100644 codex-rs/windows-sandbox-rs/src/policy.rs diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index 0b639b7d5c..c507bf8ddf 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -76,10 +76,8 @@ impl WindowsSandboxRequestProcessor { Ok(config) => { let setup_request = WindowsSandboxSetupRequest { mode, - policy: config - .permissions - .legacy_sandbox_policy(config.cwd.as_path()), - policy_cwd: config.cwd.to_path_buf(), + permission_profile: config.permissions.effective_permission_profile(), + permission_profile_cwd: config.cwd.to_path_buf(), command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.to_path_buf(), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 918d81aad2..36c5e683b2 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -209,9 +209,9 @@ async fn run_command_under_sandbox( // does not support `--cwd`, but let's use the config value for consistency. let cwd = config.cwd.clone(); // For now, we always use the same cwd for both the command and the - // sandbox policy. In the future, we could add a CLI option to set them + // permission profile. In the future, we could add a CLI option to set them // separately. - let sandbox_policy_cwd = cwd.clone(); + let permission_profile_cwd = cwd.clone(); let env = create_env( &config.permissions.shell_environment_policy, @@ -222,7 +222,8 @@ async fn run_command_under_sandbox( if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await; + run_command_under_windows_session(&config, command, cwd, permission_profile_cwd, env) + .await; } #[cfg(not(target_os = "windows"))] { @@ -265,7 +266,7 @@ async fn run_command_under_sandbox( command, file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy, - sandbox_policy_cwd: sandbox_policy_cwd.as_path(), + sandbox_policy_cwd: permission_profile_cwd.as_path(), enforce_managed_network: false, network: network.as_ref(), extra_allow_unix_sockets: allow_unix_sockets, @@ -297,7 +298,7 @@ async fn run_command_under_sandbox( command, cwd.as_path(), &config.permissions.effective_permission_profile(), - sandbox_policy_cwd.as_path(), + permission_profile_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), ); @@ -349,24 +350,15 @@ async fn run_command_under_windows_session( config: &Config, command: Vec, cwd: AbsolutePathBuf, - sandbox_policy_cwd: AbsolutePathBuf, + permission_profile_cwd: AbsolutePathBuf, env: std::collections::HashMap, ) -> ! { use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::spawn_windows_sandbox_session_elevated; + use codex_windows_sandbox::spawn_windows_sandbox_session_elevated_for_permission_profile; use codex_windows_sandbox::spawn_windows_sandbox_session_legacy; - let sandbox_policy = config - .permissions - .legacy_sandbox_policy(sandbox_policy_cwd.as_path()); - let policy_str = match serde_json::to_string(&sandbox_policy) { - Ok(policy_str) => policy_str, - Err(err) => { - eprintln!("windows sandbox failed to serialize policy: {err}"); - std::process::exit(1); - } - }; + let permission_profile = config.permissions.effective_permission_profile(); let use_elevated = matches!( WindowsSandboxLevel::from_config(config), @@ -374,9 +366,9 @@ async fn run_command_under_windows_session( ); let spawned = if use_elevated { - spawn_windows_sandbox_session_elevated( - policy_str.as_str(), - sandbox_policy_cwd.as_path(), + spawn_windows_sandbox_session_elevated_for_permission_profile( + &permission_profile, + permission_profile_cwd.as_path(), config.codex_home.as_path(), command, cwd.as_path(), @@ -394,8 +386,8 @@ async fn run_command_under_windows_session( .await } else { spawn_windows_sandbox_session_legacy( - policy_str.as_str(), - sandbox_policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), config.codex_home.as_path(), command, cwd.as_path(), diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 84ebe337c6..5cec3b49bb 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -423,7 +423,6 @@ pub(crate) async fn execute_exec_request( stdout_stream: Option, after_spawn: Option>, ) -> Result { - let sandbox_policy = exec_request.compatibility_sandbox_policy(); let ExecRequest { command, cwd, @@ -464,7 +463,6 @@ pub(crate) async fn execute_exec_request( stdout_stream, after_spawn, sandbox, - &sandbox_policy, &permission_profile, &windows_sandbox_policy_cwd, windows_sandbox_filesystem_overrides.as_ref(), @@ -481,7 +479,6 @@ async fn get_raw_output_result( stdout_stream: Option, after_spawn: Option>, #[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType, - #[cfg_attr(not(windows), allow(unused_variables))] sandbox_policy: &SandboxPolicy, #[cfg_attr(not(windows), allow(unused_variables))] permission_profile: &PermissionProfile, #[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_policy_cwd: &AbsolutePathBuf, #[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_filesystem_overrides: Option< @@ -492,7 +489,6 @@ async fn get_raw_output_result( if sandbox == SandboxType::WindowsRestrictedToken { return exec_windows_sandbox( params, - sandbox_policy, permission_profile, windows_sandbox_policy_cwd, windows_sandbox_filesystem_overrides, @@ -572,7 +568,6 @@ fn record_windows_sandbox_spawn_failure( #[cfg(target_os = "windows")] async fn exec_windows_sandbox( params: ExecParams, - sandbox_policy: &SandboxPolicy, permission_profile: &PermissionProfile, windows_sandbox_policy_cwd: &AbsolutePathBuf, windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>, @@ -604,11 +599,6 @@ async fn exec_windows_sandbox( None }; - let policy_str = serde_json::to_string(sandbox_policy).map_err(|err| { - CodexErr::Io(io::Error::other(format!( - "failed to serialize Windows sandbox policy: {err}" - ))) - })?; let sandbox_cwd = windows_sandbox_policy_cwd.clone(); let permission_profile = permission_profile.clone(); let codex_home = find_codex_home().map_err(|err| { @@ -655,7 +645,7 @@ async fn exec_windows_sandbox( ) } else { run_windows_sandbox_capture_with_filesystem_overrides( - policy_str.as_str(), + &permission_profile, &sandbox_cwd, codex_home.as_ref(), command, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fb99500755..08cd92ba53 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -866,12 +866,6 @@ impl UnifiedExecProcessManager { #[cfg(target_os = "windows")] if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { - let sandbox_policy = request.compatibility_sandbox_policy(); - let policy_json = serde_json::to_string(&sandbox_policy).map_err(|err| { - UnifiedExecError::create_process(format!( - "failed to serialize Windows sandbox policy: {err}" - )) - })?; let codex_home = crate::config::find_codex_home().map_err(|err| { UnifiedExecError::create_process(format!( "windows sandbox: failed to resolve codex_home: {err}" @@ -923,7 +917,7 @@ impl UnifiedExecProcessManager { codex_protocol::config_types::WindowsSandboxLevel::RestrictedToken | codex_protocol::config_types::WindowsSandboxLevel::Disabled => { codex_windows_sandbox::spawn_windows_sandbox_session_legacy( - policy_json.as_str(), + &request.permission_profile, request.windows_sandbox_policy_cwd.as_path(), codex_home.as_ref(), request.command.clone(), diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 1494ce262e..b2ac8bf1f0 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -8,7 +8,7 @@ use codex_features::FeaturesToml; use codex_login::default_client::originator; use codex_otel::sanitize_metric_tag_value; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; @@ -145,16 +145,17 @@ pub fn elevated_setup_failure_metric_name(_err: &anyhow::Error) -> &'static str #[cfg(target_os = "windows")] pub fn run_elevated_setup( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, ) -> anyhow::Result<()> { let permissions = - codex_windows_sandbox::ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd( - policy, policy_cwd, - ); + codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + )?; codex_windows_sandbox::run_elevated_setup( codex_windows_sandbox::SandboxSetupRequest { permissions: &permissions, @@ -169,8 +170,8 @@ pub fn run_elevated_setup( #[cfg(not(target_os = "windows"))] pub fn run_elevated_setup( - _policy: &SandboxPolicy, - _policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _command_cwd: &Path, _env_map: &HashMap, _codex_home: &Path, @@ -180,15 +181,15 @@ pub fn run_elevated_setup( #[cfg(target_os = "windows")] pub fn run_legacy_setup_preflight( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, ) -> anyhow::Result<()> { codex_windows_sandbox::run_windows_sandbox_legacy_preflight( - policy, - policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, command_cwd, env_map, @@ -197,16 +198,16 @@ pub fn run_legacy_setup_preflight( #[cfg(target_os = "windows")] pub fn run_setup_refresh_with_extra_read_roots( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, extra_read_roots: Vec, ) -> anyhow::Result<()> { codex_windows_sandbox::run_setup_refresh_with_extra_read_roots( - policy, - policy_cwd, + permission_profile, + permission_profile_cwd, command_cwd, env_map, codex_home, @@ -217,8 +218,8 @@ pub fn run_setup_refresh_with_extra_read_roots( #[cfg(not(target_os = "windows"))] pub fn run_legacy_setup_preflight( - _policy: &SandboxPolicy, - _policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _command_cwd: &Path, _env_map: &HashMap, _codex_home: &Path, @@ -228,8 +229,8 @@ pub fn run_legacy_setup_preflight( #[cfg(not(target_os = "windows"))] pub fn run_setup_refresh_with_extra_read_roots( - _policy: &SandboxPolicy, - _policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _command_cwd: &Path, _env_map: &HashMap, _codex_home: &Path, @@ -247,8 +248,8 @@ pub enum WindowsSandboxSetupMode { #[derive(Debug, Clone)] pub struct WindowsSandboxSetupRequest { pub mode: WindowsSandboxSetupMode, - pub policy: SandboxPolicy, - pub policy_cwd: PathBuf, + pub permission_profile: PermissionProfile, + pub permission_profile_cwd: PathBuf, pub command_cwd: PathBuf, pub env_map: HashMap, pub codex_home: PathBuf, @@ -285,8 +286,8 @@ async fn run_windows_sandbox_setup_and_persist( request: WindowsSandboxSetupRequest, ) -> anyhow::Result<()> { let mode = request.mode; - let policy = request.policy; - let policy_cwd = request.policy_cwd; + let permission_profile = request.permission_profile; + let permission_profile_cwd = request.permission_profile_cwd; let command_cwd = request.command_cwd; let env_map = request.env_map; let codex_home = request.codex_home; @@ -297,8 +298,8 @@ async fn run_windows_sandbox_setup_and_persist( WindowsSandboxSetupMode::Elevated => { if !sandbox_setup_is_complete(setup_codex_home.as_path()) { run_elevated_setup( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, setup_codex_home.as_path(), @@ -307,8 +308,8 @@ async fn run_windows_sandbox_setup_and_persist( } WindowsSandboxSetupMode::Unelevated => { run_legacy_setup_preflight( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, setup_codex_home.as_path(), diff --git a/codex-rs/core/src/windows_sandbox_read_grants.rs b/codex-rs/core/src/windows_sandbox_read_grants.rs index b0891ec6a6..a9561372ce 100644 --- a/codex-rs/core/src/windows_sandbox_read_grants.rs +++ b/codex-rs/core/src/windows_sandbox_read_grants.rs @@ -1,13 +1,13 @@ use crate::windows_sandbox::run_setup_refresh_with_extra_read_roots; use anyhow::Result; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; pub fn grant_read_root_non_elevated( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, @@ -25,8 +25,8 @@ pub fn grant_read_root_non_elevated( let canonical_root = dunce::canonicalize(read_root)?; run_setup_refresh_with_extra_read_roots( - policy, - policy_cwd, + permission_profile, + permission_profile_cwd, command_cwd, env_map, codex_home, diff --git a/codex-rs/core/src/windows_sandbox_read_grants_tests.rs b/codex-rs/core/src/windows_sandbox_read_grants_tests.rs index bffa840ede..93cbcddfe3 100644 --- a/codex-rs/core/src/windows_sandbox_read_grants_tests.rs +++ b/codex-rs/core/src/windows_sandbox_read_grants_tests.rs @@ -1,18 +1,18 @@ use super::grant_read_root_non_elevated; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; -fn policy() -> SandboxPolicy { - SandboxPolicy::new_workspace_write_policy() +fn permission_profile() -> PermissionProfile { + PermissionProfile::workspace_write() } #[test] fn rejects_relative_path() { let tmp = TempDir::new().expect("tempdir"); let err = grant_read_root_non_elevated( - &policy(), + &permission_profile(), tmp.path(), tmp.path(), &HashMap::new(), @@ -28,7 +28,7 @@ fn rejects_missing_path() { let tmp = TempDir::new().expect("tempdir"); let missing = tmp.path().join("does-not-exist"); let err = grant_read_root_non_elevated( - &policy(), + &permission_profile(), tmp.path(), tmp.path(), &HashMap::new(), @@ -45,7 +45,7 @@ fn rejects_file_path() { let file_path = tmp.path().join("file.txt"); std::fs::write(&file_path, "hello").expect("write file"); let err = grant_read_root_non_elevated( - &policy(), + &permission_profile(), tmp.path(), tmp.path(), &HashMap::new(), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index f92806b162..18ded9bbbc 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -907,8 +907,8 @@ impl App { return Ok(AppRunControl::Continue); } }; - let policy_cwd = self.config.cwd.clone(); - let command_cwd = policy_cwd.clone(); + let permission_profile_cwd = self.config.cwd.clone(); + let command_cwd = permission_profile_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); @@ -930,25 +930,10 @@ impl App { self.chat_widget.show_windows_sandbox_setup_status(); self.windows_sandbox.setup_started_at = Some(Instant::now()); let session_telemetry = self.session_telemetry.clone(); - let Ok(policy) = permission_profile - .to_legacy_sandbox_policy(policy_cwd.as_path()) - .inspect_err(|err| { - tracing::error!( - %err, - "approval preset permissions cannot be projected for elevated Windows sandbox setup" - ); - }) - else { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { - preset, - profile_selection, - }); - return Ok(AppRunControl::Continue); - }; tokio::task::spawn_blocking(move || { let result = crate::legacy_core::windows_sandbox::run_elevated_setup( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), @@ -1031,8 +1016,8 @@ impl App { return Ok(AppRunControl::Continue); } }; - let policy_cwd = self.config.cwd.clone(); - let command_cwd = policy_cwd.clone(); + let permission_profile_cwd = self.config.cwd.clone(); + let command_cwd = permission_profile_cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); let codex_home = self.config.codex_home.clone(); @@ -1040,26 +1025,11 @@ impl App { let session_telemetry = self.session_telemetry.clone(); self.chat_widget.show_windows_sandbox_setup_status(); - let Ok(policy) = permission_profile - .to_legacy_sandbox_policy(policy_cwd.as_path()) - .inspect_err(|err| { - tracing::error!( - %err, - "approval preset permissions cannot be projected for legacy Windows sandbox setup" - ); - }) - else { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { - preset, - profile_selection, - }); - return Ok(AppRunControl::Continue); - }; tokio::task::spawn_blocking(move || { if let Err(err) = crate::legacy_core::windows_sandbox::run_legacy_setup_preflight( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), @@ -1096,11 +1066,8 @@ impl App { /*hint*/ None, )); - let policy = self - .config - .permissions - .legacy_sandbox_policy(self.config.cwd.as_path()); - let policy_cwd = self.config.cwd.clone(); + let permission_profile = self.config.permissions.effective_permission_profile(); + let permission_profile_cwd = self.config.cwd.clone(); let command_cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); @@ -1110,8 +1077,8 @@ impl App { tokio::task::spawn_blocking(move || { let requested_path = PathBuf::from(path); let event = match crate::legacy_core::grant_read_root_non_elevated( - &policy, - policy_cwd.as_path(), + &permission_profile, + permission_profile_cwd.as_path(), command_cwd.as_path(), &env_map, codex_home.as_path(), diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index c87776f48b..d11e410884 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -21,20 +21,25 @@ impl App { permission_profile: PermissionProfile, tx: AppEventSender, ) { - let Ok(sandbox_policy) = permission_profile.to_legacy_sandbox_policy(cwd.as_path()) else { - send_world_writable_scan_failed(&tx); + let Ok(permissions) = + codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + &permission_profile, + cwd.as_path(), + ) + else { return; }; tokio::task::spawn_blocking(move || { let logs_base_dir_path = logs_base_dir.as_path(); - let result = codex_windows_sandbox::apply_world_writable_scan_and_denies( - logs_base_dir_path, - cwd.as_path(), - &env_map, - &sandbox_policy, - Some(logs_base_dir_path), - ); + let result = + codex_windows_sandbox::apply_world_writable_scan_and_denies_for_permissions( + logs_base_dir_path, + cwd.as_path(), + &env_map, + &permissions, + Some(logs_base_dir_path), + ); if result.is_err() { // Scan failed: warn without examples. send_world_writable_scan_failed(&tx); diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index dd90af0557..3b8dfce200 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -15,12 +15,20 @@ impl ChatWidget { } let cwd = self.config.cwd.clone(); let env_map: std::collections::HashMap = std::env::vars().collect(); - let policy = self.config.legacy_sandbox_policy(); - match codex_windows_sandbox::apply_world_writable_scan_and_denies( + let permission_profile = self.config.permissions.effective_permission_profile(); + let Ok(permissions) = + codex_windows_sandbox::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + &permission_profile, + cwd.as_path(), + ) + else { + return None; + }; + match codex_windows_sandbox::apply_world_writable_scan_and_denies_for_permissions( self.config.codex_home.as_path(), cwd.as_path(), &env_map, - &policy, + &permissions, Some(self.config.codex_home.as_path()), ) { Ok(_) => None, diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 541a86fc1c..59ed2468c6 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -44,19 +44,36 @@ pub(crate) fn compute_allow_paths_for_permissions( #[cfg(test)] mod tests { use super::*; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; use tempfile::TempDir; + fn workspace_write_profile( + writable_roots: &[AbsolutePathBuf], + exclude_tmpdir_env_var: bool, + exclude_slash_tmp: bool, + ) -> PermissionProfile { + PermissionProfile::workspace_write_with( + writable_roots, + NetworkSandboxPolicy::Restricted, + exclude_tmpdir_env_var, + exclude_slash_tmp, + ) + } + fn compute_allow_paths( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, ) -> AllowDenyPaths { - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); + let permissions = ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + ) + .expect("managed permission profile"); compute_allow_paths_for_permissions(&permissions, command_cwd, env_map) } @@ -68,14 +85,19 @@ mod tests { let _ = fs::create_dir_all(&command_cwd); let _ = fs::create_dir_all(&extra_root); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let writable_roots = vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()]; + let permission_profile = workspace_write_profile( + &writable_roots, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &command_cwd, + &command_cwd, + &HashMap::new(), + ); assert!( paths @@ -91,25 +113,29 @@ mod tests { } #[test] - fn uses_policy_cwd_for_legacy_workspace_root() { + fn uses_profile_cwd_for_workspace_root() { let tmp = TempDir::new().expect("tempdir"); - let policy_cwd = tmp.path().join("workspace"); - let command_cwd = policy_cwd.join("subdir"); + let permission_profile_cwd = tmp.path().join("workspace"); + let command_cwd = permission_profile_cwd.join("subdir"); fs::create_dir_all(&command_cwd).expect("create command cwd"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); - let paths = compute_allow_paths(&policy, &policy_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &permission_profile_cwd, + &command_cwd, + &HashMap::new(), + ); assert!( paths .allow - .contains(&dunce::canonicalize(&policy_cwd).unwrap()) + .contains(&dunce::canonicalize(&permission_profile_cwd).unwrap()) ); assert!( !paths @@ -127,17 +153,16 @@ mod tests { let _ = fs::create_dir_all(&command_cwd); let _ = fs::create_dir_all(&temp_dir); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); let mut env_map = HashMap::new(); env_map.insert("TEMP".into(), temp_dir.to_string_lossy().to_string()); env_map.insert("TMP".into(), temp_dir.to_string_lossy().to_string()); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &env_map); + let paths = compute_allow_paths(&permission_profile, &command_cwd, &command_cwd, &env_map); assert!( paths @@ -160,17 +185,16 @@ mod tests { let _ = fs::create_dir_all(&command_cwd); let _ = fs::create_dir_all(&temp_dir); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); let mut env_map = HashMap::new(); env_map.insert("TEMP".into(), temp_dir.to_string_lossy().to_string()); env_map.insert("TMP".into(), temp_dir.to_string_lossy().to_string()); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &env_map); + let paths = compute_allow_paths(&permission_profile, &command_cwd, &command_cwd, &env_map); let expected_allow: HashSet = [ dunce::canonicalize(&command_cwd).unwrap(), @@ -189,14 +213,18 @@ mod tests { let command_cwd = tmp.path().join("workspace"); let _ = fs::create_dir_all(&command_cwd); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &command_cwd, + &command_cwd, + &HashMap::new(), + ); let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] .into_iter() .collect(); @@ -212,14 +240,18 @@ mod tests { let git_dir = command_cwd.join(".git"); let _ = fs::create_dir_all(&git_dir); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &command_cwd, + &command_cwd, + &HashMap::new(), + ); let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] .into_iter() .collect(); @@ -239,14 +271,18 @@ mod tests { let _ = fs::create_dir_all(&command_cwd); let _ = fs::write(&git_file, "gitdir: .git/worktrees/example"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &command_cwd, + &command_cwd, + &HashMap::new(), + ); let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] .into_iter() .collect(); @@ -267,14 +303,18 @@ mod tests { let _ = fs::create_dir_all(&codex_dir); let _ = fs::create_dir_all(&agents_dir); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &command_cwd, + &command_cwd, + &HashMap::new(), + ); let expected_allow: HashSet = [dunce::canonicalize(&command_cwd).unwrap()] .into_iter() .collect(); @@ -295,14 +335,18 @@ mod tests { let command_cwd = tmp.path().join("workspace"); let _ = fs::create_dir_all(&command_cwd); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); - let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); + let paths = compute_allow_paths( + &permission_profile, + &command_cwd, + &command_cwd, + &HashMap::new(), + ); assert_eq!(paths.allow.len(), 1); assert!( paths.deny.is_empty(), diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 0d6f4b5744..58dd15c2c8 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -7,7 +7,6 @@ use crate::cap::workspace_write_root_contains_path; use crate::logging::debug_log; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; -use crate::policy::SandboxPolicy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup::effective_write_roots_for_permissions; use crate::token::LocalSid; @@ -218,24 +217,6 @@ pub fn audit_everyone_writable( Ok(Vec::new()) } -pub fn apply_world_writable_scan_and_denies( - codex_home: &Path, - cwd: &Path, - env_map: &std::collections::HashMap, - sandbox_policy: &SandboxPolicy, - logs_base_dir: Option<&Path>, -) -> Result<()> { - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(sandbox_policy, cwd); - apply_world_writable_scan_and_denies_for_permissions( - codex_home, - cwd, - env_map, - &permissions, - logs_base_dir, - ) -} - pub fn apply_world_writable_scan_and_denies_for_permissions( codex_home: &Path, cwd: &Path, diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index f8a059918a..6038a2413f 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -4,23 +4,6 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -pub struct ElevatedSandboxCaptureRequest<'a> { - pub policy_json_or_preset: &'a str, - pub sandbox_policy_cwd: &'a Path, - pub codex_home: &'a Path, - pub command: Vec, - pub cwd: &'a Path, - pub env_map: HashMap, - pub timeout_ms: Option, - pub use_private_desktop: bool, - pub proxy_enforced: bool, - pub read_roots_override: Option<&'a [PathBuf]>, - pub read_roots_include_platform_defaults: bool, - pub write_roots_override: Option<&'a [PathBuf]>, - pub deny_read_paths_override: &'a [AbsolutePathBuf], - pub deny_write_paths_override: &'a [AbsolutePathBuf], -} - pub struct ElevatedSandboxProfileCaptureRequest<'a> { pub permission_profile: &'a PermissionProfile, pub permission_profile_cwd: &'a Path, @@ -39,7 +22,6 @@ pub struct ElevatedSandboxProfileCaptureRequest<'a> { } mod windows_impl { - use super::ElevatedSandboxCaptureRequest; use super::ElevatedSandboxProfileCaptureRequest; use crate::acl::allow_null_device; use crate::cap::load_or_create_cap_sids; @@ -56,7 +38,6 @@ mod windows_impl { use crate::logging::log_failure; use crate::logging::log_start; use crate::logging::log_success; - use crate::policy::parse_policy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::runner_client::spawn_runner_transport; use crate::sandbox_utils::ensure_codex_home_exists; @@ -64,7 +45,6 @@ mod windows_impl { use crate::setup::effective_write_roots_for_permissions; use crate::token::LocalSid; use anyhow::Result; - use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; @@ -218,87 +198,13 @@ mod windows_impl { }) })() } - - /// Legacy policy-string adapter for callers that have not moved to permission profiles yet. - #[allow(clippy::too_many_arguments)] - pub fn run_windows_sandbox_capture( - request: ElevatedSandboxCaptureRequest<'_>, - ) -> Result { - let ElevatedSandboxCaptureRequest { - policy_json_or_preset, - sandbox_policy_cwd, - codex_home, - command, - cwd, - env_map, - timeout_ms, - use_private_desktop, - proxy_enforced, - read_roots_override, - read_roots_include_platform_defaults, - write_roots_override, - deny_read_paths_override, - deny_write_paths_override, - } = request; - let policy = parse_policy(policy_json_or_preset)?; - let permission_profile = - PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, sandbox_policy_cwd); - run_windows_sandbox_capture_for_permission_profile(ElevatedSandboxProfileCaptureRequest { - permission_profile: &permission_profile, - permission_profile_cwd: sandbox_policy_cwd, - codex_home, - command, - cwd, - env_map, - timeout_ms, - use_private_desktop, - proxy_enforced, - read_roots_override, - read_roots_include_platform_defaults, - write_roots_override, - deny_read_paths_override, - deny_write_paths_override, - }) - } - - #[cfg(test)] - mod tests { - use crate::policy::SandboxPolicy; - - fn workspace_policy(network_access: bool) -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - } - - #[test] - fn applies_network_block_when_access_is_disabled() { - assert!(!workspace_policy(/*network_access*/ false).has_full_network_access()); - } - - #[test] - fn skips_network_block_when_access_is_allowed() { - assert!(workspace_policy(/*network_access*/ true).has_full_network_access()); - } - - #[test] - fn applies_network_block_for_read_only() { - assert!(!SandboxPolicy::new_read_only_policy().has_full_network_access()); - } - } } -#[cfg(target_os = "windows")] -pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture_for_permission_profile; #[cfg(not(target_os = "windows"))] mod stub { - use super::ElevatedSandboxCaptureRequest; use super::ElevatedSandboxProfileCaptureRequest; use anyhow::Result; use anyhow::bail; @@ -311,14 +217,6 @@ mod stub { pub timed_out: bool, } - /// Stub implementation for non-Windows targets; sandboxing only works on Windows. - #[allow(clippy::too_many_arguments)] - pub fn run_windows_sandbox_capture( - _request: ElevatedSandboxCaptureRequest<'_>, - ) -> Result { - bail!("Windows sandbox is only available on Windows") - } - /// Stub implementation for non-Windows targets; sandboxing only works on Windows. #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture_for_permission_profile( @@ -328,7 +226,5 @@ mod stub { } } -#[cfg(not(target_os = "windows"))] -pub use stub::run_windows_sandbox_capture; #[cfg(not(target_os = "windows"))] pub use stub::run_windows_sandbox_capture_for_permission_profile; diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index ca9d6c16f3..03d2393ed8 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -34,8 +34,6 @@ mod logging; #[cfg(target_os = "windows")] mod path_normalization; #[cfg(target_os = "windows")] -mod policy; -#[cfg(target_os = "windows")] mod process; #[cfg(target_os = "windows")] mod resolved_permissions; @@ -106,8 +104,6 @@ pub use acl::fetch_dacl_handle; #[cfg(target_os = "windows")] pub use acl::path_mask_allows; #[cfg(target_os = "windows")] -pub use audit::apply_world_writable_scan_and_denies; -#[cfg(target_os = "windows")] pub use audit::apply_world_writable_scan_and_denies_for_permissions; #[cfg(target_os = "windows")] pub use cap::load_or_create_cap_sids; @@ -137,12 +133,8 @@ pub use dpapi::protect as dpapi_protect; #[cfg(target_os = "windows")] pub use dpapi::unprotect as dpapi_unprotect; #[cfg(target_os = "windows")] -pub use elevated_impl::ElevatedSandboxCaptureRequest; -#[cfg(target_os = "windows")] pub use elevated_impl::ElevatedSandboxProfileCaptureRequest; #[cfg(target_os = "windows")] -pub use elevated_impl::run_windows_sandbox_capture as run_windows_sandbox_capture_elevated; -#[cfg(target_os = "windows")] pub use elevated_impl::run_windows_sandbox_capture_for_permission_profile as run_windows_sandbox_capture_for_permission_profile_elevated; #[cfg(target_os = "windows")] pub use helper_materialization::resolve_current_exe_for_launch; @@ -193,10 +185,6 @@ pub use logging::log_writer; #[cfg(target_os = "windows")] pub use path_normalization::canonicalize_path; #[cfg(target_os = "windows")] -pub use policy::SandboxPolicy; -#[cfg(target_os = "windows")] -pub use policy::parse_policy; -#[cfg(target_os = "windows")] pub use process::PipeSpawnHandles; #[cfg(target_os = "windows")] pub use process::StderrMode; @@ -264,8 +252,6 @@ pub use token::create_workspace_write_token_with_caps_from; #[cfg(target_os = "windows")] pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] -pub use unified_exec::spawn_windows_sandbox_session_elevated; -#[cfg(target_os = "windows")] pub use unified_exec::spawn_windows_sandbox_session_elevated_for_permission_profile; #[cfg(target_os = "windows")] pub use unified_exec::spawn_windows_sandbox_session_legacy; @@ -293,8 +279,6 @@ pub use workspace_acl::is_command_cwd_root; #[cfg(not(target_os = "windows"))] pub use stub::CaptureResult; #[cfg(not(target_os = "windows"))] -pub use stub::apply_world_writable_scan_and_denies; -#[cfg(not(target_os = "windows"))] pub use stub::run_windows_sandbox_capture; #[cfg(not(target_os = "windows"))] pub use stub::run_windows_sandbox_legacy_preflight; @@ -303,9 +287,7 @@ pub use stub::run_windows_sandbox_legacy_preflight; mod windows_impl { use super::logging::log_failure; use super::logging::log_success; - use super::policy::SandboxPolicy; use super::process::create_process_as_user; - use super::resolved_permissions::ResolvedWindowsSandboxPermissions; use super::sandbox_utils::ensure_codex_home_exists; use super::spawn_prep::LegacyAclSids; use super::spawn_prep::SpawnPrepOptions; @@ -316,6 +298,7 @@ mod windows_impl { use super::spawn_prep::prepare_legacy_spawn_context; use super::spawn_prep::root_capability_sids; use anyhow::Result; + use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::io; @@ -370,8 +353,8 @@ mod windows_impl { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, command: Vec, cwd: &Path, @@ -380,8 +363,8 @@ mod windows_impl { use_private_desktop: bool, ) -> Result { run_windows_sandbox_capture_with_filesystem_overrides( - policy_json_or_preset, - sandbox_policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, command, cwd, @@ -395,8 +378,8 @@ mod windows_impl { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture_with_filesystem_overrides( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, command: Vec, cwd: &Path, @@ -415,8 +398,8 @@ mod windows_impl { .map(AbsolutePathBuf::to_path_buf) .collect::>(); let common = prepare_legacy_spawn_context( - policy_json_or_preset, - sandbox_policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, cwd, &mut env_map, @@ -426,12 +409,11 @@ mod windows_impl { add_git_safe_directory: false, }, )?; - let policy = common.policy; let permissions = common.permissions; let current_dir = common.current_dir; let logs_base_dir = common.logs_base_dir.as_deref(); let uses_write_capabilities = common.uses_write_capabilities; - if !policy.has_full_disk_read_access() { + if !permissions.has_full_disk_read_access() { anyhow::bail!( "Restricted read-only access requires the elevated Windows sandbox backend" ); @@ -441,14 +423,14 @@ mod windows_impl { if !additional_deny_read_paths.is_empty() { anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend"); } - let capability_roots = legacy_session_capability_roots( - &policy, - sandbox_policy_cwd, - ¤t_dir, - &env_map, + let capability_roots = + legacy_session_capability_roots(&permissions, ¤t_dir, &env_map, codex_home); + let security = prepare_legacy_session_security( + uses_write_capabilities, codex_home, - ); - let security = prepare_legacy_session_security(&policy, codex_home, cwd, capability_roots)?; + cwd, + capability_roots, + )?; allow_null_device_for_workspace_write(uses_write_capabilities); apply_legacy_session_acl_rules( &permissions, @@ -595,32 +577,29 @@ mod windows_impl { } pub fn run_windows_sandbox_legacy_preflight( - sandbox_policy: &SandboxPolicy, - sandbox_policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, cwd: &Path, env_map: &HashMap, ) -> Result<()> { - let is_workspace_write = matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }); - if !is_workspace_write { + let Ok(permissions) = super::resolved_permissions::ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + ) else { + return Ok(()); + }; + if !permissions.uses_write_capabilities_for_cwd(cwd, env_map) { return Ok(()); } ensure_codex_home_exists(codex_home)?; let current_dir = cwd.to_path_buf(); - let capability_roots = legacy_session_capability_roots( - sandbox_policy, - sandbox_policy_cwd, - ¤t_dir, - env_map, - codex_home, - ); + let capability_roots = + legacy_session_capability_roots(&permissions, ¤t_dir, env_map, codex_home); let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; apply_legacy_session_acl_rules( - &ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd( - sandbox_policy, - sandbox_policy_cwd, - ), + &permissions, codex_home, ¤t_dir, env_map, @@ -638,43 +617,66 @@ mod windows_impl { #[cfg(test)] mod tests { - use crate::policy::SandboxPolicy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; + use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::NetworkSandboxPolicy; + use std::collections::HashMap; use std::path::Path; - fn workspace_policy(network_access: bool) -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } + fn workspace_profile(network_policy: NetworkSandboxPolicy) -> PermissionProfile { + PermissionProfile::workspace_write_with( + &[], + network_policy, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ) } - fn should_apply_network_block(policy: &SandboxPolicy) -> bool { - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, Path::new(".")) - .should_apply_network_block() + fn should_apply_network_block(permission_profile: &PermissionProfile) -> bool { + ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + Path::new("."), + ) + .expect("managed permissions") + .should_apply_network_block() } #[test] fn applies_network_block_when_access_is_disabled() { - assert!(should_apply_network_block(&workspace_policy( - /*network_access*/ false + assert!(should_apply_network_block(&workspace_profile( + NetworkSandboxPolicy::Restricted ))); } #[test] fn skips_network_block_when_access_is_allowed() { - assert!(!should_apply_network_block(&workspace_policy( - /*network_access*/ true + assert!(!should_apply_network_block(&workspace_profile( + NetworkSandboxPolicy::Enabled ))); } #[test] fn applies_network_block_for_read_only() { - assert!(should_apply_network_block( - &SandboxPolicy::new_read_only_policy() - )); + assert!(should_apply_network_block(&PermissionProfile::read_only())); + } + + #[test] + fn legacy_preflight_skips_profiles_without_managed_filesystem_permissions() { + for permission_profile in [ + PermissionProfile::Disabled, + PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + ] { + super::run_windows_sandbox_legacy_preflight( + &permission_profile, + Path::new("."), + Path::new("."), + Path::new("."), + &HashMap::new(), + ) + .expect("unsupported profiles do not need ACL preflight"); + } } } } @@ -683,7 +685,7 @@ mod windows_impl { mod stub { use anyhow::Result; use anyhow::bail; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::models::PermissionProfile; use std::collections::HashMap; use std::path::Path; @@ -697,8 +699,8 @@ mod stub { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( - _policy_json_or_preset: &str, - _sandbox_policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _codex_home: &Path, _command: Vec, _cwd: &Path, @@ -709,19 +711,9 @@ mod stub { bail!("Windows sandbox is only available on Windows") } - pub fn apply_world_writable_scan_and_denies( - _codex_home: &Path, - _cwd: &Path, - _env_map: &HashMap, - _sandbox_policy: &SandboxPolicy, - _logs_base_dir: Option<&Path>, - ) -> Result<()> { - bail!("Windows sandbox is only available on Windows") - } - pub fn run_windows_sandbox_legacy_preflight( - _sandbox_policy: &SandboxPolicy, - _sandbox_policy_cwd: &Path, + _permission_profile: &PermissionProfile, + _permission_profile_cwd: &Path, _codex_home: &Path, _cwd: &Path, _env_map: &HashMap, diff --git a/codex-rs/windows-sandbox-rs/src/policy.rs b/codex-rs/windows-sandbox-rs/src/policy.rs deleted file mode 100644 index cfaa9689ee..0000000000 --- a/codex-rs/windows-sandbox-rs/src/policy.rs +++ /dev/null @@ -1,61 +0,0 @@ -use anyhow::Result; -pub use codex_protocol::protocol::SandboxPolicy; - -pub fn parse_policy(value: &str) -> Result { - match value { - "read-only" => Ok(SandboxPolicy::new_read_only_policy()), - "workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()), - "danger-full-access" | "external-sandbox" => { - anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") - } - other => { - let parsed: SandboxPolicy = serde_json::from_str(other)?; - if matches!( - parsed, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) { - anyhow::bail!( - "DangerFullAccess and ExternalSandbox are not supported for sandboxing" - ); - } - Ok(parsed) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn rejects_external_sandbox_preset() { - let err = parse_policy("external-sandbox").unwrap_err(); - assert!( - err.to_string() - .contains("DangerFullAccess and ExternalSandbox are not supported") - ); - } - - #[test] - fn rejects_external_sandbox_json() { - let payload = - serde_json::to_string(&codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: codex_protocol::protocol::NetworkAccess::Enabled, - }) - .unwrap(); - let err = parse_policy(&payload).unwrap_err(); - assert!( - err.to_string() - .contains("DangerFullAccess and ExternalSandbox are not supported") - ); - } - - #[test] - fn parses_read_only_policy() { - assert_eq!( - parse_policy("read-only").unwrap(), - SandboxPolicy::new_read_only_policy() - ); - } -} diff --git a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs index dbb08b2115..75c4c216a3 100644 --- a/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs +++ b/codex-rs/windows-sandbox-rs/src/resolved_permissions.rs @@ -5,7 +5,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::path::Path; @@ -58,14 +57,6 @@ pub fn token_mode_for_permission_profile( } impl ResolvedWindowsSandboxPermissions { - pub fn from_legacy_policy_for_cwd(policy: &SandboxPolicy, cwd: &Path) -> Self { - Self { - file_system: FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(policy, cwd) - .materialize_project_roots_with_cwd(cwd), - network: NetworkSandboxPolicy::from(policy), - } - } - pub fn try_from_permission_profile(permission_profile: &PermissionProfile) -> Result { if !matches!(permission_profile, PermissionProfile::Managed { .. }) { anyhow::bail!( @@ -246,34 +237,6 @@ mod tests { assert_eq!(expected_roots, roots); } - #[test] - fn legacy_workspace_root_stays_bound_to_policy_cwd() { - let tmp = TempDir::new().expect("tempdir"); - let policy_cwd = tmp.path().join("workspace"); - let command_cwd = policy_cwd.join("subdir"); - std::fs::create_dir_all(&command_cwd).expect("create command cwd"); - - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(&policy, &policy_cwd); - - let roots = permissions - .writable_roots_for_cwd(&command_cwd, &HashMap::new()) - .into_iter() - .map(|root| root.root) - .collect::>(); - - assert_eq!( - roots, - vec![dunce::canonicalize(&policy_cwd).expect("canonical policy cwd")] - ); - } - #[test] fn permission_profile_workspace_root_stays_bound_to_profile_cwd() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index b77a874160..cc600b85ac 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -17,7 +17,6 @@ use crate::helper_materialization::helper_bin_dir; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; -use crate::policy::SandboxPolicy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; use crate::setup_error::SetupFailure; @@ -30,6 +29,7 @@ use anyhow::Result; use anyhow::anyhow; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_protocol::models::PermissionProfile; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; @@ -104,21 +104,19 @@ pub struct SetupRootOverrides { } pub fn run_setup_refresh( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, proxy_enforced: bool, ) -> Result<()> { - if matches!( - policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) { + let Ok(permissions) = ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + ) else { return Ok(()); - } - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); + }; run_setup_refresh_inner( SandboxSetupRequest { permissions: &permissions, @@ -139,22 +137,20 @@ pub fn run_setup_refresh_with_overrides( } pub fn run_setup_refresh_with_extra_read_roots( - policy: &SandboxPolicy, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, command_cwd: &Path, env_map: &HashMap, codex_home: &Path, extra_read_roots: Vec, proxy_enforced: bool, ) -> Result<()> { - if matches!( - policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) { + let Ok(permissions) = ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + ) else { return Ok(()); - } - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); + }; let mut read_roots = gather_read_roots(command_cwd, &permissions, env_map, codex_home); read_roots.extend(extra_read_roots); run_setup_refresh_inner( @@ -1020,13 +1016,15 @@ mod tests { use crate::helper_materialization::BIN_DIRNAME; use crate::helper_materialization::RESOURCES_DIRNAME; use crate::helper_materialization::helper_bin_dir; - use crate::policy::SandboxPolicy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; + use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::collections::HashSet; use std::fs; + use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; @@ -1038,10 +1036,63 @@ mod tests { } fn permissions_for( - policy: &SandboxPolicy, - policy_cwd: &std::path::Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, ) -> ResolvedWindowsSandboxPermissions { - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd) + ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + ) + .expect("managed permission profile") + } + + fn workspace_write_profile( + writable_roots: &[AbsolutePathBuf], + exclude_tmpdir_env_var: bool, + exclude_slash_tmp: bool, + ) -> PermissionProfile { + PermissionProfile::workspace_write_with( + writable_roots, + NetworkSandboxPolicy::Restricted, + exclude_tmpdir_env_var, + exclude_slash_tmp, + ) + } + + #[test] + fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() { + let tmp = TempDir::new().expect("tempdir"); + let command_cwd = tmp.path().join("workspace"); + let codex_home = tmp.path().join("codex-home"); + fs::create_dir_all(&command_cwd).expect("create workspace"); + + for permission_profile in [ + PermissionProfile::Disabled, + PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + ] { + super::run_setup_refresh( + &permission_profile, + command_cwd.as_path(), + command_cwd.as_path(), + &HashMap::new(), + codex_home.as_path(), + /*proxy_enforced*/ false, + ) + .expect("unsupported profiles do not need setup refresh"); + + super::run_setup_refresh_with_extra_read_roots( + &permission_profile, + command_cwd.as_path(), + command_cwd.as_path(), + &HashMap::new(), + codex_home.as_path(), + vec![command_cwd.clone()], + /*proxy_enforced*/ false, + ) + .expect("unsupported profiles do not need setup refresh"); + } } #[test] @@ -1361,8 +1412,8 @@ mod tests { let codex_home = tmp.path().join("codex-home"); let command_cwd = tmp.path().join("workspace"); fs::create_dir_all(&command_cwd).expect("create workspace"); - let policy = SandboxPolicy::new_read_only_policy(); - let permissions = permissions_for(&policy, &command_cwd); + let permission_profile = PermissionProfile::read_only(); + let permissions = permissions_for(&permission_profile, &command_cwd); let roots = gather_read_roots(&command_cwd, &permissions, &HashMap::new(), &codex_home); let expected = @@ -1379,16 +1430,15 @@ mod tests { let writable_root = tmp.path().join("extra-write-root"); fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&writable_root).expect("create writable root"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::from_absolute_path(&writable_root) - .expect("absolute writable root"), - ], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let permissions = permissions_for(&policy, &command_cwd); + let writable_roots = vec![ + AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"), + ]; + let permission_profile = workspace_write_profile( + &writable_roots, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let permissions = permissions_for(&permission_profile, &command_cwd); let roots = gather_read_roots(&command_cwd, &permissions, &HashMap::new(), &codex_home); let expected_writable = @@ -1401,16 +1451,14 @@ mod tests { fn build_payload_roots_preserves_helper_roots_when_read_override_is_provided() { let tmp = TempDir::new().expect("tempdir"); let codex_home = tmp.path().join("codex-home"); - let policy_cwd = tmp.path().join("policy-cwd"); + let permission_profile_cwd = tmp.path().join("permission-profile-cwd"); let command_cwd = tmp.path().join("workspace"); let readable_root = tmp.path().join("docs"); - fs::create_dir_all(&policy_cwd).expect("create policy cwd"); + fs::create_dir_all(&permission_profile_cwd).expect("create permission profile cwd"); fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&readable_root).expect("create readable root"); - let policy = SandboxPolicy::ReadOnly { - network_access: false, - }; - let permissions = permissions_for(&policy, &policy_cwd); + let permission_profile = PermissionProfile::read_only(); + let permissions = permissions_for(&permission_profile, &permission_profile_cwd); let (read_roots, write_roots) = build_payload_roots( &super::SandboxSetupRequest { @@ -1449,16 +1497,14 @@ mod tests { fn build_payload_roots_replaces_full_read_policy_when_read_override_is_provided() { let tmp = TempDir::new().expect("tempdir"); let codex_home = tmp.path().join("codex-home"); - let policy_cwd = tmp.path().join("policy-cwd"); + let permission_profile_cwd = tmp.path().join("permission-profile-cwd"); let command_cwd = tmp.path().join("workspace"); let readable_root = tmp.path().join("docs"); - fs::create_dir_all(&policy_cwd).expect("create policy cwd"); + fs::create_dir_all(&permission_profile_cwd).expect("create permission profile cwd"); fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&readable_root).expect("create readable root"); - let policy = SandboxPolicy::ReadOnly { - network_access: false, - }; - let permissions = permissions_for(&policy, &policy_cwd); + let permission_profile = PermissionProfile::read_only(); + let permissions = permissions_for(&permission_profile, &permission_profile_cwd); let (read_roots, write_roots) = build_payload_roots( &super::SandboxSetupRequest { @@ -1504,13 +1550,12 @@ mod tests { fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&extra_root).expect("create extra root"); fs::create_dir_all(&sandbox_root).expect("create sandbox root"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let permissions = permissions_for(&policy, &command_cwd); + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let permissions = permissions_for(&permission_profile, &command_cwd); let override_roots = vec![ command_cwd.clone(), extra_root.clone(), @@ -1553,21 +1598,20 @@ mod tests { } #[test] - fn effective_write_roots_use_policy_cwd_for_legacy_workspace_root() { + fn effective_write_roots_use_profile_cwd_for_workspace_root() { let tmp = TempDir::new().expect("tempdir"); let codex_home = tmp.path().join("codex-home"); - let policy_cwd = tmp.path().join("workspace"); - let command_cwd = policy_cwd.join("subdir"); + let permission_profile_cwd = tmp.path().join("workspace"); + let command_cwd = permission_profile_cwd.join("subdir"); fs::create_dir_all(&codex_home).expect("create codex home"); fs::create_dir_all(&command_cwd).expect("create command cwd"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let permissions = permissions_for(&policy, &policy_cwd); + let permission_profile = workspace_write_profile( + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let permissions = permissions_for(&permission_profile, &permission_profile_cwd); let effective_write_roots = super::effective_write_roots_for_setup( &permissions, @@ -1579,7 +1623,7 @@ mod tests { assert_eq!( effective_write_roots, - vec![dunce::canonicalize(&policy_cwd).expect("canonical policy cwd")] + vec![dunce::canonicalize(&permission_profile_cwd).expect("canonical profile cwd")] ); } @@ -1594,16 +1638,15 @@ mod tests { let explicit_deny = tmp.path().join("explicit-deny"); fs::create_dir_all(&command_git).expect("create command .git"); fs::create_dir_all(&extra_codex).expect("create extra .codex"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::from_absolute_path(&extra_write_root) - .expect("absolute writable root"), - ], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - let permissions = permissions_for(&policy, &command_cwd); + let writable_roots = vec![ + AbsolutePathBuf::from_absolute_path(&extra_write_root).expect("absolute writable root"), + ]; + let permission_profile = workspace_write_profile( + &writable_roots, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let permissions = permissions_for(&permission_profile, &command_cwd); let request = super::SandboxSetupRequest { permissions: &permissions, command_cwd: &command_cwd, @@ -1633,8 +1676,8 @@ mod tests { let codex_home = tmp.path().join("codex-home"); let command_cwd = tmp.path().join("workspace"); fs::create_dir_all(&command_cwd).expect("create workspace"); - let policy = SandboxPolicy::new_read_only_policy(); - let permissions = permissions_for(&policy, &command_cwd); + let permission_profile = PermissionProfile::read_only(); + let permissions = permissions_for(&permission_profile, &command_cwd); let roots = gather_full_read_roots_for_permissions( &command_cwd, diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index f16e964308..b68cc57a81 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -17,8 +17,6 @@ use crate::identity::SandboxCreds; use crate::identity::require_logon_sandbox_creds; use crate::logging::log_start; use crate::path_normalization::canonicalize_path; -use crate::policy::SandboxPolicy; -use crate::policy::parse_policy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; @@ -33,6 +31,7 @@ use crate::workspace_acl::protect_workspace_agents_dir; use crate::workspace_acl::protect_workspace_codex_dir; use anyhow::Context; use anyhow::Result; +use codex_protocol::models::PermissionProfile; use std::collections::HashMap; use std::ffi::c_void; use std::path::Path; @@ -41,7 +40,6 @@ use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::HANDLE; pub(crate) struct SpawnContext { - pub(crate) policy: SandboxPolicy, pub(crate) permissions: ResolvedWindowsSandboxPermissions, pub(crate) current_dir: PathBuf, pub(crate) logs_base_dir: Option, @@ -81,21 +79,18 @@ pub(crate) struct LegacyAclSids<'a> { } fn prepare_spawn_context_common( - policy_json_or_preset: &str, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, cwd: &Path, env_map: &mut HashMap, command: &[String], options: SpawnPrepOptions, ) -> Result { - let policy = parse_policy(policy_json_or_preset)?; - if matches!( - &policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - ) { - anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") - } + let permissions = ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + permission_profile_cwd, + )?; normalize_null_device_env(env_map); ensure_non_interactive_pager(env_map); @@ -112,12 +107,9 @@ fn prepare_spawn_context_common( let logs_base_dir = Some(sandbox_base); log_start(command, logs_base_dir.as_deref()); - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(&policy, policy_cwd); let uses_write_capabilities = permissions.uses_write_capabilities_for_cwd(cwd, env_map); Ok(SpawnContext { - policy, permissions, current_dir: cwd.to_path_buf(), logs_base_dir, @@ -126,8 +118,8 @@ fn prepare_spawn_context_common( } pub(crate) fn prepare_legacy_spawn_context( - policy_json_or_preset: &str, - policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, cwd: &Path, env_map: &mut HashMap, @@ -135,8 +127,8 @@ pub(crate) fn prepare_legacy_spawn_context( options: SpawnPrepOptions, ) -> Result { let common = prepare_spawn_context_common( - policy_json_or_preset, - policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, cwd, env_map, @@ -150,38 +142,31 @@ pub(crate) fn prepare_legacy_spawn_context( } pub(crate) fn prepare_legacy_session_security( - policy: &SandboxPolicy, + uses_write_capabilities: bool, codex_home: &Path, cwd: &Path, capability_roots: impl IntoIterator, ) -> Result { let caps = load_or_create_cap_sids(codex_home)?; let (h_token, readonly_sid, readonly_sid_str, write_root_sids) = unsafe { - match policy { - SandboxPolicy::ReadOnly { .. } => { - let psid = LocalSid::from_string(&caps.readonly)?; - let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?; - (h_token, Some(psid), Some(caps.readonly), Vec::new()) - } - SandboxPolicy::WorkspaceWrite { .. } => { - let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; - if write_root_sids.is_empty() { - anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); - } - let base = get_current_token_for_restriction()?; - let cap_ptrs: Vec<*mut c_void> = write_root_sids - .iter() - .map(|root| root.sid.as_ptr()) - .collect(); - let h_token = - create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()); - CloseHandle(base); - let h_token = h_token?; - (h_token, None, None, write_root_sids) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!("dangerous policies rejected before legacy session prep") + if uses_write_capabilities { + let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; + if write_root_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); } + let base = get_current_token_for_restriction()?; + let cap_ptrs: Vec<*mut c_void> = write_root_sids + .iter() + .map(|root| root.sid.as_ptr()) + .collect(); + let h_token = create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()); + CloseHandle(base); + let h_token = h_token?; + (h_token, None, None, write_root_sids) + } else { + let psid = LocalSid::from_string(&caps.readonly)?; + let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?; + (h_token, Some(psid), Some(caps.readonly), Vec::new()) } }; @@ -194,21 +179,18 @@ pub(crate) fn prepare_legacy_session_security( } pub(crate) fn legacy_session_capability_roots( - policy: &SandboxPolicy, - policy_cwd: &Path, + permissions: &ResolvedWindowsSandboxPermissions, current_dir: &Path, env_map: &HashMap, codex_home: &Path, ) -> Vec { - let permissions = - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, policy_cwd); - let allow_paths = compute_allow_paths_for_permissions(&permissions, current_dir, env_map) + let allow_paths = compute_allow_paths_for_permissions(permissions, current_dir, env_map) .allow .into_iter() .collect::>(); if permissions.uses_write_capabilities_for_cwd(current_dir, env_map) { effective_write_roots_for_permissions( - &permissions, + permissions, current_dir, env_map, codex_home, @@ -459,7 +441,6 @@ pub(crate) fn prepare_elevated_spawn_context_for_permissions( #[cfg(test)] mod tests { - use super::SandboxPolicy; use super::SpawnPrepOptions; use super::deny_root_capabilities_for_path; use super::legacy_session_capability_roots; @@ -469,34 +450,52 @@ mod tests { use crate::cap::load_or_create_cap_sids; use crate::cap::workspace_write_cap_sid_for_root; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; + use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; - fn should_apply_network_block(policy: &SandboxPolicy) -> bool { - ResolvedWindowsSandboxPermissions::from_legacy_policy_for_cwd(policy, Path::new(".")) - .should_apply_network_block() + fn workspace_profile( + network_policy: NetworkSandboxPolicy, + writable_roots: &[AbsolutePathBuf], + exclude_tmpdir_env_var: bool, + exclude_slash_tmp: bool, + ) -> PermissionProfile { + PermissionProfile::workspace_write_with( + writable_roots, + network_policy, + exclude_tmpdir_env_var, + exclude_slash_tmp, + ) + } + + fn should_apply_network_block(permission_profile: &PermissionProfile) -> bool { + ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + permission_profile, + Path::new("."), + ) + .expect("managed permission profile") + .should_apply_network_block() } #[test] fn no_network_env_rewrite_applies_for_workspace_write() { assert!(should_apply_network_block( - &SandboxPolicy::new_workspace_write_policy(), + &PermissionProfile::workspace_write() )); } #[test] fn no_network_env_rewrite_skips_when_network_access_is_allowed() { - assert!(!should_apply_network_block( - &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - )); + assert!(!should_apply_network_block(&workspace_profile( + NetworkSandboxPolicy::Enabled, + &[], + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ))); } #[test] @@ -506,7 +505,7 @@ mod tests { let mut env_map = HashMap::new(); let _context = prepare_legacy_spawn_context( - "workspace-write", + &PermissionProfile::workspace_write(), cwd.path(), codex_home.path(), cwd.path(), @@ -536,7 +535,7 @@ mod tests { )]); let context = prepare_spawn_context_common( - "workspace-write", + &PermissionProfile::workspace_write(), cwd.path(), codex_home.path(), cwd.path(), @@ -548,7 +547,7 @@ mod tests { }, ) .expect("preserve existing env prep"); - assert_eq!(context.policy, SandboxPolicy::new_workspace_write_policy()); + assert!(context.uses_write_capabilities); assert_eq!(env_map.get("SBX_NONET_ACTIVE"), None); assert_eq!( @@ -558,24 +557,28 @@ mod tests { } #[test] - fn legacy_session_capability_roots_use_policy_cwd_for_workspace_root() { + fn legacy_session_capability_roots_use_profile_cwd_for_workspace_root() { let tmp = TempDir::new().expect("tempdir"); let codex_home = tmp.path().join("codex-home"); - let policy_cwd = tmp.path().join("workspace"); - let command_cwd = policy_cwd.join("subdir"); + let permission_profile_cwd = tmp.path().join("workspace"); + let command_cwd = permission_profile_cwd.join("subdir"); std::fs::create_dir_all(&codex_home).expect("create codex home"); std::fs::create_dir_all(&command_cwd).expect("create command cwd"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; + let permission_profile = workspace_profile( + NetworkSandboxPolicy::Restricted, + &[], + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + let permissions = ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + &permission_profile, + &permission_profile_cwd, + ) + .expect("managed permission profile"); let roots = legacy_session_capability_roots( - &policy, - &policy_cwd, + &permissions, &command_cwd, &HashMap::new(), &codex_home, @@ -583,7 +586,7 @@ mod tests { assert_eq!( roots, - vec![dunce::canonicalize(&policy_cwd).expect("canonical policy cwd")] + vec![dunce::canonicalize(&permission_profile_cwd).expect("canonical profile cwd")] ); } @@ -672,24 +675,25 @@ mod tests { std::fs::create_dir_all(&active_root).expect("create active root"); std::fs::create_dir_all(&sandbox_root).expect("create sandbox root"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![ - AbsolutePathBuf::try_from(active_root.as_path()).expect("active root"), - AbsolutePathBuf::try_from(codex_home.as_path()).expect("codex home"), - AbsolutePathBuf::try_from(sandbox_root.as_path()).expect("sandbox root"), - ], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - let roots = legacy_session_capability_roots( - &policy, - &workspace, - &workspace, - &HashMap::new(), - &codex_home, + let writable_roots = vec![ + AbsolutePathBuf::try_from(active_root.as_path()).expect("active root"), + AbsolutePathBuf::try_from(codex_home.as_path()).expect("codex home"), + AbsolutePathBuf::try_from(sandbox_root.as_path()).expect("sandbox root"), + ]; + let permission_profile = workspace_profile( + NetworkSandboxPolicy::Restricted, + &writable_roots, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, ); + let permissions = ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_cwd( + &permission_profile, + &workspace, + ) + .expect("managed permission profile"); + + let roots = + legacy_session_capability_roots(&permissions, &workspace, &HashMap::new(), &codex_home); assert!(roots.contains(&dunce::canonicalize(&workspace).expect("workspace"))); assert!(roots.contains(&dunce::canonicalize(&active_root).expect("active root"))); diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs index 1e13e45cc0..09ac786238 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs @@ -8,7 +8,6 @@ use crate::ipc_framed::FramedMessage; use crate::ipc_framed::IPC_PROTOCOL_VERSION; use crate::ipc_framed::Message; use crate::ipc_framed::SpawnRequest; -use crate::policy::parse_policy; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::runner_client::spawn_runner_transport; use crate::spawn_prep::prepare_elevated_spawn_context_for_permissions; @@ -145,44 +144,3 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil stdin_open, )) } - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn spawn_windows_sandbox_session_elevated( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, - codex_home: &Path, - command: Vec, - cwd: &Path, - env_map: HashMap, - timeout_ms: Option, - read_roots_override: Option<&[PathBuf]>, - read_roots_include_platform_defaults: bool, - write_roots_override: Option<&[PathBuf]>, - deny_read_paths_override: &[AbsolutePathBuf], - deny_write_paths_override: &[AbsolutePathBuf], - tty: bool, - stdin_open: bool, - use_private_desktop: bool, -) -> Result { - let policy = parse_policy(policy_json_or_preset)?; - let permission_profile = - PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, sandbox_policy_cwd); - spawn_windows_sandbox_session_elevated_for_permission_profile( - &permission_profile, - sandbox_policy_cwd, - codex_home, - command, - cwd, - env_map, - timeout_ms, - read_roots_override, - read_roots_include_platform_defaults, - write_roots_override, - deny_read_paths_override, - deny_write_paths_override, - tty, - stdin_open, - use_private_desktop, - ) - .await -} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index 4db782a601..2fc8c12e00 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -17,6 +17,7 @@ use crate::spawn_prep::legacy_session_capability_roots; use crate::spawn_prep::prepare_legacy_session_security; use crate::spawn_prep::prepare_legacy_spawn_context; use anyhow::Result; +use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; use codex_utils_pty::SpawnedProcess; @@ -269,8 +270,8 @@ fn resize_conpty_handle(hpc: &Arc>>, size: TerminalSize) #[allow(clippy::too_many_arguments)] pub(crate) async fn spawn_windows_sandbox_session_legacy( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, command: Vec, cwd: &Path, @@ -283,8 +284,8 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( use_private_desktop: bool, ) -> Result { let common = prepare_legacy_spawn_context( - policy_json_or_preset, - sandbox_policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, cwd, &mut env_map, @@ -294,7 +295,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( add_git_safe_directory: false, }, )?; - if !common.policy.has_full_disk_read_access() { + if !common.permissions.has_full_disk_read_access() { anyhow::bail!("Restricted read-only access requires the elevated Windows sandbox backend"); } // WRITE_RESTRICTED tokens consult restricting SIDs only for writes, so this @@ -307,14 +308,17 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( .map(AbsolutePathBuf::to_path_buf) .collect::>(); let capability_roots = legacy_session_capability_roots( - &common.policy, - sandbox_policy_cwd, + &common.permissions, &common.current_dir, &env_map, codex_home, ); - let security = - prepare_legacy_session_security(&common.policy, codex_home, cwd, capability_roots)?; + let security = prepare_legacy_session_security( + common.uses_write_capabilities, + codex_home, + cwd, + capability_roots, + )?; allow_null_device_for_workspace_write(common.uses_write_capabilities); apply_legacy_session_acl_rules( diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs index ceb4bd03b6..e327200426 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs @@ -19,8 +19,8 @@ use std::path::PathBuf; #[allow(clippy::too_many_arguments)] pub async fn spawn_windows_sandbox_session_legacy( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, + permission_profile: &PermissionProfile, + permission_profile_cwd: &Path, codex_home: &Path, command: Vec, cwd: &Path, @@ -33,8 +33,8 @@ pub async fn spawn_windows_sandbox_session_legacy( use_private_desktop: bool, ) -> Result { backends::legacy::spawn_windows_sandbox_session_legacy( - policy_json_or_preset, - sandbox_policy_cwd, + permission_profile, + permission_profile_cwd, codex_home, command, cwd, @@ -87,44 +87,6 @@ pub async fn spawn_windows_sandbox_session_elevated_for_permission_profile( .await } -#[allow(clippy::too_many_arguments)] -pub async fn spawn_windows_sandbox_session_elevated( - policy_json_or_preset: &str, - sandbox_policy_cwd: &Path, - codex_home: &Path, - command: Vec, - cwd: &Path, - env_map: HashMap, - timeout_ms: Option, - read_roots_override: Option<&[PathBuf]>, - read_roots_include_platform_defaults: bool, - write_roots_override: Option<&[PathBuf]>, - deny_read_paths_override: &[AbsolutePathBuf], - deny_write_paths_override: &[AbsolutePathBuf], - tty: bool, - stdin_open: bool, - use_private_desktop: bool, -) -> Result { - backends::elevated::spawn_windows_sandbox_session_elevated( - policy_json_or_preset, - sandbox_policy_cwd, - codex_home, - command, - cwd, - env_map, - timeout_ms, - read_roots_override, - read_roots_include_platform_defaults, - write_roots_override, - deny_read_paths_override, - deny_write_paths_override, - tty, - stdin_open, - use_private_desktop, - ) - .await -} - #[cfg(test)] pub(crate) use backends::windows_common::finish_driver_spawn; #[cfg(test)] diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs index e2830ad8a1..b08d7a6b2f 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -5,6 +5,7 @@ use crate::ipc_framed::Message; use crate::ipc_framed::decode_bytes; use crate::ipc_framed::read_frame; use crate::run_windows_sandbox_capture; +use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; use pretty_assertions::assert_eq; @@ -149,8 +150,9 @@ fn legacy_non_tty_cmd_emits_output() { let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-non-tty-cmd"); println!("cmd codex_home={}", codex_home.path().display()); + let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ @@ -189,8 +191,9 @@ fn legacy_non_tty_cmd_rejects_deny_read_overrides() { let secret_path = AbsolutePathBuf::from_absolute_path(cwd.join("legacy-non-tty-deny-read-secret.env")) .expect("absolute deny-read fixture path"); + let permission_profile = PermissionProfile::workspace_write(); let err = spawn_windows_sandbox_session_legacy( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ @@ -228,8 +231,9 @@ fn legacy_non_tty_powershell_emits_output() { let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-non-tty-pwsh"); println!("pwsh codex_home={}", codex_home.path().display()); + let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ @@ -413,8 +417,9 @@ fn legacy_capture_powershell_emits_output() { let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-capture-pwsh"); println!("capture pwsh codex_home={}", codex_home.path().display()); + let permission_profile = PermissionProfile::workspace_write(); let result = run_windows_sandbox_capture( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ @@ -452,8 +457,9 @@ fn legacy_tty_powershell_emits_output_and_accepts_input() { let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-tty-pwsh"); println!("tty pwsh codex_home={}", codex_home.path().display()); + let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ @@ -505,8 +511,9 @@ fn legacy_tty_cmd_emits_output_and_accepts_input() { let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-tty-cmd"); println!("tty cmd codex_home={}", codex_home.path().display()); + let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ @@ -558,8 +565,9 @@ fn legacy_tty_cmd_default_desktop_emits_output_and_accepts_input() { "tty cmd default desktop codex_home={}", codex_home.path().display() ); + let permission_profile = PermissionProfile::workspace_write(); let spawned = spawn_windows_sandbox_session_legacy( - "workspace-write", + &permission_profile, cwd.as_path(), codex_home.path(), vec![ From 46391f7efa0aa06a8074f47bc01d2651d95889b0 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 26 May 2026 15:49:37 -0700 Subject: [PATCH 013/204] [codex] remove plain image wrapper spans (#24652) ## Why Remote image submissions currently wrap native `input_image` spans in literal `` and `` text spans. Those extra prompt tokens add structure without providing label or routing information. ## What Changed - Serialize `UserInput::Image` directly as an `input_image` content span. - Preserve named local-image framing and legacy wrapper parsing for labeled attachments and existing histories. - Update existing request-shape expectations for drag-and-drop images, model switching, and compaction. ## Validation - `just test -p codex-protocol` - Focused `codex-core` run covering `drag_drop_image_persists_rollout_request_shape`, `model_change_from_image_to_text_strips_prior_image_content`, and `snapshot_request_shape_pre_turn_compaction_including_incoming_user_message` ## Notes - A broader `just test -p codex-core` run was attempted; the affected tests passed, while the overall run failed in unrelated CLI, MCP, and tooling tests plus a `thread_manager` timeout. --- codex-rs/core/tests/suite/image_rollout.rs | 6 --- codex-rs/core/tests/suite/model_switching.rs | 13 ----- ..._compaction_including_incoming_shapes.snap | 8 ++- codex-rs/protocol/src/models.rs | 54 +++++-------------- 4 files changed, 16 insertions(+), 65 deletions(-) diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 67a79d603f..87eb03079e 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -257,16 +257,10 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> id: None, role: "user".to_string(), content: vec![ - ContentItem::InputText { - text: codex_protocol::models::image_open_tag_text(), - }, ContentItem::InputImage { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }, - ContentItem::InputText { - text: codex_protocol::models::image_close_tag_text(), - }, ContentItem::InputText { text: "dropped image".to_string(), }, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index fd237a8d5c..3c4dd40b32 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -555,19 +555,6 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< .any(|text| text == "image content omitted because you do not support image input"), "second request should include the image-omitted placeholder text" ); - assert!( - second_user_texts - .iter() - .any(|text| text == &codex_protocol::models::image_open_tag_text()), - "second request should preserve the image open tag text" - ); - assert!( - second_user_texts - .iter() - .any(|text| text == &codex_protocol::models::image_close_tag_text()), - "second request should preserve the image close tag text" - ); - Ok(()) } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap index 2edd63d3d2..679081353e 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_including_incoming_shapes.snap @@ -19,8 +19,6 @@ Scenario: Pre-turn auto-compaction with a context override emits the context dif 02:message/user:\nPRE_TURN_SUMMARY 03:message/developer: 04:message/user: -05:message/user[4]: - [01] - [02] - [03] - [04] USER_THREE +05:message/user[2]: + [01] + [02] USER_THREE diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 6112ea8a6c..85c5a5d704 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1238,18 +1238,10 @@ impl From> for ResponseInputItem { UserInput::Image { image_url, detail } => { image_index += 1; let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL); - vec![ - ContentItem::InputText { - text: image_open_tag_text(), - }, - ContentItem::InputImage { - image_url, - detail: Some(detail), - }, - ContentItem::InputText { - text: image_close_tag_text(), - }, - ] + vec![ContentItem::InputImage { + image_url, + detail: Some(detail), + }] } UserInput::LocalImage { path, detail } => { image_index += 1; @@ -2622,7 +2614,7 @@ mod tests { } #[test] - fn wraps_image_user_input_with_tags() -> Result<()> { + fn serializes_image_user_input_without_tags() -> Result<()> { let image_url = "data:image/png;base64,abc".to_string(); let item = ResponseInputItem::from(vec![UserInput::Image { @@ -2632,18 +2624,10 @@ mod tests { match item { ResponseInputItem::Message { content, .. } => { - let expected = vec![ - ContentItem::InputText { - text: image_open_tag_text(), - }, - ContentItem::InputImage { - image_url, - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - ContentItem::InputText { - text: image_close_tag_text(), - }, - ]; + let expected = vec![ContentItem::InputImage { + image_url, + detail: Some(DEFAULT_IMAGE_DETAIL), + }]; assert_eq!(content, expected); } other => panic!("expected message response but got {other:?}"), @@ -2664,7 +2648,7 @@ mod tests { match item { ResponseInputItem::Message { content, .. } => { assert_eq!( - content.get(1), + content.first(), Some(&ContentItem::InputImage { image_url, detail: Some(ImageDetail::Original), @@ -2862,35 +2846,23 @@ mod tests { ResponseInputItem::Message { content, .. } => { assert_eq!( content.first(), - Some(&ContentItem::InputText { - text: image_open_tag_text(), - }) - ); - assert_eq!( - content.get(1), Some(&ContentItem::InputImage { image_url, detail: Some(DEFAULT_IMAGE_DETAIL), }) ); assert_eq!( - content.get(2), - Some(&ContentItem::InputText { - text: image_close_tag_text(), - }) - ); - assert_eq!( - content.get(3), + content.get(1), Some(&ContentItem::InputText { text: local_image_open_tag_text(/*label_number*/ 2), }) ); assert!(matches!( - content.get(4), + content.get(2), Some(ContentItem::InputImage { .. }) )); assert_eq!( - content.get(5), + content.get(3), Some(&ContentItem::InputText { text: image_close_tag_text(), }) From 9826581e7b2c130bc389f4490cd746851eb79af0 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 26 May 2026 15:59:25 -0700 Subject: [PATCH 014/204] Attach Windows sandbox log to feedback reports (#24623) ## Why Windows sandbox diagnostics are currently hard to recover from `/feedback` even though they are often the most useful artifact when debugging sandbox behavior. Now that sandbox logging uses daily rolling files, feedback can safely include the current day's sandbox log without uploading the old ever-growing legacy `sandbox.log`. ## What changed - Add a `codex-windows-sandbox` helper that resolves the current daily sandbox log from `codex_home`. - When feedback is submitted with logs enabled on Windows, app-server attaches today's sandbox log if it exists. - Upload the attachment under the stable filename `windows-sandbox.log`, independent of the dated on-disk filename. - Keep existing raw `extra_log_files` behavior unchanged for rollout and desktop log attachments. ## Verification - `cargo fmt -p codex-app-server -p codex-windows-sandbox` - `cargo test -p codex-windows-sandbox current_log_file_path_for_codex_home_uses_sandbox_dir` - `cargo test -p codex-app-server windows_sandbox_log_attachment_uses_current_log` - Manual CLI/TUI `/feedback` test confirmed Sentry received `windows-sandbox.log`. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 3 ++ .../request_processors/feedback_processor.rs | 51 +++++++++++++++++++ codex-rs/feedback/src/lib.rs | 2 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 33 ++++++++++++ ...ck_upload_consent_lists_doctor_report.snap | 2 +- ...sts_windows_sandbox_log_when_included.snap | 13 +++++ codex-rs/tui/src/chatwidget.rs | 7 +++ ..._tests__feedback_upload_consent_popup.snap | 2 + .../chatwidget/tests/popups_and_settings.rs | 2 + codex-rs/windows-sandbox-rs/src/lib.rs | 2 + codex-rs/windows-sandbox-rs/src/logging.rs | 14 +++++ 12 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 044146d9de..1b1bc0e59a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1929,6 +1929,7 @@ dependencies = [ "codex-utils-json-to-toml", "codex-utils-pty", "codex-web-search-extension", + "codex-windows-sandbox", "core_test_support", "flate2", "futures", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 17ecd7dc3f..09e3197b77 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -94,6 +94,9 @@ tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } uuid = { workspace = true, features = ["serde", "v7"] } +[target.'cfg(windows)'.dependencies] +codex-windows-sandbox = { workspace = true } + [dev-dependencies] app_test_support = { workspace = true } axum = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 18f519d3c0..73da67f735 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(target_os = "windows")] +use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; #[derive(Clone)] pub(crate) struct FeedbackRequestProcessor { @@ -186,6 +188,12 @@ impl FeedbackRequestProcessor { )), }); } + if let Some(sandbox_log_attachment) = + windows_sandbox_log_attachment(&self.config.codex_home) + && seen_attachment_paths.insert(sandbox_log_attachment.path.clone()) + { + attachment_paths.push(sandbox_log_attachment); + } } if let Some(extra_log_files) = extra_log_files { for extra_log_file in extra_log_files { @@ -264,3 +272,46 @@ impl FeedbackRequestProcessor { fn auto_review_rollout_filename(thread_id: ThreadId) -> String { format!("auto-review-rollout-{thread_id}.jsonl") } + +#[cfg(target_os = "windows")] +fn windows_sandbox_log_attachment(codex_home: &Path) -> Option { + let sandbox_log_path = codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home); + sandbox_log_path + .is_file() + .then_some(FeedbackAttachmentPath { + path: sandbox_log_path, + attachment_filename_override: Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()), + }) +} + +#[cfg(not(target_os = "windows"))] +fn windows_sandbox_log_attachment(_codex_home: &Path) -> Option { + None +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn windows_sandbox_log_attachment_uses_current_log() { + let codex_home = tempfile::tempdir().expect("create tempdir"); + let sandbox_dir = codex_windows_sandbox::sandbox_dir(codex_home.path()); + std::fs::create_dir_all(&sandbox_dir).expect("create sandbox dir"); + let sandbox_log_path = + codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home.path()); + std::fs::write(&sandbox_log_path, "sandbox log").expect("write sandbox log"); + + let attachment = windows_sandbox_log_attachment(codex_home.path()) + .map(|attachment| (attachment.path, attachment.attachment_filename_override)); + + assert_eq!( + attachment, + Some(( + sandbox_log_path, + Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()) + )) + ); + } +} diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 9a8caf489a..7c27d2b3b0 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -29,6 +29,8 @@ pub use feedback_diagnostics::FeedbackDiagnostics; /// Filename used for the redacted `codex doctor --json` feedback attachment. pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json"; +/// Filename used for the Windows sandbox log feedback attachment. +pub const WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME: &str = "windows-sandbox.log"; const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 04bcbd0b00..2770598fc4 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,6 +1,7 @@ use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; use codex_feedback::FeedbackDiagnostics; +use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -472,6 +473,7 @@ pub(crate) fn feedback_upload_consent_params( category: FeedbackCategory, rollout_path: Option, auto_review_rollout_filename: Option, + include_windows_sandbox_log: bool, feedback_diagnostics: &FeedbackDiagnostics, ) -> super::SelectionViewParams { use super::popup_consts::standard_popup_hint_line; @@ -509,6 +511,15 @@ pub(crate) fn feedback_upload_consent_params( ]) .into(), ]; + if include_windows_sandbox_log { + header_lines.push( + Line::from(vec![ + " • ".into(), + WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.into(), + ]) + .into(), + ); + } if let Some(path) = rollout_path.as_deref() && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) { @@ -696,6 +707,7 @@ mod tests { FeedbackCategory::Bug, Some(std::path::PathBuf::from("rollout.jsonl")), Some("auto-review-rollout.jsonl".to_string()), + /*include_windows_sandbox_log*/ false, &FeedbackDiagnostics::default(), ); @@ -704,6 +716,27 @@ mod tests { insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered); } + #[test] + fn feedback_upload_consent_lists_windows_sandbox_log_when_included() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + /*include_windows_sandbox_log*/ true, + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!( + "feedback_upload_consent_lists_windows_sandbox_log_when_included", + rendered + ); + } + #[test] fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap index 6cca54542d..e91d736ecd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -1,6 +1,6 @@ --- source: tui/src/bottom_pane/feedback_view.rs -assertion_line: 704 +assertion_line: 716 expression: rendered --- Upload logs? diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap new file mode 100644 index 0000000000..741505d5f5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_windows_sandbox_log_when_included.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 734 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • windows-sandbox.log + • rollout.jsonl + • auto-review-rollout.jsonl diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b9c5101187..16ac980f35 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -977,12 +977,19 @@ impl ChatWidget { pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let snapshot = self.feedback.snapshot(self.thread_id); + #[cfg(target_os = "windows")] + let include_windows_sandbox_log = + codex_windows_sandbox::current_log_file_path_for_codex_home(&self.config.codex_home) + .is_file(); + #[cfg(not(target_os = "windows"))] + let include_windows_sandbox_log = false; let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), category, self.current_rollout_path.clone(), self.thread_id .map(|thread_id| format!("auto-review-rollout-{thread_id}.jsonl")), + include_windows_sandbox_log, snapshot.feedback_diagnostics(), ); self.bottom_pane.show_selection_view(params); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index 354e473a18..b860cf8fb3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs +assertion_line: 2562 expression: popup --- Upload logs? @@ -7,6 +8,7 @@ expression: popup The following files will be sent: • codex-logs.log • codex-doctor-report.json + • windows-sandbox.log • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 3a4bdd1329..55aadefb6d 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2621,6 +2621,7 @@ async fn feedback_upload_consent_popup_snapshot() { crate::app_event::FeedbackCategory::Bug, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), + /*include_windows_sandbox_log*/ true, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), @@ -2641,6 +2642,7 @@ async fn feedback_good_result_consent_popup_includes_connectivity_diagnostics_fi crate::app_event::FeedbackCategory::GoodResult, chat.current_rollout_path.clone(), Some("auto-review-rollout-thread-1.jsonl".to_string()), + /*include_windows_sandbox_log*/ false, &codex_feedback::FeedbackDiagnostics::new(vec![codex_feedback::FeedbackDiagnostic { headline: "Proxy environment variables are set and may affect connectivity." .to_string(), diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 03d2393ed8..26e22dde07 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -177,6 +177,8 @@ pub use ipc_framed::write_frame; #[cfg(target_os = "windows")] pub use logging::current_log_file_path; #[cfg(target_os = "windows")] +pub use logging::current_log_file_path_for_codex_home; +#[cfg(target_os = "windows")] pub use logging::log_file_path_for_utc_date; #[cfg(target_os = "windows")] pub use logging::log_note; diff --git a/codex-rs/windows-sandbox-rs/src/logging.rs b/codex-rs/windows-sandbox-rs/src/logging.rs index f5e8399709..7108a1de36 100644 --- a/codex-rs/windows-sandbox-rs/src/logging.rs +++ b/codex-rs/windows-sandbox-rs/src/logging.rs @@ -43,6 +43,10 @@ pub fn current_log_file_path(base_dir: &Path) -> PathBuf { log_file_path_for_utc_date(base_dir, chrono::Utc::now().date_naive()) } +pub fn current_log_file_path_for_codex_home(codex_home: &Path) -> PathBuf { + current_log_file_path(&crate::sandbox_dir(codex_home)) +} + pub fn log_writer(base_dir: &Path) -> Option { if !base_dir.is_dir() { return None; @@ -142,4 +146,14 @@ mod tests { PathBuf::from("logs").join("sandbox.2026-05-21.log") ); } + + #[test] + fn current_log_file_path_for_codex_home_uses_sandbox_dir() { + let codex_home = Path::new("codex-home"); + + assert_eq!( + current_log_file_path_for_codex_home(codex_home), + current_log_file_path(&codex_home.join(".sandbox")) + ); + } } From dc4e54d06152b3bb91a4e9989299b094d20ad827 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Tue, 26 May 2026 16:24:33 -0700 Subject: [PATCH 015/204] Restore legacy image detail values (#24644) ## Why Older persisted rollouts can contain `input_image.detail` values of `auto` or `low` from before `ImageDetail` was narrowed to `high`/`original`. Current deserialization rejects those values, which can make resume skip later compacted checkpoints and reconstruct an oversized raw suffix before the next compaction attempt. Confirmed Sentry reports fixed by this compatibility path: - [CODEX-1H3F](https://openai.sentry.io/issues/7500642496/) - [CODEX-1H6N](https://openai.sentry.io/issues/7501025347/) - [CODEX-1JDP](https://openai.sentry.io/issues/7504549065/) - [CODEX-1HW6](https://openai.sentry.io/issues/7503407986/) ## Background [openai/codex#20693](https://github.com/openai/codex/pull/20693) added image-detail plumbing for app-server `UserInput` so input images could explicitly request `detail: original`. The Slack discussion behind that PR was about ScreenSpot / bridge evals where user input images were resized, while tool output images already had MCP/code-mode ways to request image detail. In review, the intended new API surface was narrowed to `high` and `original`: default to `high`, allow `original` when callers need unchanged image handling, and avoid encouraging new `auto` or `low` usage. That policy still makes sense for newly emitted values. The missing compatibility piece is persisted history. Older rollouts can already contain `auto` and `low`, and resume reconstructs typed history by deserializing those rollout records. Rejecting old values at that boundary causes valid compacted checkpoints to be skipped. This PR restores `auto` and `low` as real variants so old records deserialize and round-trip without being rewritten as `high`, while product paths can continue to default to `high` and avoid emitting `auto` for new behavior. ## What changed - Restored `ImageDetail::Auto` and `ImageDetail::Low` as first-class protocol values. - Preserved `auto`/`low` through rollout deserialization, MCP image metadata, code-mode image output, and schema/type generation. - Kept local image byte handling conservative: only `original` switches to original-resolution loading; `auto`/`low`/`high` continue through the resize-to-fit path while retaining their detail value. - Added regression coverage for enum round-tripping and code-mode `low` detail handling. ## Testing - `just write-app-server-schema` - `just test -p codex-protocol` - `just test -p codex-tools` - `just test -p codex-code-mode` - `just test -p codex-app-server-protocol` - `just test -p codex-core suite::rmcp_client::stdio_image_responses_preserve_original_detail_metadata` - `just test -p codex-core suite::code_mode::code_mode_can_use_mcp_image_result_with_image_helper` - Loaded broken rollouts on local fixed builds, and started/completed new turns. I also attempted `just test -p codex-core`; the local broad run did not finish green: 2559 tests run, 2467 passed, 55 flaky, 91 failed, 1 timed out. The failures were broad timeout/deadline failures across unrelated areas; targeted changed-path core tests above passed. --- .../schema/json/ClientRequest.json | 2 + .../schema/json/ServerNotification.json | 2 + .../codex_app_server_protocol.schemas.json | 2 + .../codex_app_server_protocol.v2.schemas.json | 2 + .../json/v2/ItemCompletedNotification.json | 2 + .../json/v2/ItemStartedNotification.json | 2 + .../RawResponseItemCompletedNotification.json | 2 + .../schema/json/v2/ReviewStartResponse.json | 2 + .../schema/json/v2/ThreadForkResponse.json | 2 + .../schema/json/v2/ThreadListResponse.json | 2 + .../json/v2/ThreadMetadataUpdateResponse.json | 2 + .../schema/json/v2/ThreadReadResponse.json | 2 + .../schema/json/v2/ThreadResumeParams.json | 2 + .../schema/json/v2/ThreadResumeResponse.json | 2 + .../json/v2/ThreadRollbackResponse.json | 2 + .../schema/json/v2/ThreadStartResponse.json | 2 + .../json/v2/ThreadStartedNotification.json | 2 + .../json/v2/ThreadUnarchiveResponse.json | 2 + .../json/v2/TurnCompletedNotification.json | 2 + .../schema/json/v2/TurnStartParams.json | 2 + .../schema/json/v2/TurnStartResponse.json | 2 + .../json/v2/TurnStartedNotification.json | 2 + .../schema/json/v2/TurnSteerParams.json | 2 + .../schema/typescript/ImageDetail.ts | 2 +- codex-rs/code-mode/src/description.rs | 2 +- codex-rs/code-mode/src/response.rs | 2 + codex-rs/code-mode/src/runtime/value.rs | 8 +++- codex-rs/code-mode/src/service.rs | 38 ++++++++++++++++++- .../src/tools/code_mode/response_adapter.rs | 2 + codex-rs/protocol/src/models.rs | 36 +++++++++++++++++- codex-rs/tools/src/image_detail.rs | 2 +- codex-rs/tools/src/image_detail_tests.rs | 12 +++++- 32 files changed, 140 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 552defad1b..63e8fbde1a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1085,6 +1085,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 90899cb152..2c290558da 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2031,6 +2031,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ab19f3e770..28dc006b5e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10160,6 +10160,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b12b0eedd8..d1c59e9855 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6640,6 +6640,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 87d36d2927..cea3dae7d2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -287,6 +287,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index d45fddf4ba..529667388e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -287,6 +287,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index e69b342282..f82acbae64 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -165,6 +165,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 877eeb1278..df93fbbea5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 8c45f57d38..a9a2529fc5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -529,6 +529,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index a9a186228b..8ccceaaa7c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index a2a4834cb3..87af03ea62 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 5d76f9b181..a6d86c3485 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index d4bdeda0ed..58b85d41a4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -224,6 +224,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 8055ff58fc..b4fa181c88 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -529,6 +529,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index d0e25a1eaa..4256d13ec4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index d456ccebc4..6ab655a8bc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -529,6 +529,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index dd7de34b82..91bc13a9c6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 5929510520..0b68bfa957 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -450,6 +450,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index ddaab863f7..4856f73529 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index ffc130dcc9..17b69a698e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -123,6 +123,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index a2836f7f03..c5e48ae6ce 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 8c57890a3b..075699a34e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -424,6 +424,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index ef05b27673..6f5452de66 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -44,6 +44,8 @@ }, "ImageDetail": { "enum": [ + "auto", + "low", "high", "original" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts index 5a62cc32f1..a48f07c088 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ImageDetail.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ImageDetail = "high" | "original"; +export type ImageDetail = "auto" | "low" | "high" | "original"; diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 18d3c4555c..0c2813e51a 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -24,7 +24,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Global helpers: - `exit()`: Immediately ends the current script successfully (like an early return from the top level). - `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible. -- `image(imageUrlOrItem: string | { image_url: string; detail?: "high" | "original" | null } | ImageContent, detail?: "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. +- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument. - `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`. diff --git a/codex-rs/code-mode/src/response.rs b/codex-rs/code-mode/src/response.rs index ae92639cc0..0ac3a03770 100644 --- a/codex-rs/code-mode/src/response.rs +++ b/codex-rs/code-mode/src/response.rs @@ -4,6 +4,8 @@ use serde::Serialize; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { + Auto, + Low, High, Original, } diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode/src/runtime/value.rs index 865b5a569d..8d76a832d3 100644 --- a/codex-rs/code-mode/src/runtime/value.rs +++ b/codex-rs/code-mode/src/runtime/value.rs @@ -71,10 +71,14 @@ pub(super) fn normalize_output_image( Some(detail) => { let normalized = detail.to_ascii_lowercase(); Some(match normalized.as_str() { + "auto" => ImageDetail::Auto, + "low" => ImageDetail::Low, "high" => ImageDetail::High, "original" => ImageDetail::Original, _ => { - return Err("image detail must be one of: high, original".to_string()); + return Err( + "image detail must be one of: auto, low, high, original".to_string() + ); } }) } @@ -156,7 +160,7 @@ fn parse_mcp_output_image( .and_then(JsonValue::as_object) .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(JsonValue::as_str) - .filter(|detail| matches!(*detail, "high" | "original")) + .filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original")) .map(str::to_string); Ok((image_url, detail)) } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index de4ed13e58..d153f1b416 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1359,7 +1359,7 @@ image( } #[tokio::test] - async fn image_helper_rejects_unsupported_detail() { + async fn image_helper_accepts_low_detail() { let service = CodeModeService::new(); let response = service @@ -1369,6 +1369,38 @@ image({ image_url: "https://example.com/image.jpg", detail: "low", }); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), + detail: Some(crate::ImageDetail::Low), + }], + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = service + .execute(ExecuteRequest { + source: r#" +image({ + image_url: "https://example.com/image.jpg", + detail: "medium", +}); "# .to_string(), yield_time_ms: None, @@ -1382,7 +1414,9 @@ image({ RuntimeResponse::Result { cell_id: "1".to_string(), content_items: Vec::new(), - error_text: Some("image detail must be one of: high, original".to_string()), + error_text: Some( + "image detail must be one of: auto, low, high, original".to_string() + ), } ); } diff --git a/codex-rs/core/src/tools/code_mode/response_adapter.rs b/codex-rs/core/src/tools/code_mode/response_adapter.rs index 133d6e2fc1..e20cf6a071 100644 --- a/codex-rs/core/src/tools/code_mode/response_adapter.rs +++ b/codex-rs/core/src/tools/code_mode/response_adapter.rs @@ -17,6 +17,8 @@ impl IntoProtocol for CodeModeImageDetail { fn into_protocol(self) -> ImageDetail { let value = self; match value { + CodeModeImageDetail::Auto => ImageDetail::Auto, + CodeModeImageDetail::Low => ImageDetail::Low, CodeModeImageDetail::High => ImageDetail::High, CodeModeImageDetail::Original => ImageDetail::Original, } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 85c5a5d704..86603db1ef 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -725,6 +725,8 @@ pub enum ContentItem { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { + Auto, + Low, High, Original, } @@ -1077,7 +1079,7 @@ pub fn local_image_content_items_with_label_number( ) -> Vec { let mode = match detail { ImageDetail::Original => PromptImageMode::Original, - ImageDetail::High => PromptImageMode::ResizeToFit, + ImageDetail::Auto | ImageDetail::Low | ImageDetail::High => PromptImageMode::ResizeToFit, }; match load_for_prompt_bytes(path, file_bytes, mode) { @@ -1599,6 +1601,8 @@ fn convert_mcp_content_to_items( .and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY)) .and_then(serde_json::Value::as_str) .and_then(|detail| match detail { + "auto" => Some(ImageDetail::Auto), + "low" => Some(ImageDetail::Low), "high" => Some(ImageDetail::High), "original" => Some(ImageDetail::Original), _ => None, @@ -1674,6 +1678,36 @@ mod tests { ); } + #[test] + fn image_detail_roundtrips_all_wire_values() -> Result<()> { + assert_eq!( + serde_json::from_str::("\"auto\"")?, + ImageDetail::Auto + ); + assert_eq!( + serde_json::from_str::("\"low\"")?, + ImageDetail::Low + ); + assert_eq!(serde_json::to_string(&ImageDetail::Auto)?, "\"auto\""); + assert_eq!(serde_json::to_string(&ImageDetail::Low)?, "\"low\""); + + let content_item: ContentItem = serde_json::from_value(serde_json::json!({ + "type": "input_image", + "image_url": "data:image/png;base64,abc", + "detail": "auto", + }))?; + + assert_eq!( + content_item, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: Some(ImageDetail::Auto), + } + ); + + Ok(()) + } + #[test] fn sandbox_permissions_helpers_match_documented_semantics() { let cases = [ diff --git a/codex-rs/tools/src/image_detail.rs b/codex-rs/tools/src/image_detail.rs index 145cda663c..37086f691d 100644 --- a/codex-rs/tools/src/image_detail.rs +++ b/codex-rs/tools/src/image_detail.rs @@ -16,7 +16,7 @@ pub fn normalize_output_image_detail( Some(ImageDetail::Original) } Some(ImageDetail::Original) | None => None, - Some(ImageDetail::High) => Some(ImageDetail::High), + Some(ImageDetail::Auto | ImageDetail::Low | ImageDetail::High) => detail, } } diff --git a/codex-rs/tools/src/image_detail_tests.rs b/codex-rs/tools/src/image_detail_tests.rs index 919537acf7..2e46b49d08 100644 --- a/codex-rs/tools/src/image_detail_tests.rs +++ b/codex-rs/tools/src/image_detail_tests.rs @@ -70,6 +70,14 @@ fn explicit_original_is_dropped_without_model_support() { fn explicit_non_original_detail_is_preserved() { let model_info = model_info(); + assert_eq!( + normalize_output_image_detail(&model_info, Some(ImageDetail::Auto)), + Some(ImageDetail::Auto) + ); + assert_eq!( + normalize_output_image_detail(&model_info, Some(ImageDetail::Low)), + Some(ImageDetail::Low) + ); assert_eq!( normalize_output_image_detail(&model_info, Some(ImageDetail::High)), Some(ImageDetail::High) @@ -88,7 +96,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::High), + detail: Some(ImageDetail::Low), }, ]; @@ -106,7 +114,7 @@ fn sanitize_original_falls_back_to_high_without_support() { }, FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,BBB".to_string(), - detail: Some(ImageDetail::High), + detail: Some(ImageDetail::Low), }, ] ); From 487521733bea0a0a37559cffb519a1f6fb6bb444 Mon Sep 17 00:00:00 2001 From: marksteinbrick-oai Date: Tue, 26 May 2026 16:38:46 -0700 Subject: [PATCH 016/204] [codex-analytics] add grouped session id to runtime events (#24655) ## Why - Runtime analytics events report `thread_id`, which identifies the individual thread emitting an event - They don't report `session_id`, which identifies the shared session for a root thread and its subagent threads - Emitting both identifiers allows analytics to group related activity ## What Changed - Adds `session_id` to relevant analytics events (thread_initalized, turn, turn_steer, compaction, guardian_review) - Tracks each thread's session ID in the analytics reducer so subsequent thread scoped events emit the same value - Carries the shared session ID through subagent initialization ## Verification - `just test -p codex-analytics` validates event payloads and subagent session grouping. - Focused `codex-app-server` tests validate session IDs for thread, turn, and steer events. - Focused `codex-core` tests validate root and subagent session ID propagation. --- .../analytics/src/analytics_client_tests.rs | 32 +++++++++++++++++++ codex-rs/analytics/src/events.rs | 8 +++++ codex-rs/analytics/src/facts.rs | 1 + codex-rs/analytics/src/reducer.rs | 15 +++++++-- .../app-server/tests/suite/v2/analytics.rs | 2 ++ .../app-server/tests/suite/v2/thread_fork.rs | 9 +++++- .../tests/suite/v2/thread_resume.rs | 9 +++++- .../app-server/tests/suite/v2/thread_start.rs | 9 +++++- .../app-server/tests/suite/v2/turn_start.rs | 1 + .../app-server/tests/suite/v2/turn_steer.rs | 1 + codex-rs/core/src/agent/control.rs | 1 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/session/mod.rs | 3 ++ 13 files changed, 87 insertions(+), 5 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 26daadf5f5..fa64b46d7a 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1213,6 +1213,7 @@ fn compaction_event_serializes_expected_shape() { completed_at: 106, duration_ms: Some(6543), }, + "session-thread-1".to_string(), sample_app_server_client_metadata(), sample_runtime_metadata(), Some(ThreadSource::User), @@ -1229,6 +1230,7 @@ fn compaction_event_serializes_expected_shape() { "event_type": "codex_compaction_event", "event_params": { "thread_id": "thread-1", + "session_id": "session-thread-1", "turn_id": "turn-1", "app_server_client": { "product_client_id": DEFAULT_ORIGINATOR, @@ -1307,6 +1309,7 @@ fn thread_initialized_event_serializes_expected_shape() { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { thread_id: "thread-0".to_string(), + session_id: "session-thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), client_name: Some("codex-tui".to_string()), @@ -1338,6 +1341,7 @@ fn thread_initialized_event_serializes_expected_shape() { "event_type": "codex_thread_initialized", "event_params": { "thread_id": "thread-0", + "session_id": "session-thread-0", "app_server_client": { "product_client_id": DEFAULT_ORIGINATOR, "client_name": "codex-tui", @@ -1605,6 +1609,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); assert_eq!( payload[0]["event_params"]["app_server_client"]["product_client_id"], DEFAULT_ORIGINATOR @@ -1781,6 +1786,7 @@ async fn compaction_event_ingests_custom_fact() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_compaction_event"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact"); assert_eq!( @@ -1905,6 +1911,10 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_guardian_review"); + assert_eq!( + payload[0]["event_params"]["session_id"], + "session-thread-guardian" + ); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); @@ -2397,6 +2407,7 @@ async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() { fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2440,6 +2451,7 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2459,18 +2471,21 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { )); let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event"); + assert_eq!(payload["event_params"]["thread_id"], "thread-spawn"); assert_eq!(payload["event_params"]["thread_source"], "subagent"); assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn"); assert_eq!( payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!(payload["event_params"]["session_id"], "session-root"); } #[test] fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2496,6 +2511,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { fn subagent_thread_started_other_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2517,6 +2533,7 @@ fn subagent_thread_started_other_serializes_expected_shape() { fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: Some("parent-thread-guardian".to_string()), product_client_id: "codex-tui".to_string(), @@ -2546,6 +2563,7 @@ async fn subagent_thread_started_publishes_without_initialize() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2619,6 +2637,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "parent-client".to_string(), @@ -2666,6 +2685,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { .await; let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-root"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-review"); assert_eq!( payload[0]["event_params"]["app_server_client"]["product_client_id"], "parent-client" @@ -2686,6 +2707,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), product_client_id: "codex-tui".to_string(), @@ -3191,6 +3213,7 @@ fn turn_event_serializes_expected_shape() { event_type: "codex_turn_event", event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), + session_id: "session-thread-2".to_string(), turn_id: "turn-2".to_string(), app_server_client: sample_app_server_client_metadata(), runtime: sample_runtime_metadata(), @@ -3241,6 +3264,7 @@ fn turn_event_serializes_expected_shape() { "event_type": "codex_turn_event", "event_params": { "thread_id": "thread-2", + "session_id": "session-thread-2", "turn_id": "turn-2", "submission_type": null, "app_server_client": { @@ -3342,6 +3366,10 @@ async fn accepted_turn_steer_emits_expected_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!( + payload["event_params"]["session_id"], + json!("session-thread-2") + ); assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); @@ -3559,6 +3587,10 @@ async fn turn_lifecycle_emits_turn_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_type"], json!("codex_turn_event")); assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!( + payload["event_params"]["session_id"], + json!("session-thread-2") + ); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( payload["event_params"]["app_server_client"], diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 87cb165ac2..8791e5767a 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -147,6 +147,7 @@ pub(crate) struct CodexRuntimeMetadata { #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) model: String, @@ -420,6 +421,7 @@ impl GuardianReviewAnalyticsResult { #[derive(Serialize)] pub(crate) struct GuardianReviewEventPayload { + pub(crate) session_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, #[serde(flatten)] @@ -738,6 +740,7 @@ pub(crate) struct CodexHookRunEventRequest { #[derive(Serialize)] pub(crate) struct CodexCompactionEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) turn_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -767,6 +770,7 @@ pub(crate) struct CodexCompactionEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) turn_id: String, // TODO(rhan-oai): Populate once queued/default submission type is plumbed from // the turn/start callsites instead of always being reported as None. @@ -821,6 +825,7 @@ pub(crate) struct CodexTurnEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnSteerEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) expected_turn_id: Option, pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, @@ -926,6 +931,7 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu pub(crate) fn codex_compaction_event_params( input: CodexCompactionEvent, + session_id: String, app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, thread_source: Option, @@ -934,6 +940,7 @@ pub(crate) fn codex_compaction_event_params( ) -> CodexCompactionEventParams { CodexCompactionEventParams { thread_id: input.thread_id, + session_id, turn_id: input.turn_id, app_server_client, runtime, @@ -1026,6 +1033,7 @@ pub(crate) fn subagent_thread_started_event_request( ) -> ThreadInitializedEvent { let event_params = ThreadInitializedEventParams { thread_id: input.thread_id, + session_id: input.session_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, client_name: Some(input.client_name), diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 56bd0a5d2c..9a85276712 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -199,6 +199,7 @@ pub struct AppInvocation { #[derive(Clone)] pub struct SubAgentThreadStartedInput { + pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, pub product_client_id: String, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d072720d10..f2f036b8f4 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -255,6 +255,7 @@ struct ItemReviewSummary { #[derive(Clone)] struct ThreadMetadataState { + session_id: String, thread_source: Option, initialization_mode: ThreadInitializationMode, subagent_source: Option, @@ -263,6 +264,7 @@ struct ThreadMetadataState { impl ThreadMetadataState { fn from_thread_metadata( + session_id: String, session_source: &SessionSource, thread_source: Option, initialization_mode: ThreadInitializationMode, @@ -281,6 +283,7 @@ impl ThreadMetadataState { | SessionSource::Unknown => (None, None), }; Self { + session_id, thread_source, initialization_mode, subagent_source, @@ -525,6 +528,7 @@ impl AnalyticsReducer { thread_state .metadata .get_or_insert_with(|| ThreadMetadataState { + session_id: input.session_id.clone(), thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), @@ -543,8 +547,8 @@ impl AnalyticsReducer { input: GuardianReviewEventParams, out: &mut Vec, ) { - let Some(connection_state) = - self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input)) + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::guardian(&input)) else { return; }; @@ -552,6 +556,7 @@ impl AnalyticsReducer { GuardianReviewEventRequest { event_type: "codex_guardian_review", event_params: GuardianReviewEventPayload { + session_id: thread_metadata.session_id.clone(), app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), guardian_review: input, @@ -1231,11 +1236,13 @@ impl AnalyticsReducer { out: &mut Vec, ) { let session_source: SessionSource = thread.source.into(); + let session_id = thread.session_id; let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; let thread_metadata = ThreadMetadataState::from_thread_metadata( + session_id.clone(), &session_source, thread.thread_source.map(Into::into), initialization_mode, @@ -1252,6 +1259,7 @@ impl AnalyticsReducer { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { thread_id, + session_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, @@ -1277,6 +1285,7 @@ impl AnalyticsReducer { event_type: "codex_compaction_event", event_params: codex_compaction_event_params( input, + thread_metadata.session_id.clone(), connection_state.app_server_client.clone(), connection_state.runtime.clone(), thread_metadata.thread_source, @@ -1379,6 +1388,7 @@ impl AnalyticsReducer { event_type: "codex_turn_steer_event", event_params: CodexTurnSteerEventParams { thread_id: pending_request.thread_id, + session_id: thread_metadata.session_id.clone(), expected_turn_id: Some(pending_request.expected_turn_id), accepted_turn_id, app_server_client: connection_state.app_server_client.clone(), @@ -2447,6 +2457,7 @@ fn codex_turn_event_params( let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, + session_id: thread_metadata.session_id.clone(), turn_id, app_server_client, runtime, diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index c6f95af95d..8e8f2a7557 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -168,11 +168,13 @@ pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { pub(crate) fn assert_basic_thread_initialized_event( event: &Value, thread_id: &str, + session_id: &str, expected_model: &str, initialization_mode: &str, expected_thread_source: &str, ) { assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["session_id"], session_id); assert_eq!( event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index cc89dd71cb..287afe1016 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -405,7 +405,14 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "mock-model", + "forked", + "user", + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 8e60c90a01..bb2de2cfbb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -422,7 +422,14 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "gpt-5.3-codex", + "resumed", + "user", + ); assert_eq!(event["event_params"]["thread_source"], "user"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index ecd8ad8e3d..6be6d838ba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -439,7 +439,14 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; assert_eq!(payload["events"].as_array().expect("events array").len(), 1); let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "mock-model", + "new", + "user", + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 88596b4639..d0f1c13246 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -862,6 +862,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["session_id"], thread.session_id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( event["event_params"]["app_server_client"]["product_client_id"], diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 66ac33972b..0c4dcabe77 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -306,6 +306,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["session_id"], thread.session_id); assert_eq!(event["event_params"]["result"], "accepted"); assert_eq!(event["event_params"]["num_input_images"], 0); assert_eq!(event["event_params"]["expected_turn_id"], turn.id); diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 4706a455ff..8f25074d76 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -317,6 +317,7 @@ impl AgentControl { .services .analytics_events_client, client_metadata, + new_thread.thread.codex.session.session_id(), new_thread.thread_id, /*parent_thread_id*/ None, thread_config, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index dd88e9238f..2afbeb45c9 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -109,6 +109,7 @@ pub(crate) async fn run_codex_thread_interactive( emit_subagent_session_started( &parent_session.services.analytics_events_client, client_metadata, + codex.session.session_id(), codex.session.conversation_id, Some(parent_session.conversation_id), thread_config, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 5ea30e5bb5..245047c925 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -72,6 +72,7 @@ use codex_network_proxy::normalize_host; use codex_otel::current_span_trace_id; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::approvals::ExecPolicyAmendment; @@ -3302,6 +3303,7 @@ impl Session { pub(crate) fn emit_subagent_session_started( analytics_events_client: &AnalyticsEventsClient, client_metadata: AppServerClientMetadata, + session_id: SessionId, thread_id: ThreadId, parent_thread_id: Option, thread_config: ThreadConfigSnapshot, @@ -3320,6 +3322,7 @@ pub(crate) fn emit_subagent_session_started( .unwrap_or_default() .as_secs(); analytics_events_client.track_subagent_thread_started(SubAgentThreadStartedInput { + session_id: session_id.to_string(), thread_id: thread_id.to_string(), parent_thread_id: parent_thread_id.map(|thread_id| thread_id.to_string()), product_client_id: client_name.clone(), From 0d37db4b2b10b56991566b3512b0677754733835 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 26 May 2026 17:19:02 -0700 Subject: [PATCH 017/204] [codex] Remove obsolete goal continuation turn marker (#24658) ## Why `continuation_turn_id` was introduced to distinguish synthetic goal continuation turns for the no-tool continuation suppression heuristic. #20523 removed that heuristic, but left the marker behind. It is still written and cleared without affecting any runtime decision. ## What Changed - Remove `GoalRuntimeState::continuation_turn_id`. - Remove the marker setter/clearer and their now-no-op start, finish, and abort call sites. ## Testing - Not run yet (deferred at request). --- codex-rs/core/src/goals.rs | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 6df681d4b0..c8e76f7d41 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -172,7 +172,6 @@ pub(crate) struct GoalRuntimeState { pub(crate) budget_limit_reported_goal_id: Mutex>, accounting_lock: Semaphore, accounting: Mutex, - continuation_turn_id: Mutex>, pub(crate) continuation_lock: Semaphore, } @@ -188,7 +187,6 @@ impl GoalRuntimeState { budget_limit_reported_goal_id: Mutex::new(None), accounting_lock: Semaphore::new(/*permits*/ 1), accounting: Mutex::new(GoalAccountingSnapshot::new()), - continuation_turn_id: Mutex::new(None), continuation_lock: Semaphore::new(/*permits*/ 1), } } @@ -900,20 +898,6 @@ impl Session { } } - async fn mark_thread_goal_continuation_turn_started(&self, turn_id: String) { - *self.goal_runtime.continuation_turn_id.lock().await = Some(turn_id); - } - - async fn take_thread_goal_continuation_turn(&self, turn_id: &str) -> bool { - let mut continuation_turn_id = self.goal_runtime.continuation_turn_id.lock().await; - if continuation_turn_id.as_deref() == Some(turn_id) { - *continuation_turn_id = None; - true - } else { - false - } - } - async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc>) { let mut active_turn_guard = self.active_turn.lock().await; if let Some(active_turn) = active_turn_guard.as_ref() @@ -941,8 +925,6 @@ impl Session { tracing::warn!("failed to account thread goal progress at turn end: {err}"); } - self.take_thread_goal_continuation_turn(&turn_context.sub_id) - .await; if turn_completed { let mut accounting = self.goal_runtime.accounting.lock().await; if accounting @@ -957,8 +939,6 @@ impl Session { async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { if let Some(turn_context) = turn_context { - self.take_thread_goal_continuation_turn(&turn_context.sub_id) - .await; if let Err(err) = self .account_thread_goal_progress( turn_context, @@ -1373,8 +1353,6 @@ impl Session { .await; return; } - self.mark_thread_goal_continuation_turn_started(turn_context.sub_id.clone()) - .await; self.start_task(turn_context, Vec::new(), RegularTask::new()) .await; } From 9fe55d68e6e1d850642cd2fc5039ae76d1213dad Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Tue, 26 May 2026 18:05:19 -0700 Subject: [PATCH 018/204] fix: dont compact standalone websearch schema (#24660) add new `parse_tool_input_schema_without_compaction` to bypass the existing compaction/trimming of client-provided tool schemas that are over 4k bytes. we want this for standalone web search to keep field guidance/metadata on certain fields; this keeps us closer to parity with existing hosted tool schema (which didnt go through this 4k byte filter). --- .../app-server/tests/suite/v2/web_search.rs | 9 ++++-- codex-rs/ext/extension-api/src/lib.rs | 1 + codex-rs/ext/web-search/src/tool.rs | 5 ++-- codex-rs/tools/src/json_schema.rs | 18 +++++++++++- codex-rs/tools/src/json_schema_tests.rs | 29 ++++++++++++++++--- codex-rs/tools/src/lib.rs | 1 + 6 files changed, 53 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 58b2ff91a8..927f14ca9d 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -112,9 +112,12 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { assert_eq!(requests.len(), 2); let first_response = requests[0].body_json(); - assert!( - requests[0].tool_by_name("web", "run").is_some(), - "web.run should be sent to the model" + let web_run = requests[0] + .tool_by_name("web", "run") + .context("web.run should be sent to the model")?; + assert_eq!( + web_run.pointer("/parameters/properties/time/description"), + Some(&json!("Get time for the given UTC offsets.")) ); assert!( !has_hosted_web_search(&first_response), diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index a7c5c87e52..f3c3dc526e 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -21,6 +21,7 @@ pub use codex_tools::ToolOutput; pub use codex_tools::ToolPayload; pub use codex_tools::ToolSpec; pub use codex_tools::parse_tool_input_schema; +pub use codex_tools::parse_tool_input_schema_without_compaction; pub use contributors::ApprovalReviewContributor; pub use contributors::ConfigContributor; pub use contributors::ContextContributor; diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 1e1db2deca..875476db9a 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -10,7 +10,7 @@ use codex_extension_api::ToolExecutor; use codex_extension_api::ToolName; use codex_extension_api::ToolOutput; use codex_extension_api::ToolSpec; -use codex_extension_api::parse_tool_input_schema; +use codex_extension_api::parse_tool_input_schema_without_compaction; use codex_login::default_client::build_reqwest_client; use codex_model_provider::SharedModelProvider; use codex_tools::ResponsesApiNamespace; @@ -40,7 +40,8 @@ impl ToolExecutor for WebSearchTool { } fn spec(&self) -> ToolSpec { - let parameters = match parse_tool_input_schema(&commands_schema()) { + // parse schema without compaction that removes field metadata/descriptions to match hosted tool definition + let parameters = match parse_tool_input_schema_without_compaction(&commands_schema()) { Ok(parameters) => parameters, Err(err) => panic!("search command schema should parse: {err}"), }; diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 02936d52d1..1b53ad9803 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -157,10 +157,26 @@ impl From for AdditionalProperties { /// Parse the tool `input_schema` or return an error for invalid schema. pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + let mut input_schema = prepare_tool_input_schema(input_schema); + compact_large_tool_schema(&mut input_schema); + deserialize_tool_input_schema(input_schema) +} + +/// Parse a trusted tool `input_schema` without running large-schema compaction. +pub fn parse_tool_input_schema_without_compaction( + input_schema: &JsonValue, +) -> Result { + deserialize_tool_input_schema(prepare_tool_input_schema(input_schema)) +} + +fn prepare_tool_input_schema(input_schema: &JsonValue) -> JsonValue { let mut input_schema = input_schema.clone(); sanitize_json_schema(&mut input_schema); prune_unreachable_definitions(&mut input_schema); - compact_large_tool_schema(&mut input_schema); + input_schema +} + +fn deserialize_tool_input_schema(input_schema: JsonValue) -> Result { let schema: JsonSchema = serde_json::from_value(input_schema)?; if matches!( schema.schema_type, diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 52b30138c1..9315d43c00 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -3,6 +3,7 @@ use super::JsonSchema; use super::JsonSchemaPrimitiveType; use super::JsonSchemaType; use super::parse_tool_input_schema; +use super::parse_tool_input_schema_without_compaction; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -791,8 +792,8 @@ fn many_string_properties(count: usize) -> serde_json::Map Date: Tue, 26 May 2026 19:00:40 -0700 Subject: [PATCH 019/204] fix(core): instrument stalled tool-listing handoff (#24667) ## Why When a turn needs a follow-up request after tool output is recorded, Codex can still appear stuck in `Thinking` before the next `/responses` request is opened. The existing local trace showed the last completed response and the absence of a new backend request, but it did not show whether the stall was in tool-router preparation or later request setup. Issue: N/A (internal incident investigation) ## What Changed Added trace spans around the pre-stream tool-router handoff in `core/src/session/turn.rs`, including the `built_tools` phase and the MCP manager read lock. Added per-server MCP tool-listing spans and trace breadcrumbs in `codex-mcp/src/connection_manager.rs` with startup snapshot / startup-complete state so a pending MCP client is visible in feedback logs instead of looking like a silent hang. ## Verification - `just fmt` - `just test -p codex-mcp` - `just test -p codex-core` (prior full rerun fails in this workspace on unrelated integration tests: code-mode output length expectations, one shell timeout formatting assertion, and shell snapshot timeouts; latest review-fix rerun compiled and passed 1160 tests before I stopped the abnormally slow unrelated suite) --- codex-rs/codex-mcp/src/connection_manager.rs | 33 ++++++++++++++++++-- codex-rs/core/src/session/turn.rs | 15 ++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 91ed8b1b64..00d8adca2e 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -64,7 +64,10 @@ use rmcp::model::Resource; use rmcp::model::ResourceTemplate; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; +use tracing::Instrument; use tracing::instrument; +use tracing::trace; +use tracing::trace_span; use tracing::warn; /// A thin wrapper around a set of running [`RmcpClient`] instances. @@ -378,13 +381,37 @@ impl McpConnectionManager { } /// Returns all tools with model-visible names normalized. - #[instrument(level = "trace", skip_all)] + #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))] pub async fn list_all_tools(&self) -> Vec { let mut tools = Vec::new(); - for managed_client in self.clients.values() { - let Some(server_tools) = managed_client.listed_tools().await else { + for (server_name, managed_client) in &self.clients { + let has_startup_snapshot = managed_client.startup_snapshot.is_some(); + let startup_complete = managed_client + .startup_complete + .load(std::sync::atomic::Ordering::Acquire); + trace!( + server_name = %server_name, + has_startup_snapshot, + startup_complete, + "waiting for MCP server tools while building tool list" + ); + let Some(server_tools) = managed_client + .listed_tools() + .instrument(trace_span!( + "list_tools_for_server", + server_name = %server_name, + has_startup_snapshot, + startup_complete + )) + .await + else { continue; }; + trace!( + server_name = %server_name, + tool_count = server_tools.len(), + "listed MCP server tools while building tool list" + ); tools.extend( server_tools .into_iter() diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 82fa35f108..2b9fa02529 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1006,12 +1006,25 @@ async fn run_sampling_request( clippy::await_holding_invalid_type, reason = "tool router construction reads through the session-owned manager guard" )] +#[instrument(level = "trace", + skip_all, + fields( + turn_id = %turn_context.sub_id, + model = %turn_context.model_info.slug, + apps_enabled = turn_context.apps_enabled() + ) +)] pub(crate) async fn built_tools( sess: &Session, turn_context: &TurnContext, cancellation_token: &CancellationToken, ) -> CodexResult> { - let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; + let mcp_connection_manager = sess + .services + .mcp_connection_manager + .read() + .instrument(trace_span!("read_mcp_connection_manager")) + .await; let has_mcp_servers = mcp_connection_manager.has_servers(); let all_mcp_tools = mcp_connection_manager .list_all_tools() From cca1e0ba1d3ea925d297dcbaca2f77c658fcf1bc Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 26 May 2026 20:59:47 -0700 Subject: [PATCH 020/204] Uprev Rust toolchain pins to 1.95.0 (#24684) ## Summary - Bump the workspace Rust toolchain from `1.93.0` to `1.95.0` across Cargo, Bazel, CI, release workflows, devcontainers, and the Codex environment config. - Refresh `MODULE.bazel.lock` so the Bazel Rust toolchain artifacts match the new version. - Leave purpose-specific toolchains unchanged, including the `argument-comment-lint` nightly and the upstream `rusty_v8` `1.91.0` build pin. - Includes fixes for new lints from `just fix` and a few codex-authored fixes for lints without a suggestion. --- .codex/environments/environment.toml | 2 +- .devcontainer/Dockerfile.secure | 2 +- .devcontainer/devcontainer.secure.json | 2 +- .github/workflows/cargo-deny.yml | 4 +- .../rust-ci-full-nextest-platform.yml | 4 +- .github/workflows/rust-ci-full.yml | 8 +- .github/workflows/rust-ci.yml | 6 +- .../rust-release-argument-comment-lint.yml | 2 +- .github/workflows/rust-release-windows.yml | 2 +- .github/workflows/rust-release.yml | 4 +- .github/workflows/rusty-v8-release.yml | 4 +- .github/workflows/v8-canary.yml | 10 +- MODULE.bazel | 2 +- MODULE.bazel.lock | 212 +++++++++--------- codex-rs/.github/workflows/cargo-audit.yml | 2 +- codex-rs/app-server-protocol/src/export.rs | 24 +- .../src/schema_fixtures.rs | 2 +- .../src/transport/remote_control/websocket.rs | 13 +- .../src/transport/websocket.rs | 8 +- codex-rs/app-server/src/outgoing_message.rs | 4 +- .../suite/v2/connection_handling_websocket.rs | 13 +- codex-rs/apply-patch/src/lib.rs | 2 +- codex-rs/cli/src/doctor.rs | 6 +- codex-rs/cli/src/mcp_cmd.rs | 6 +- codex-rs/code-mode/src/description.rs | 2 +- codex-rs/codex-client/src/custom_ca.rs | 16 +- codex-rs/config/src/config_toml.rs | 2 +- codex-rs/config/src/loader/mod.rs | 2 +- codex-rs/config/src/mcp_edit.rs | 4 +- codex-rs/config/src/schema.rs | 2 +- codex-rs/core/src/config/edit.rs | 6 +- .../core/src/mcp_tool_approval_templates.rs | 2 +- codex-rs/core/src/test_support.rs | 2 +- .../src/tools/handlers/multi_agents_common.rs | 2 +- codex-rs/core/tests/suite/compact.rs | 6 +- codex-rs/core/tests/suite/compact_remote.rs | 2 +- .../core/tests/suite/compact_remote_parity.rs | 2 +- .../core/tests/suite/compact_resume_fork.rs | 6 +- .../core/tests/suite/realtime_conversation.rs | 6 +- codex-rs/debug-client/src/client.rs | 6 +- codex-rs/exec-server/src/client.rs | 2 +- codex-rs/exec-server/src/fs_sandbox.rs | 2 +- codex-rs/hooks/src/schema.rs | 2 +- codex-rs/memories/write/src/phase1.rs | 2 +- codex-rs/models-manager/src/manager.rs | 2 +- codex-rs/models-manager/src/test_support.rs | 2 +- codex-rs/rust-toolchain.toml | 2 +- codex-rs/scripts/setup-windows.ps1 | 2 +- codex-rs/state/src/runtime/memories.rs | 2 +- codex-rs/tui/src/app/thread_events.rs | 8 +- .../src/bottom_pane/request_user_input/mod.rs | 8 +- codex-rs/tui/src/chatwidget/protocol.rs | 6 +- codex-rs/tui/src/history_cell/mcp.rs | 6 +- codex-rs/tui/src/history_cell/session.rs | 2 +- codex-rs/tui/src/multi_agents.rs | 2 +- codex-rs/tui/src/resume_picker.rs | 20 +- codex-rs/tui/src/text_formatting.rs | 2 +- codex-rs/tui/src/theme_picker.rs | 4 +- codex-rs/utils/cli/src/format_env_display.rs | 2 +- 59 files changed, 230 insertions(+), 260 deletions(-) diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index f67f1983f2..4595a1fa65 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -8,4 +8,4 @@ script = "" [[actions]] name = "Run" icon = "run" -command = "cargo +1.93.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file" +command = "cargo +1.95.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file" diff --git a/.devcontainer/Dockerfile.secure b/.devcontainer/Dockerfile.secure index 6c1878eafa..83af317d8b 100644 --- a/.devcontainer/Dockerfile.secure +++ b/.devcontainer/Dockerfile.secure @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 ARG TZ ARG DEBIAN_FRONTEND=noninteractive ARG NODE_MAJOR=22 -ARG RUST_TOOLCHAIN=1.92.0 +ARG RUST_TOOLCHAIN=1.95.0 # Keep this in sync with .devcontainer/codex-install/package.json and pnpm-lock.yaml. ARG CODEX_NPM_VERSION=0.121.0 diff --git a/.devcontainer/devcontainer.secure.json b/.devcontainer/devcontainer.secure.json index 5d5808e541..7b05fb9a6c 100644 --- a/.devcontainer/devcontainer.secure.json +++ b/.devcontainer/devcontainer.secure.json @@ -7,7 +7,7 @@ "args": { "TZ": "${localEnv:TZ:UTC}", "NODE_MAJOR": "22", - "RUST_TOOLCHAIN": "1.92.0", + "RUST_TOOLCHAIN": "1.95.0", "CODEX_NPM_VERSION": "0.121.0" } }, diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index f20d09e112..656c50ef28 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -20,10 +20,10 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Run cargo-deny uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2 with: - rust-version: 1.93.0 + rust-version: 1.95.0 manifest-path: ./codex-rs/Cargo.toml diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml index 7dc39d33ed..3fdf7b51ee 100644 --- a/.github/workflows/rust-ci-full-nextest-platform.yml +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -94,7 +94,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ inputs.target }} @@ -319,7 +319,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ inputs.target }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index c4a9329c00..2111ed7b0a 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 @@ -46,7 +46,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-shear@1.11.2 @@ -63,7 +63,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -266,7 +266,7 @@ jobs: fi sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} components: clippy diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 029b6f2c7e..d1d7c0df96 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -67,7 +67,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 @@ -91,7 +91,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-shear@1.11.2 @@ -111,7 +111,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install nightly argument-comment-lint toolchain shell: bash run: | diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index f654bd9dd7..94f15af7d4 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -60,7 +60,7 @@ jobs: with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 51412be0e0..088cb8a74c 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -100,7 +100,7 @@ jobs: Write-Host "Total RAM: $ramGiB GiB" Write-Host "Disk usage:" Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2cd11e66fe..29d61d6a54 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -56,7 +56,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Validate tag matches Cargo.toml version shell: bash env: @@ -254,7 +254,7 @@ jobs: sudo apt-get update -y sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 3b56a1e93f..d98cdb2c43 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -152,9 +152,9 @@ jobs: python-version: "3.12" - name: Set up Rust toolchain for Cargo smoke - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: - toolchain: "1.93.0" + toolchain: "1.95.0" - name: Build Bazel V8 release pair env: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 979e991504..6341f218a6 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -166,9 +166,9 @@ jobs: python-version: "3.12" - name: Set up Rust toolchain for Cargo smoke - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: - toolchain: "1.93.0" + toolchain: "1.95.0" - name: Build Bazel V8 release pair env: @@ -310,9 +310,9 @@ jobs: architecture: x64 - name: Set up Codex Rust toolchain for Cargo smoke - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: - toolchain: "1.93.0" + toolchain: "1.95.0" targets: ${{ matrix.target }} - name: Install rusty_v8 Rust toolchain @@ -401,7 +401,7 @@ jobs: cd codex-rs RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ - cargo +1.93.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run + cargo +1.95.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run ) - name: Upload staged artifacts diff --git a/MODULE.bazel b/MODULE.bazel index 81f16b7b9e..abca889d64 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -163,7 +163,7 @@ use_repo(nightly_rust, "rust_toolchains") toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains") toolchains.toolchain( edition = "2024", - version = "1.93.0", + version = "1.95.0", ) use_repo(toolchains, "default_rust_toolchains") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ad56196dd9..8a3cdeb66e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1867,112 +1867,112 @@ "zvariant_utils_2.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.81\"},{\"name\":\"quote\",\"req\":\"^1.0.36\"},{\"features\":[\"extra-traits\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.64\"}],\"features\":{}}" }, "@@rules_rs+//rs/experimental/toolchains:module_extension.bzl%toolchains": { - "cargo-1.93.0-aarch64-apple-darwin.tar.xz": "6443909350322ad07f09bb5edfd9ff29268e6fe88c7d78bfba7a5e254248dc25", - "cargo-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "387832b989c8eb96c9ebd66402a87962167633bc6d91a49ffb8c7903c45f3476", - "cargo-1.93.0-aarch64-pc-windows-msvc.tar.xz": "155bff7a16aa7054e7ed7c3a82e362d4b302b3882d751b823e06ff63ae3f103d", - "cargo-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "5998940b8b97286bb67facb1a85535eeb3d4d7a61e36a85e386e5c0c5cfe5266", - "cargo-1.93.0-x86_64-apple-darwin.tar.xz": "95a47c5ed797c35419908f04188d8b7de09946e71073c4b72632b16f5b10dfae", - "cargo-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "f19766837559f90476508140cb95cc708220012ec00a854fa9f99187b1f246b6", - "cargo-1.93.0-x86_64-pc-windows-msvc.tar.xz": "e59c5e2baa9ec17261f2cda6676ebf7b68b21a860e3f7451c4d964728951da75", - "cargo-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "c23de3ae709ff33eed5e4ae59d1f9bcd75fa4dbaa9fb92f7b06bfb534b8db880", - "clippy-1.93.0-aarch64-apple-darwin.tar.xz": "0b6e943a8d12be0e68575acf59c9ea102daf795055fcbbf862b0bfd35ec40039", - "clippy-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "296949f49be2de77ce9d1c5023a5f0a58e28e329eec03642a3da0e175a67beeb", - "clippy-1.93.0-aarch64-pc-windows-msvc.tar.xz": "07bcf2edb88cdf5ead2f02e4a8493e9b0ef935a31253fac6f9f3378d8023f113", - "clippy-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "872ae6d68d625946d281b91d928332e6b74f6ab269b6af842338df4338805a60", - "clippy-1.93.0-x86_64-apple-darwin.tar.xz": "e6d0b1afb9607c14a1172d09ee194a032bbb3e48af913d55c5a473e0559eddde", - "clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f", - "clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57", - "clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d", - "rust-analyzer-1.93.0-aarch64-apple-darwin.tar.xz": "8a09a46d45277678b2d112eef61736e03e78dfa6c506e187df176b904659e5a7", - "rust-analyzer-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "1fb48fadbaea6da36cfcd6b72122eeaa6ab025c9c82441777cf913108f73c115", - "rust-analyzer-1.93.0-aarch64-pc-windows-msvc.tar.xz": "558100ab62fe22d552be43c1bf166fef057ff2f3eb0d786cf5fe509a08701f37", - "rust-analyzer-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "b2e7890cf5953f572eb05d7328c086fa60d4f16e5d301d35c52b3f58b88a8316", - "rust-analyzer-1.93.0-x86_64-apple-darwin.tar.xz": "3cb7975d4fd17840e2980c9d864755f801ee9e7594de886a5816c73b07594508", - "rust-analyzer-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "4471db6393b7380624899482aae99612fd1cf509ae36d0c68292077c2104127d", - "rust-analyzer-1.93.0-x86_64-pc-windows-msvc.tar.xz": "f4c1f8f120f48974cb1e0cea01a955ce52cdfa0f1db0355c5fb5a2deeca8188a", - "rust-analyzer-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "33fcd377be3b5ffdd95977c3d0219f63725c18b6c8b53fb5be0418962a84738c", - "rust-src-1.93.0.tar.xz": "0e7b9acd5debfeffef3741dc8f6edf137d70426a0027d6b190cc8cfb0a1ac23c", - "rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b", - "rust-std-1.93.0-aarch64-apple-ios-macabi.tar.xz": "24d47e615ce101869ff452a572a6b77ed7cf70f2454d0b50892ac849e8c7ac4d", - "rust-std-1.93.0-aarch64-apple-ios-sim.tar.xz": "d1d5e2d1b79206f2cc9fb7f6a2958cfe0f4bbc9147fda8dbc3608aa4be5e6816", - "rust-std-1.93.0-aarch64-apple-ios.tar.xz": "49228e70387773a71cf144509baf39979ab2cdb604340fff64b483ab41f61617", - "rust-std-1.93.0-aarch64-linux-android.tar.xz": "59c16648d9a29c07f63a1749cae6b73078f20fef1206c5e0f81c784ae8796cdb", - "rust-std-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "9a270d50eaaacc7cb1925565a8b65ff831686aa1592b7034bb9848d7f2a9738d", - "rust-std-1.93.0-aarch64-pc-windows-msvc.tar.xz": "f7bd3d25baf3643c8769b8c4d2e6cde45bb25042fac698e0daf19fc9f58f8568", - "rust-std-1.93.0-aarch64-unknown-fuchsia.tar.xz": "d1e46c443a9607603c810942e99a95a1acfb105d1312426b468ff68febaabf77", - "rust-std-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "84e82ff52c39c64dfd0e1c2d58fd3d5309d1d2502378131544c0d486b44af20a", - "rust-std-1.93.0-aarch64-unknown-linux-musl.tar.xz": "bab885a87da586040064064bd1c314d707164d8dc0fefee39d59be7f15ce6f7d", - "rust-std-1.93.0-aarch64-unknown-none-softfloat.tar.xz": "0f6305daf658a7d6c0efd075859cb60432c13b82e7ecee0d097074e4e1873391", - "rust-std-1.93.0-aarch64-unknown-none.tar.xz": "3cf1aa3309a8903e89bb20537113155ca4e88844c8cc9c34c43865d5ce5a6192", - "rust-std-1.93.0-aarch64-unknown-uefi.tar.xz": "317b0af124e0e124bd76b8e5a2fb0c600279177d0bed9c841a3202df2d0f7f8e", - "rust-std-1.93.0-arm-linux-androideabi.tar.xz": "d010b26fc88e28a93cc94ea6ca5d2c90efed7f9846fae1e40db7c183b50575e2", - "rust-std-1.93.0-arm-unknown-linux-gnueabi.tar.xz": "deedc54ffce099781986eed4aec0843866f1bf72904ab0f5cdb115b9c7af540e", - "rust-std-1.93.0-arm-unknown-linux-gnueabihf.tar.xz": "89e44e042bc1241b3999191c385fec8433d60a5a9fc373583cd3b2d9408d5074", - "rust-std-1.93.0-arm-unknown-linux-musleabi.tar.xz": "641a17acb5104637d4dc9c4be022a7927ae721eb08759fea96ecfaf5c60be4dc", - "rust-std-1.93.0-arm-unknown-linux-musleabihf.tar.xz": "94a92b454bf3b0aab046b257f555ccb08f16dc2dc281bea6a4ef17ea8f58cbdc", - "rust-std-1.93.0-armv7-linux-androideabi.tar.xz": "e295f26bb219a7a4ebb5c2e07fedfebb075be6830aaf910c742a57cd21018b6d", - "rust-std-1.93.0-armv7-unknown-linux-gnueabi.tar.xz": "8a7bd5227c78294864095edb07837ff32ff6c07cd1a4a418f9bcc3ebd7e79874", - "rust-std-1.93.0-armv7-unknown-linux-gnueabihf.tar.xz": "f015f9b2d588454a9dc62942ab2e800d82c602e4eab6f89f8213419491bcd203", - "rust-std-1.93.0-armv7-unknown-linux-musleabi.tar.xz": "d15d24c9fb7c15243e1341cea53590002df271060118914bd0efcda8ccbd0731", - "rust-std-1.93.0-armv7-unknown-linux-musleabihf.tar.xz": "a2e5ec22ed35fb51a503d1e10b37447b0fa7333f079585bc0b6a2eb599de43f3", - "rust-std-1.93.0-i686-linux-android.tar.xz": "68fd86f62dd63221717d1741210f0f5cf75da7a1e32eed5a46b1e67c9d9430e1", - "rust-std-1.93.0-i686-pc-windows-gnu.tar.xz": "cb613d5d1eb245e8a1f4c0b25f93c2997cd06c1cc3fc202155f2997aebf44d4d", - "rust-std-1.93.0-i686-pc-windows-gnullvm.tar.xz": "0f713dc252a6de706519fe6cdaab6d66aaf1b555133b536cc0ab28061aa4269c", - "rust-std-1.93.0-i686-pc-windows-msvc.tar.xz": "33dc1951e2dc21bd05361160d52f496eecf48e0b95df5083172698b1cd5b9a3f", - "rust-std-1.93.0-i686-unknown-freebsd.tar.xz": "67718aae1381879fdcca5699051eb87e0cda3d2fd0fe75e306ba0948b79df7db", - "rust-std-1.93.0-i686-unknown-linux-gnu.tar.xz": "b8b7020a61418b95c1ea26badaf8db6979778e28dbadddc81fb5010fe27c935b", - "rust-std-1.93.0-i686-unknown-linux-musl.tar.xz": "867e54b3e89dc0b6d2b7a538a80443add6c3990bb4bd2260dea2ed98a0dc9870", - "rust-std-1.93.0-i686-unknown-uefi.tar.xz": "929fd484b08d5b2077ff864f5f2d24b51a78f1b6e837b9eab9d7e8fb7f31adce", - "rust-std-1.93.0-powerpc-unknown-linux-gnu.tar.xz": "e851c0fa3e726ce3f7139c5803198a1aa9723594394734ac9e373c93d92e5ea3", - "rust-std-1.93.0-powerpc64-unknown-linux-gnu.tar.xz": "f729bb7d95705e12a92eb072e494b93d8822ca40aa4802ca780b0cf33b56d401", - "rust-std-1.93.0-powerpc64le-unknown-linux-gnu.tar.xz": "d209ac698a69ca9b9035adb97a0ed8e60a08db52960198c3e03b9ee714c1a46b", - "rust-std-1.93.0-powerpc64le-unknown-linux-musl.tar.xz": "34b98d5eca2fdbd6ba41b0faf14160ef1ebd038f6ecaa264d318ad33263e1cf1", - "rust-std-1.93.0-riscv32imc-unknown-none-elf.tar.xz": "71af84c81241cbc7811b267927990be025f30d7d3dc55df4b56da7ac250f7c78", - "rust-std-1.93.0-riscv64gc-unknown-linux-gnu.tar.xz": "b769fb6c9f3e0419a6bd0b7b79f9191bbd7a48a9f243b23eb7d135711aa6de1b", - "rust-std-1.93.0-riscv64gc-unknown-linux-musl.tar.xz": "a7ced602573d814d875d69022e026c1ccb520b4b2de9d430ddfd0966ec6c9643", - "rust-std-1.93.0-riscv64gc-unknown-none-elf.tar.xz": "842f72913f288a0c76601438e67ccd88c816dbf187587928e48bf8b9ce74cbf3", - "rust-std-1.93.0-s390x-unknown-linux-gnu.tar.xz": "41a65db45a288eb3eedb187b366f132d5b3615767de7ce994b123b342ac7a848", - "rust-std-1.93.0-thumbv6m-none-eabi.tar.xz": "be3f8aad5680dabb203300847dcbbabc15729170ba5c3a9c499efae4df410a9e", - "rust-std-1.93.0-thumbv7em-none-eabi.tar.xz": "8f93eefca39c0da417feddab64775f862c72bbe80da11914dcf47babef8f1644", - "rust-std-1.93.0-thumbv7em-none-eabihf.tar.xz": "a3b6914b966ac93dbe7531016d5f570b189445603c43614a60e0b9ea12674bd3", - "rust-std-1.93.0-thumbv7m-none-eabi.tar.xz": "cfa6227214f3ae58c06b36547c5bd6f0f6787764afa48cfa4ff3488264deab6c", - "rust-std-1.93.0-thumbv8m.main-none-eabi.tar.xz": "5f24df0aa8322561125575e365be7ad13a5bb26cf73c7fc9a3f4bcfa58e0febc", - "rust-std-1.93.0-thumbv8m.main-none-eabihf.tar.xz": "906b07580be2df277cced2b56bc03cb565b758c382bf3e82cbd8375b459815dd", - "rust-std-1.93.0-wasm32-unknown-emscripten.tar.xz": "63cdbb1ea7f353060539c00f7346f4f5fb0d6f09899cacddc1f172ef07c4af8b", - "rust-std-1.93.0-wasm32-unknown-unknown.tar.xz": "3100cb920ddac646943243f0eddd331128836b9161dd5f7b0a6c76375d39cc5e", - "rust-std-1.93.0-wasm32-wasip1-threads.tar.xz": "439c65dea31e855f0258632b6d19435ba8a80561297fa6dc6be48048c5cd1871", - "rust-std-1.93.0-wasm32-wasip1.tar.xz": "075de970ef865678dad258f1566d7cfe76a594698e9bf93dd69fa5cfdfcf1a6f", - "rust-std-1.93.0-wasm32-wasip2.tar.xz": "0ef01bb552036ab44456f5505015b13c88d3694629804d7af46452c8b0a48f8c", - "rust-std-1.93.0-x86_64-apple-darwin.tar.xz": "f112d41c8a31794f0f561d37fe77010ed0b405fa70284a2910891869d8c52418", - "rust-std-1.93.0-x86_64-apple-ios-macabi.tar.xz": "a543dd545747d372d973ace8b485a13603ce96c110c7ae734d605e45f6e162c5", - "rust-std-1.93.0-x86_64-apple-ios.tar.xz": "e151013b9bc5990e178285a33e62bae7700d8c48c06e97771abb1643aa907d75", - "rust-std-1.93.0-x86_64-linux-android.tar.xz": "dc05ca79d9fecc5ce3643adb9c6f89fd35c8e1d7146bf9b62e30bad41f9fb6a7", - "rust-std-1.93.0-x86_64-pc-windows-gnu.tar.xz": "a07c6ab596fad15ca7acd63ee7f2a5fea93fd421179252067e309c2aa0b2021b", - "rust-std-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "ef6cf0977bc5aa4bbd594afb9df4ba76fdd4f0fc5685cddbefff49ceed202a91", - "rust-std-1.93.0-x86_64-pc-windows-msvc.tar.xz": "2593e29af0b8def34ceb1185b8e85bd93a9e0d3b0c108d704c1b31370c50a48c", - "rust-std-1.93.0-x86_64-unknown-freebsd.tar.xz": "51b2feaff7c2d28633504ed92ab442a55d112e6a2bf09c91188f00dbaf03378a", - "rust-std-1.93.0-x86_64-unknown-fuchsia.tar.xz": "41f0f3eb96cedfc13ab5fd4f15065063f262d035c1f71d96c42587acdacdbabe", - "rust-std-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "a849a418d0f27e69573e41763c395e924a0b98c16fcdc55599c1c79c27c1c777", - "rust-std-1.93.0-x86_64-unknown-linux-musl.tar.xz": "874658d2ced1ed2b9bf66c148b78a2e10cad475d0a4db32e68a08900905b89b8", - "rust-std-1.93.0-x86_64-unknown-netbsd.tar.xz": "aad63193af89772031f9a85f193afc0b15f8e6d4a9a4983c5f5d3802f69a89e8", - "rust-std-1.93.0-x86_64-unknown-none.tar.xz": "01dcca7ae4b7e82fbfa399adb5e160afaa13143e5a17e1e0737c38cf07365fb3", - "rust-std-1.93.0-x86_64-unknown-uefi.tar.xz": "ec4e439d9485ce752b56999e8e41ed82373fc833a005cf2531c6f7ef7e785392", - "rustc-1.93.0-aarch64-apple-darwin.tar.xz": "092be03c02b44c405dab1232541c84f32b2d9e8295747568c3d531dd137221dc", - "rustc-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "d3bc0cdaf157e20b1f23e510b5e3c4c6e9117d08f5284c04dee60aecff1bc851", - "rustc-1.93.0-aarch64-pc-windows-msvc.tar.xz": "a3ac1a8e411de8470f71b366f89d187718c431526912b181692ed0a18c56c7ad", - "rustc-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "1a9045695892ec08d8e9751bf7cf7db71fe27a6202dd12ce13aca48d0602dbde", - "rustc-1.93.0-x86_64-apple-darwin.tar.xz": "594bb293f0a4f444656cf8dec2149fcb979c606260efee9e09bcf8c9c6ed6ae7", - "rustc-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "0cdaa8de66f5ce21d1ea73917efc5c64f408bda49f678ddde19465ced9d5ec63", - "rustc-1.93.0-x86_64-pc-windows-msvc.tar.xz": "fa17677eee0d83eb055b309953184bf87ba634923d8897f860cda65d55c6e350", - "rustc-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "00c6e6740ea6a795e33568cd7514855d58408a1180cd820284a7bbf7c46af715", - "rustfmt-1.93.0-aarch64-apple-darwin.tar.xz": "0dd1faedf0768ef362f4aae4424b34e8266f2b9cf5e76ea4fcaf780220b363a0", - "rustfmt-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "5888827e7fbd7d59930870b4856fce8d6d8fca5e02f6535f8ae3d7ad0ccf2d4a", - "rustfmt-1.93.0-aarch64-pc-windows-msvc.tar.xz": "24eed108489567133bbfe40c8eacda1567be55fae4c526911b39eb33eb27a6cb", - "rustfmt-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "92e1acb45ae642136258b4dabb39302af2d53c83e56ebd5858bc969f9e5c141a", - "rustfmt-1.93.0-x86_64-apple-darwin.tar.xz": "c8453b4c5758eb39423042ffa9c23ed6128cbed2b15b581e5e1192c9cc0b1d4e", - "rustfmt-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "47167e9e78db9be4503a060dee02f4df2cda252da32175dbf44331f965a747b9", - "rustfmt-1.93.0-x86_64-pc-windows-msvc.tar.xz": "5becc7c2dba4b9ab5199012cad30829235a7f7fb5d85a238697e8f0e44cbd9af", - "rustfmt-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "7f81f6c17d11a7fda5b4e1b111942fb3b23d30dcec767e13e340ebfb762a5e33" + "cargo-1.95.0-aarch64-apple-darwin.tar.xz": "6c2ffed8e1ac9cf4dc9e80f282a869a6b237a153e7c55cca039d33de29d80aaf", + "cargo-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "14683a8e0b0ee8afdd7e2896fc8e91a7ff0ba55c2e20912e639eea498f1e1d10", + "cargo-1.95.0-aarch64-pc-windows-msvc.tar.xz": "e645b30fa035a18aa12d28b699052014c7efa9dd4a33dabd223f0d16b5fa28e8", + "cargo-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "7c070aeba9bbf12073646995a03f36c346bb5f541d0078ba6d9dc2a7adaaf6af", + "cargo-1.95.0-x86_64-apple-darwin.tar.xz": "e2e1131ade2dddc0d779e0ab3a6a990085c7a654951235742823c3a1ce0f190f", + "cargo-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "239c098b9878ad01ad5e0feeee377e6ee3311bc1534b1fb5eb630489d463bab2", + "cargo-1.95.0-x86_64-pc-windows-msvc.tar.xz": "cab2606cb2d0aa31c55d50512fe07a9f15e893227566fbeb448306760cd0d2bf", + "cargo-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "e74edd2cf7d0f1f1383b4f00eb90c843750bc489e2ccf7214e6476678a907425", + "clippy-1.95.0-aarch64-apple-darwin.tar.xz": "fd183baa023d0c4e0c5b8184226e2d4c85126adf156cb1f3a726ec593bba8d62", + "clippy-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "b81d0fe05c4ec514aefaffdf0649b175a2f82572163202c17531358f196b6168", + "clippy-1.95.0-aarch64-pc-windows-msvc.tar.xz": "44c1b7ada72aa8f3fcaceb37a3899665bc9b160c2fea77879c8ecb65a9e97eba", + "clippy-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "fb021e0c0fc2238be9266d7614f4a26bc372544c4cba3528d729ab24ad229fc9", + "clippy-1.95.0-x86_64-apple-darwin.tar.xz": "e47367f6b1489d74cbba93b387310adcb82e27a51e44b2c6ff543eb4f199fe32", + "clippy-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "9358dfc3b831a5f4b3a3a0016da734b4ef5e78c84ca8f148f56a9c126515ebfa", + "clippy-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ddc151d6f58c6658b7380292ecaef36e62d063bbdbf7f5802669810575bb5b75", + "clippy-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "ac779bc9839dd47180806b133e4e2563c4a34716284cd5b8fede8ef289f452ca", + "rust-analyzer-1.95.0-aarch64-apple-darwin.tar.xz": "11231fc6574301b94bd379af4ef409caef7c65b877bcecf2b227dc0d74aa0ec7", + "rust-analyzer-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "6b2c0820957fdf0e3026f51fe85ad3cef94d5948e29cc83d2221f65dafc7a16b", + "rust-analyzer-1.95.0-aarch64-pc-windows-msvc.tar.xz": "92958624f23d4b0980748ac9e6d67f6f67a868f8224e8c6240e3f84145e2d805", + "rust-analyzer-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "b37e5b9aad624e54228254f98a710ee19ad464fe7ada93ef12e20c87886a0047", + "rust-analyzer-1.95.0-x86_64-apple-darwin.tar.xz": "6cd111900e13fd19b188c5d8844b34136af3967066c0ea2914ce5c3508296c85", + "rust-analyzer-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "01bd05a6b990ad37907ff26e8c285c5ba8b7e674fabd0e264fc7f7aa04d963c5", + "rust-analyzer-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ba58e349f5e8b0ef13735c48d4ad8d8c7664472f8403f3c9d97b291bd54a7638", + "rust-analyzer-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "a9d71c6e7427c45afcd846a8b34a3e3301ae7a0e91a2bcf929326af77a7dc68e", + "rust-src-1.95.0.tar.xz": "67b09138c8db96afc4bbfc69ea771ac9a091fd777698acb43f6dfd9fb7dea363", + "rust-std-1.95.0-aarch64-apple-darwin.tar.xz": "9b30089b0f767cb91b2190ffec55a9beeb2a21a1405d8da0f664d7e09d08e6d8", + "rust-std-1.95.0-aarch64-apple-ios-macabi.tar.xz": "0e1760828f4e0fa1cde0061ba5680619dcc1cdcafec9242cc18dc4547c73b1cd", + "rust-std-1.95.0-aarch64-apple-ios-sim.tar.xz": "4bfe5b0c74c10d121a8ac60f1833c7714b963f9130f6256ca313d94405267deb", + "rust-std-1.95.0-aarch64-apple-ios.tar.xz": "6fcc42d8dbba4910a128ffa32d62a730339a7e3882a90341a881f2edf66ff55a", + "rust-std-1.95.0-aarch64-linux-android.tar.xz": "de5e8fa5d955809891eea77682811fc90be705f78883bd94071e98f5a738d05b", + "rust-std-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "93d810b8872771afe04f66aa30a4eee48736aca693186e4c7cc2766e1a82e340", + "rust-std-1.95.0-aarch64-pc-windows-msvc.tar.xz": "be21b5a8a71c49b4dcbc19956233b0de7bfda3ee3c8a199148299f867e95cb42", + "rust-std-1.95.0-aarch64-unknown-fuchsia.tar.xz": "b311a0523f75e031d683d983515edb9baaf22a843dbd44ce2a30a3204752f592", + "rust-std-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "3a21b271b1ff973b94d69b25e7a39992f9fbcae1ab6d9475844a23e6ad3908ac", + "rust-std-1.95.0-aarch64-unknown-linux-musl.tar.xz": "f6710416ed9a7d5cf2a15efa761eb79a1deeb43f9961bbe05cc97bec4ef9064a", + "rust-std-1.95.0-aarch64-unknown-none-softfloat.tar.xz": "54d691468e25e7989b022a171337beadf78b5202877b312b75182b7f93efbb8b", + "rust-std-1.95.0-aarch64-unknown-none.tar.xz": "2b0c986dc9902866311f1fe2d44bc2bd84479d2ac84ed7ada76a5eb7ba37080f", + "rust-std-1.95.0-aarch64-unknown-uefi.tar.xz": "ca657564103024d345ca32e8e4ade7ebb395a51163d2897d9e5f8373c025e49e", + "rust-std-1.95.0-arm-linux-androideabi.tar.xz": "12a9c5fa24608159c2b2bd50abc0c6d0add407c0258cee894c2f61c07051a9c4", + "rust-std-1.95.0-arm-unknown-linux-gnueabi.tar.xz": "f6cd592dacdf41f724ee90a2f34db028e37ca2a7fb26fa86e93e8fd68e24d066", + "rust-std-1.95.0-arm-unknown-linux-gnueabihf.tar.xz": "fda8408ea17881c6529e27e58672d6c628f786cad557fac92856077e7a610239", + "rust-std-1.95.0-arm-unknown-linux-musleabi.tar.xz": "62f21fabc209fd0de53156764fc426da74c59525bb60cb4c1c3ffd1be0bbe00b", + "rust-std-1.95.0-arm-unknown-linux-musleabihf.tar.xz": "2842ce67f7a4c68c6e8b30ad3bb36484fb745edcc2694b2a36bf0609cf758044", + "rust-std-1.95.0-armv7-linux-androideabi.tar.xz": "61e95e144986c52ff9fa2fe3c249a68b2bf268adb2a2eeb81d80c180e43027f1", + "rust-std-1.95.0-armv7-unknown-linux-gnueabi.tar.xz": "e23454d6ca7fc3f5eb7cf9241572765176d86e9f45d4f394de31a5fd794e523f", + "rust-std-1.95.0-armv7-unknown-linux-gnueabihf.tar.xz": "bd319e18ca2dad0450f76277874d56356330da536be8cf271509f8e6f28ac6de", + "rust-std-1.95.0-armv7-unknown-linux-musleabi.tar.xz": "a49bc987e0531800f92825ce61402b5734ea747caa8970d3373506742ed7daa6", + "rust-std-1.95.0-armv7-unknown-linux-musleabihf.tar.xz": "77f9aaff4669c076edc96cdb99dc21749d2c692c862232b52176572c289fe671", + "rust-std-1.95.0-i686-linux-android.tar.xz": "e7b5e18d1d4119c7c1454ee8427933f27aeff9e81a22248c2829f5f299d3a937", + "rust-std-1.95.0-i686-pc-windows-gnu.tar.xz": "2e98fb94fc500690d7e72e071ce29dd98790ff518163963f5c82c32afba9231d", + "rust-std-1.95.0-i686-pc-windows-gnullvm.tar.xz": "b429a8c456d6a1815ece064102bd6a25f67d46db4f6d7b79dbe7d1b9d1f78e0f", + "rust-std-1.95.0-i686-pc-windows-msvc.tar.xz": "6206fd7e8bd119e9d1ba1c425ad25c282512eb0d5659f2dcb4be224b84715706", + "rust-std-1.95.0-i686-unknown-freebsd.tar.xz": "3b13b3ccecf482c0da3e94f19e0aa5e8e375622f4e09b30e6dae2b7638bee63f", + "rust-std-1.95.0-i686-unknown-linux-gnu.tar.xz": "527c5d5249a7f77b48d3c9da3ac512d27b47f43d08dbe3c6f82a3d5b35d8aa27", + "rust-std-1.95.0-i686-unknown-linux-musl.tar.xz": "af4d3e7aabb63d39a7a2ff5435cc993b65ff38a2d2e23f1967e519037a1b0455", + "rust-std-1.95.0-i686-unknown-uefi.tar.xz": "3233985273616ec36861f2d50b4a025c903b2bb8c45b171c0ae9e2de8342125b", + "rust-std-1.95.0-powerpc-unknown-linux-gnu.tar.xz": "59e0abbaa246502521e37c55b8d6cf88d5b8a697b0c70c61ec189937308f7246", + "rust-std-1.95.0-powerpc64-unknown-linux-gnu.tar.xz": "cc7fb9aa289ff1756502ae16a05e2885289165f01ed94a7c2db6576b3dae74a6", + "rust-std-1.95.0-powerpc64le-unknown-linux-gnu.tar.xz": "2370d9266051a0b23346d42e43a00f91b2daff22a963fb03e28ae50cb0b76c50", + "rust-std-1.95.0-powerpc64le-unknown-linux-musl.tar.xz": "0362ba4ecfe0bb508f0d2b064c6fbbfe72604d5ae1989d6a8a7b4fe5ff1889f1", + "rust-std-1.95.0-riscv32imc-unknown-none-elf.tar.xz": "04befebf3b15372dacb7e6c0fcdab842c17a63ea58bf2f4cdccd7182ae9195c6", + "rust-std-1.95.0-riscv64gc-unknown-linux-gnu.tar.xz": "50fe7869e166bb4c990a0e1664366b1ffdbe669664b7663cd03c079bd0efdcac", + "rust-std-1.95.0-riscv64gc-unknown-linux-musl.tar.xz": "e01bdbf5d6fa3e529671d49e87ba81dc9612101144f3ee5a0e1de3c48f27b47c", + "rust-std-1.95.0-riscv64gc-unknown-none-elf.tar.xz": "a4cb7a1527f3b56a39464e5ca2b174a27b708b26d78e604735eb5ffd9ee4d20b", + "rust-std-1.95.0-s390x-unknown-linux-gnu.tar.xz": "31978c1286afff9a0bb7f01c2ae4a39f40727b6100a82b6d934f146b06cde510", + "rust-std-1.95.0-thumbv6m-none-eabi.tar.xz": "602ec023c4615fc1c2d78b688554d42fa525e07e861c052f406fd7a607e5d5ee", + "rust-std-1.95.0-thumbv7em-none-eabi.tar.xz": "fb671966ba9aede333956ed43fcfe114ec890ca6e70369c9f3219871ee3ae8ae", + "rust-std-1.95.0-thumbv7em-none-eabihf.tar.xz": "fa3d189c09b64d818ad65a3fec1ce1c7d7b3908aea6fc4607a3fcc05067cad81", + "rust-std-1.95.0-thumbv7m-none-eabi.tar.xz": "e9e39f483ad4c1ce55fa4f508009e8d7d7ef25efe6592f7bfbabc159a24658ff", + "rust-std-1.95.0-thumbv8m.main-none-eabi.tar.xz": "3b64bffb193b37e83dc7b169add55f0ef3298220f8475468be4bb87276a68105", + "rust-std-1.95.0-thumbv8m.main-none-eabihf.tar.xz": "25ec92187d3a45fb1e397b0ba6000341d872523925a99a48b1978bdf1e6038cb", + "rust-std-1.95.0-wasm32-unknown-emscripten.tar.xz": "967b92a8682e8b8a1a459d776b36e41c906cdcac1008fef70e4800fec40d6864", + "rust-std-1.95.0-wasm32-unknown-unknown.tar.xz": "5587b89ff69623d09e476439d44a24453b4e4ea3d5e0b53a5c0a935151ff3fd1", + "rust-std-1.95.0-wasm32-wasip1-threads.tar.xz": "9079935a00a3c3aaf284957bbe82972983ce2708019687cec1f4988c30c1e0f3", + "rust-std-1.95.0-wasm32-wasip1.tar.xz": "86e5b6d98c7520bb9c3ad4f8cbbbf14beaf230b0f06b437db398e3c4f7dae43e", + "rust-std-1.95.0-wasm32-wasip2.tar.xz": "68146eb4c887431379966efa21d75dc957f18bd1166239c1deaa53fe38cb9ab4", + "rust-std-1.95.0-x86_64-apple-darwin.tar.xz": "2be13c14122b8d4d09b7f7c434fca9ae7215ec72049944189c88c4d9128ce504", + "rust-std-1.95.0-x86_64-apple-ios-macabi.tar.xz": "60b92e51e87f84046e0b19ccedc88c45b4b62c3ab10351f8473453f341c894f1", + "rust-std-1.95.0-x86_64-apple-ios.tar.xz": "11abb7b1c92b5a88b8c3ba21ee596a1be7dee5e817b336f26b4968d8ed5513ad", + "rust-std-1.95.0-x86_64-linux-android.tar.xz": "77b8e2be4a6e784a63cd77de944864c8044ddf4d5c7d56f663ada8a38a8319c4", + "rust-std-1.95.0-x86_64-pc-windows-gnu.tar.xz": "f57e045016a04130125fb43295d95f9ad2bebc296150eadb031dbf5167ad12bd", + "rust-std-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "3c52a0e34e0b4abe4439cabd77383408f5f9c80b6e249fbc0855df424b45008d", + "rust-std-1.95.0-x86_64-pc-windows-msvc.tar.xz": "7c659bdc88646e7e1befa370881bd311be87b26f006933a28b40dcab2f7cc832", + "rust-std-1.95.0-x86_64-unknown-freebsd.tar.xz": "dfe913c2f477172db10d3723d9f5c536d8b6f42776c979cc855820d8249adec3", + "rust-std-1.95.0-x86_64-unknown-fuchsia.tar.xz": "faa2bc09a3992f1d81b538121d64a3a396f4ec666e665c79d2ab47461c2d3206", + "rust-std-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "047ea7098803d3500fa1072e9cee5392697e21525559e4458128a2bf874aa382", + "rust-std-1.95.0-x86_64-unknown-linux-musl.tar.xz": "aee540abf132920f791ef781489851a078d69dff493fb628d49c1d573f92bb3a", + "rust-std-1.95.0-x86_64-unknown-netbsd.tar.xz": "7a82b71c53f20cb147a340819fcab645da220b312a96194531e631ee99783a7d", + "rust-std-1.95.0-x86_64-unknown-none.tar.xz": "7c151c0e7bf3b0b4d7136774cd3686e5f691b761b648b17e83af58e7669d3e01", + "rust-std-1.95.0-x86_64-unknown-uefi.tar.xz": "4cc55629480aa8ab5b39eb6b7458433b48461d6626fdea0330fb88e23af818ea", + "rustc-1.95.0-aarch64-apple-darwin.tar.xz": "149e85a285b6eba58eb6c8bdf7deb1b93763890598e62cb635a712e3a8454f04", + "rustc-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "758e729faabd8dabfab584d2bead59ca4fbcf1125ffc3c43a69332d6d9f2316b", + "rustc-1.95.0-aarch64-pc-windows-msvc.tar.xz": "0dbec9739b93427ccdd3948c3b1f83cec42e4c9545d930a8d1e1464ff4092c5f", + "rustc-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "0fe3689eeaed603e5ef24572d11597d3edadaefd2cb181674ad621260f2501d2", + "rustc-1.95.0-x86_64-apple-darwin.tar.xz": "33db457715446a69ed6f69f78f5fbb9ca8e17a16585d1d7a0060479bfe4c7afc", + "rustc-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "44ffbe057bb8f967087a1ad549a7139e9e5017d3aab396f42a6393f897e39531", + "rustc-1.95.0-x86_64-pc-windows-msvc.tar.xz": "4cb1f3b578adc6541cbe13a6f85f1fd8c0ce643d90b506a36dee24c680864c67", + "rustc-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "8426a3d170a5879f5682f5fbdd024a1779b3951e7baba685af2d6dc32a6dfc15", + "rustfmt-1.95.0-aarch64-apple-darwin.tar.xz": "c54af79adfdc790d27fc56e24407e370c80be5a89ad537eef9fbd45d3c3e28e8", + "rustfmt-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "85b690b664ee4f03521c6bc3f1af8fd7e6efae5851a8a7ff18965594484688e2", + "rustfmt-1.95.0-aarch64-pc-windows-msvc.tar.xz": "a873c048743e6da29e09a8b55c774ee113f8f6ae4fd57d988d304a6453801b34", + "rustfmt-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "64cce868f0f3d29f1524e11e9bab01ac9d538a31665fea1cd6b78af46a1c0a41", + "rustfmt-1.95.0-x86_64-apple-darwin.tar.xz": "5f7228f40a160e80d260e74e068d6fec8627aa02f1f5ae29d2019b9347076401", + "rustfmt-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "74eb0bb0227af0df81a5df9cbf8384e5e038c4cd575152ecc27dfa891ff3756a", + "rustfmt-1.95.0-x86_64-pc-windows-msvc.tar.xz": "8bcf91606e36b8a0164efafde50709cd7a3c02143a2edaa81dbf3dccd6ed8f4c", + "rustfmt-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "f1b2a7301513ffdd95ebf22ebbdd932e4d17fc806f748d93924740d0297b1396" } } } diff --git a/codex-rs/.github/workflows/cargo-audit.yml b/codex-rs/.github/workflows/cargo-audit.yml index 0c41471b65..1d935d61e8 100644 --- a/codex-rs/.github/workflows/cargo-audit.yml +++ b/codex-rs/.github/workflows/cargo-audit.yml @@ -17,7 +17,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install cargo-audit uses: taiki-e/install-action@v2 with: diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index fcaa8a1eb5..56cf7038f2 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -948,10 +948,8 @@ impl ScanState { '(' => self.depth.paren += 1, ')' => self.depth.paren = (self.depth.paren - 1).max(0), '<' => self.depth.angle += 1, - '>' => { - if self.depth.angle > 0 { - self.depth.angle -= 1; - } + '>' if self.depth.angle > 0 => { + self.depth.angle -= 1; } _ => {} } @@ -2212,20 +2210,14 @@ mod tests { continue; } match ch { - '\\' => { - if in_single || in_double { - escape = true; - } + '\\' if (in_single || in_double) => { + escape = true; } - '\'' => { - if !in_double { - in_single = !in_single; - } + '\'' if !in_double => { + in_single = !in_single; } - '"' => { - if !in_single { - in_double = !in_double; - } + '"' if !in_single => { + in_double = !in_double; } '{' if !in_single && !in_double => level_brace += 1, '}' if !in_single && !in_double => level_brace -= 1, diff --git a/codex-rs/app-server-protocol/src/schema_fixtures.rs b/codex-rs/app-server-protocol/src/schema_fixtures.rs index 447fcf8cfe..18ed557ddf 100644 --- a/codex-rs/app-server-protocol/src/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/src/schema_fixtures.rs @@ -196,7 +196,7 @@ fn canonicalize_json(value: &Value) -> Value { } Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize_json(child)); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 6358d592e4..95bc9d3c6f 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1390,14 +1390,13 @@ pub(super) async fn connect_remote_control_websocket( status_publisher.publish_environment_id(/*environment_id*/ None); } tungstenite::Error::Http(response) - if matches!(response.status().as_u16(), 401 | 403) => + if matches!(response.status().as_u16(), 401 | 403) + && recover_remote_control_auth(auth_recovery, auth_change_rx).await => { - if recover_remote_control_auth(auth_recovery, auth_change_rx).await { - return Err(io::Error::other(format!( - "remote control websocket auth failed with HTTP {}; retrying after auth recovery", - response.status() - ))); - } + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; retrying after auth recovery", + response.status() + ))); } _ => {} } diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index 177e378906..5677faae67 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -347,7 +347,7 @@ async fn run_websocket_inbound_loop( incoming_message = websocket_reader.next() => { match incoming_message { Some(Ok(message)) => match message.into_incoming() { - Some(IncomingWebSocketMessage::Text(text)) => { + Some(IncomingWebSocketMessage::Text(text)) if !forward_incoming_message( &transport_event_tx, &writer_tx_for_reader, @@ -355,10 +355,10 @@ async fn run_websocket_inbound_loop( &text, ) .await - { - break; - } + => { + break; } + Some(IncomingWebSocketMessage::Text(_)) => {} Some(IncomingWebSocketMessage::Ping(payload)) => { match writer_control_tx.try_send(M::pong(payload)) { Ok(()) => {} diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 6cab439bcc..e8fffd5361 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -456,8 +456,8 @@ impl OutgoingMessageSender { ) -> Vec { let request_id_to_callback = self.request_id_to_callback.lock().await; let mut requests = request_id_to_callback - .iter() - .filter_map(|(_, entry)| { + .values() + .filter_map(|entry| { (entry.thread_id == Some(thread_id)).then_some(entry.request.clone()) }) .collect::>(); diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index cf76b9d573..0e00bc6309 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -758,12 +758,15 @@ pub(super) async fn read_response_and_notification_for_method( JSONRPCMessage::Response(candidate) if candidate.id == target_id => { response = Some(candidate); } + JSONRPCMessage::Notification(candidate) + if candidate.method == method && notification.is_some() => + { + bail!( + "received duplicate notification for method `{method}` before completing paired read" + ); + } JSONRPCMessage::Notification(candidate) if candidate.method == method => { - if notification.replace(candidate).is_some() { - bail!( - "received duplicate notification for method `{method}` before completing paired read" - ); - } + notification = Some(candidate); } _ => {} } diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index aa2326ab28..4e79141aab 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -776,7 +776,7 @@ fn compute_replacements( } } - replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx)); + replacements.sort_by_key(|(index, _, _)| *index); Ok(replacements) } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index d2422d05de..2e53d5d51f 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2176,11 +2176,7 @@ struct RolloutStats { impl RolloutStats { fn average_bytes(&self) -> u64 { - if self.files == 0 { - 0 - } else { - self.total_bytes / self.files - } + self.total_bytes.checked_div(self.files).unwrap_or(0) } } diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index a84d1c9019..0103782653 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -537,7 +537,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; let mut entries: Vec<_> = mcp_servers.iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries.sort_by_key(|(name, _)| *name); let auth_statuses = compute_auth_statuses( effective_mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, @@ -906,7 +906,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let headers_display = match http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); pairs .into_iter() .map(|(k, _)| format!("{k}=*****")) @@ -919,7 +919,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let env_headers_display = match env_http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); pairs .into_iter() .map(|(k, var)| format!("{k}={var}")) diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 0c2813e51a..1362b51a4c 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -649,7 +649,7 @@ fn render_json_schema_object(map: &serde_json::Map) -> String .unwrap_or_default(); let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a); if sorted_properties .iter() .any(|(_, value)| has_property_description(value)) diff --git a/codex-rs/codex-client/src/custom_ca.rs b/codex-rs/codex-client/src/custom_ca.rs index 7a8b2f27bd..1a211beedf 100644 --- a/codex-rs/codex-client/src/custom_ca.rs +++ b/codex-rs/codex-client/src/custom_ca.rs @@ -477,15 +477,13 @@ impl ConfiguredCaBundle { })?; certificates.push(CertificateDer::from(cert_der.to_vec())); } - SectionKind::Crl => { - if !logged_crl_presence { - info!( - source_env = self.source_env, - ca_path = %self.path.display(), - "ignoring X509 CRL entries found in custom CA bundle" - ); - logged_crl_presence = true; - } + SectionKind::Crl if !logged_crl_presence => { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + "ignoring X509 CRL entries found in custom CA bundle" + ); + logged_crl_presence = true; } _ => {} } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 20a0769d83..3dbf483c2d 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -861,7 +861,7 @@ fn project_config_for_lookup_key( .iter() .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key) .collect(); - normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches.sort_by_key(|(key, _)| *key); normalized_matches .first() .map(|(_, project_config)| (**project_config).clone()) diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index e8972ce5ad..3c1723e51b 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1006,7 +1006,7 @@ fn project_trust_for_lookup_key( .iter() .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key) .collect(); - normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches.sort_by_key(|(key, _)| *key); normalized_matches .first() .map(|(key, trust_level)| ((**key).clone(), **trust_level)) diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 7e062e2998..9f007de113 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -230,7 +230,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { let mut tools = TomlTable::new(); tools.set_implicit(false); let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + tool_entries.sort_by_key(|(name, _)| *name); for (name, tool_config) in tool_entries { let mut tool_entry = TomlTable::new(); tool_entry.set_implicit(false); @@ -280,7 +280,7 @@ where I: IntoIterator, { let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut table = TomlTable::new(); table.set_implicit(false); for (key, value_str) in entries { diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index df0de3b1cd..e0d2c70671 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -99,7 +99,7 @@ pub fn canonicalize(value: &Value) -> Value { Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize(child)); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index ca54a7c3f5..290edf6fb8 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -179,7 +179,7 @@ pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit { pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); - shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); + shown_count_entries.sort_unstable_by_key(|(left, _)| *left); let mut edits = vec![ConfigEdit::ClearPath { segments: vec!["tui".to_string(), "model_availability_nux".to_string()], @@ -354,7 +354,7 @@ mod document_helpers { if !config.tools.is_empty() { let mut tools = new_implicit_table(); let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + tool_entries.sort_by_key(|(name, _)| *name); for (name, tool_config) in tool_entries { tools.insert(name, serialize_mcp_server_tool(tool_config)); } @@ -501,7 +501,7 @@ mod document_helpers { I: IntoIterator, { let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries.sort_by_key(|(key, _)| *key); let mut table = TomlTable::new(); table.set_implicit(false); for (key, val) in entries { diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs index 66002ab5ab..1c905b4f27 100644 --- a/codex-rs/core/src/mcp_tool_approval_templates.rs +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -170,7 +170,7 @@ fn render_tool_params( .iter() .filter(|(name, _)| !handled_names.contains(name.as_str())) .collect::>(); - remaining_params.sort_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); + remaining_params.sort_by_key(|(name, _)| *name); for (name, value) in remaining_params { if handled_names.contains(name.as_str()) { diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index 48eec66c58..e491ca7413 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -30,7 +30,7 @@ use crate::unified_exec; static TEST_MODEL_PRESETS: Lazy> = Lazy::new(|| { let mut response = bundled_models_response() .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); - response.models.sort_by(|a, b| a.priority.cmp(&b.priority)); + response.models.sort_by_key(|model| model.priority); let mut presets: Vec = response.models.into_iter().map(Into::into).collect(); ModelPreset::mark_default_by_picker_visibility(&mut presets); presets diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 4f459a0d0e..f1e7bcd429 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -105,7 +105,7 @@ pub(crate) fn build_wait_agent_statuses( status: status.clone(), }) .collect::>(); - extras.sort_by(|left, right| left.thread_id.to_string().cmp(&right.thread_id.to_string())); + extras.sort_by_key(|entry| entry.thread_id.to_string()); entries.extend(extras); entries } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index be6d30c6fa..20f4eae489 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -559,10 +559,8 @@ async fn summarize_context_three_requests_and_instructions() { RolloutItem::TurnContext(_) => { regular_turn_context_count += 1; } - RolloutItem::Compacted(ci) => { - if ci.message == expected_summary_message { - saw_compacted_summary = true; - } + RolloutItem::Compacted(ci) if ci.message == expected_summary_message => { + saw_compacted_summary = true; } _ => {} } diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d8779bfb05..bd290cad41 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -110,7 +110,7 @@ fn canonical_json(value: &Value) -> Value { match value { Value::Object(map) => { let mut entries = map.iter().collect::>(); - entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + entries.sort_by_key(|(left_key, _)| *left_key); Value::Object( entries .into_iter() diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 8979b2cced..22678fc46f 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -1047,7 +1047,7 @@ fn canonical_json(value: &Value) -> Value { match value { Value::Object(map) => { let mut entries = map.iter().collect::>(); - entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + entries.sort_by_key(|(left_key, _)| *left_key); Value::Object( entries .into_iter() diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index ed2f1a8e1b..92284b2a1e 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -638,10 +638,8 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { fn normalize_line_endings(value: &mut Value) { match value { - Value::String(text) => { - if text.contains('\r') { - *text = text.replace("\r\n", "\n").replace('\r', "\n"); - } + Value::String(text) if text.contains('\r') => { + *text = text.replace("\r\n", "\n").replace('\r', "\n"); } Value::Array(items) => { for item in items { diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 05428799ad..b99333e66c 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1835,15 +1835,14 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge "head detail ".repeat(120), "tail detail ".repeat(170), ); - let mut user_turns = (1..=7) + let user_turns = (1..=7) .map(|index| { format!( "short-turn-{index}-start {} short-turn-{index}-end", "detail ".repeat(86) ) }) - .collect::>(); - user_turns.push(latest_long_user_turn.clone()); + .chain([latest_long_user_turn.clone()]); let mut builder = test_codex().with_config({ let realtime_base_url = realtime_server.uri().to_string(); @@ -1858,7 +1857,6 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge // end-to-end startup-context test without paying for a model turn per // fixture entry in platform CI. let history = user_turns - .into_iter() .enumerate() .flat_map(|(index, user_turn)| { let turn_number = index + 1; diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 69eb474aaf..90bccf1daa 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -312,10 +312,8 @@ impl AppServerClient { }; match message { - JSONRPCMessage::Response(response) => { - if &response.id == request_id { - return Ok(response); - } + JSONRPCMessage::Response(response) if &response.id == request_id => { + return Ok(response); } JSONRPCMessage::Request(request) => { let _ = handle_server_request(request, &stdin); diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index aae415f047..e06a736c17 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -218,7 +218,7 @@ impl LazyRemoteExecServerClient { } let next_client = match self.cached_client() { - Some(client) + Some(_client) if matches!( &self.transport_params, ExecServerTransportParams::WebSocketUrl { .. } diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 76b0f22b2b..3356791d44 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -137,7 +137,7 @@ fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result Vec { let mut roots = Vec::new(); for path in std::iter::once(runtime_paths.codex_self_exe.as_path()) - .chain(runtime_paths.codex_linux_sandbox_exe.as_deref().into_iter()) + .chain(runtime_paths.codex_linux_sandbox_exe.as_deref()) { if let Some(parent) = path.parent() && let Ok(root) = AbsolutePathBuf::from_absolute_path(parent) diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 3f17986dec..bd2ac16dfb 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -707,7 +707,7 @@ fn canonicalize_json(value: &Value) -> Value { Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()), Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize_json(child)); diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 1844ab3b52..21b20759b1 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -206,7 +206,7 @@ async fn run_jobs( claimed_candidates: Vec, stage_one_context: StageOneRequestContext, ) -> Vec { - futures::stream::iter(claimed_candidates.into_iter()) + futures::stream::iter(claimed_candidates) .map(|claim| { let context = Arc::clone(&context); let config = Arc::clone(&config); diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index af510c8d73..ac4044ac20 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -107,7 +107,7 @@ pub trait ModelsManager: fmt::Debug + Send + Sync { /// Build picker-ready presets from the active catalog snapshot. fn build_available_models(&self, mut remote_models: Vec) -> Vec { - remote_models.sort_by(|a, b| a.priority.cmp(&b.priority)); + remote_models.sort_by_key(|model| model.priority); let mut presets: Vec = remote_models.into_iter().map(Into::into).collect(); let uses_codex_backend = self diff --git a/codex-rs/models-manager/src/test_support.rs b/codex-rs/models-manager/src/test_support.rs index aff2838907..7397231689 100644 --- a/codex-rs/models-manager/src/test_support.rs +++ b/codex-rs/models-manager/src/test_support.rs @@ -14,7 +14,7 @@ pub fn get_model_offline_for_tests(model: Option<&str>) -> String { return model.to_string(); } let mut response = bundled_models_response().unwrap_or_default(); - response.models.sort_by(|a, b| a.priority.cmp(&b.priority)); + response.models.sort_by_key(|model| model.priority); let presets: Vec = response.models.into_iter().map(Into::into).collect(); presets .iter() diff --git a/codex-rs/rust-toolchain.toml b/codex-rs/rust-toolchain.toml index 954b684895..910c65114e 100644 --- a/codex-rs/rust-toolchain.toml +++ b/codex-rs/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.0" +channel = "1.95.0" components = ["clippy", "rustfmt", "rust-src"] diff --git a/codex-rs/scripts/setup-windows.ps1 b/codex-rs/scripts/setup-windows.ps1 index df87731380..a8fe0c2f75 100644 --- a/codex-rs/scripts/setup-windows.ps1 +++ b/codex-rs/scripts/setup-windows.ps1 @@ -179,7 +179,7 @@ if (-not (Ensure-Command 'cargo')) { Write-Host "==> Configuring Rust toolchain per rust-toolchain.toml" -ForegroundColor Cyan # Pin to the workspace toolchain and install components -$toolchain = '1.93.0' +$toolchain = '1.95.0' & rustup toolchain install $toolchain --profile minimal | Out-Host & rustup default $toolchain | Out-Host & rustup component add clippy rustfmt rust-src --toolchain $toolchain | Out-Host diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 350d22cc6d..3101500796 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -511,7 +511,7 @@ WHERE so.thread_id = ? AND so.source_updated_at = ? } } - selected.sort_by(|a, b| a.thread_id.to_string().cmp(&b.thread_id.to_string())); + selected.sort_by_key(|entry| entry.thread_id.to_string()); Ok(selected) } diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index b0c9e7e1be..a4e514a433 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -104,10 +104,10 @@ impl ThreadEventStore { ServerNotification::TurnStarted(turn) => { self.active_turn_id = Some(turn.turn.id.clone()); } - ServerNotification::TurnCompleted(turn) => { - if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) { - self.active_turn_id = None; - } + ServerNotification::TurnCompleted(turn) + if self.active_turn_id.as_deref() == Some(turn.turn.id.as_str()) => + { + self.active_turn_id = None; } ServerNotification::ThreadClosed(_) => { self.active_turn_id = None; diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 13cbc34daa..16baedc097 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1188,11 +1188,9 @@ impl BottomPaneView for RequestUserInputOverlay { KeyCode::Backspace | KeyCode::Delete => { self.clear_selection(); } - KeyCode::Tab => { - if self.selected_option_index().is_some() { - self.focus = Focus::Notes; - self.ensure_selected_for_notes(); - } + KeyCode::Tab if self.selected_option_index().is_some() => { + self.focus = Focus::Notes; + self.ensure_selected_for_notes(); } KeyCode::Enter => { let has_selection = self.selected_option_index().is_some(); diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index b3b58cbc82..6d56330367 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -332,10 +332,8 @@ impl ChatWidget { reasoning_effort, agents_states, }), - ThreadItem::EnteredReviewMode { review, .. } => { - if !from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ false); - } + ThreadItem::EnteredReviewMode { review, .. } if !from_replay => { + self.enter_review_mode_with_hint(review, /*from_replay*/ false); } _ => {} } diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 5b761c1ef5..7b1e8eebc4 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -358,7 +358,7 @@ pub(crate) fn new_mcp_tools_output( let effective_servers = config.mcp_servers.get().clone(); let mut servers: Vec<_> = effective_servers.iter().collect(); - servers.sort_by(|(a, _), (b, _)| a.cmp(b)); + servers.sort_by_key(|(server, _)| *server); for (server, cfg) in servers { let prefix = qualified_mcp_tool_name_prefix(server); @@ -430,7 +430,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); let display = pairs .into_iter() .map(|(name, _)| format!("{name}=*****")) @@ -442,7 +442,7 @@ pub(crate) fn new_mcp_tools_output( && !headers.is_empty() { let mut pairs: Vec<_> = headers.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); let display = pairs .into_iter() .map(|(name, var)| format!("{name}={var}")) diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 46bbb35eed..23e8836454 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -58,7 +58,7 @@ fn with_border_internal( let span_count = line.spans.len(); let mut spans: Vec> = Vec::with_capacity(span_count + 4); spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); + spans.extend(line); if used_width < content_width { spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); } diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 4fb5c8c265..3273c610ae 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -516,7 +516,7 @@ fn wait_complete_lines( .then(|| (parsed_thread_id, agent_metadata(parsed_thread_id), status)) }) .collect::>(); - extras.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string())); + extras.sort_by_key(|entry| entry.0.to_string()); entries.extend(extras); if entries.is_empty() { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 63f0c8773b..6e8b65decd 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1159,23 +1159,21 @@ impl PickerState { self.request_frame(); } } - _ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key) => { - if !self.filtered_rows.is_empty() { + _ if allow_plain_char_navigation && self.list_keymap.jump_top.is_pressed(key) + && !self.filtered_rows.is_empty() => { self.selected = 0; self.ensure_selected_visible(); self.request_frame(); } - } - _ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key) => { - if !self.filtered_rows.is_empty() { + _ if allow_plain_char_navigation && self.list_keymap.jump_bottom.is_pressed(key) + && !self.filtered_rows.is_empty() => { self.selected = self.filtered_rows.len().saturating_sub(1); self.ensure_selected_visible(); self.maybe_load_more_for_scroll(); self.request_frame(); } - } - _ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key) => { - if !self.filtered_rows.is_empty() { + _ if allow_plain_char_navigation && self.list_keymap.page_down.is_pressed(key) + && !self.filtered_rows.is_empty() => { let step = self.view_rows.unwrap_or(10).max(1); let target = self.selected.saturating_add(step); let max_index = self.filtered_rows.len().saturating_sub(1); @@ -1189,7 +1187,6 @@ impl PickerState { } self.request_frame(); } - } KeyEvent { code: KeyCode::Tab, .. } => { @@ -1222,16 +1219,15 @@ impl PickerState { code: KeyCode::Char(c), modifiers, .. - } => { + } // basic text input for search if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) - { + => { let mut new_query = self.query.clone(); new_query.push(c); self.set_query(new_query); } - } _ => {} } Ok(None) diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index ae93890bbb..a89f392ebb 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -284,7 +284,7 @@ pub(crate) fn center_truncate_path(path: &str, max_width: usize) -> String { } }; - for (left_count, right_count) in prioritized.into_iter().chain(fallback.into_iter()) { + for (left_count, right_count) in prioritized.into_iter().chain(fallback) { let mut segments: Vec> = raw_segments[..left_count] .iter() .map(|seg| Segment { diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs index 90393ed0b1..1c3171045e 100644 --- a/codex-rs/tui/src/theme_picker.rs +++ b/codex-rs/tui/src/theme_picker.rs @@ -199,10 +199,9 @@ fn render_preview( 0 }; - let mut y = area.y.saturating_add(top_pad); let render_width = area.width.saturating_sub(left_pad); let style_context = current_diff_render_style_context(); - for (idx, row) in preview_rows.iter().enumerate() { + for (y, (idx, row)) in (area.y.saturating_add(top_pad)..).zip(preview_rows.iter().enumerate()) { if y >= area.y + area.height { break; } @@ -232,7 +231,6 @@ fn render_preview( Rect::new(area.x.saturating_add(left_pad), y, render_width, 1), buf, ); - y += 1; } } diff --git a/codex-rs/utils/cli/src/format_env_display.rs b/codex-rs/utils/cli/src/format_env_display.rs index cee63bb32b..714b44c312 100644 --- a/codex-rs/utils/cli/src/format_env_display.rs +++ b/codex-rs/utils/cli/src/format_env_display.rs @@ -8,7 +8,7 @@ pub fn format_env_display>( if let Some(map) = env { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(key, _)| *key); parts.extend(pairs.into_iter().map(|(key, _)| format!("{key}=*****"))); } From 8ed38fe38eb99dd51a0eef33366a45a392733b82 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Tue, 26 May 2026 22:09:54 -0700 Subject: [PATCH 021/204] fix: add noninteractive install script mode (#21567) # Summary The Codex standalone installers can pause after installation to ask about an older managed install or launching Codex. That makes unattended bootstrap and update flows hard to complete reliably. This PR adds noninteractive installer control on macOS/Linux and Windows through `CODEX_NON_INTERACTIVE=1`. Noninteractive operation is environment-only, which gives automated callers one stable way to suppress prompts. When a noninteractive install leaves an older npm, bun, or brew-managed Codex installed, the standalone bin is configured ahead of that command on `PATH` so the newly installed Codex is the one future launches select. It also supports `CODEX_RELEASE` for callers that select a release through environment variables while retaining the existing explicit release inputs. Release selection accepts `latest`, stable `x.y.z` versions, and Codex prereleases written as `rust-v0.134.0-alpha.3`, `v0.134.0-alpha.3`, or `0.134.0-alpha.3`; it validates that shape before constructing release requests. # Stack 1. [#21567](https://github.com/openai/codex/pull/21567) - Adds release and noninteractive environment controls to the installers. (current) 2. [#24637](https://github.com/openai/codex/pull/24637) - Runs standalone updater installs with `CODEX_NON_INTERACTIVE=1`. 3. [#24639](https://github.com/openai/codex/pull/24639) - Removes explicit release argument inputs in favor of `CODEX_RELEASE`. # Evidence | Before | After | | --- | --- | | ![Interactive install prompts](https://github.com/user-attachments/assets/feecb45a-7087-4681-8775-ba57b07e97fa) | ![Noninteractive install completes without prompts](https://github.com/user-attachments/assets/53dcc791-383a-46e2-9a95-3b37b80ae053) | Environment-controlled macOS install with an existing npm-managed Codex on `PATH`: https://github.com/user-attachments/assets/442e0b5b-4a32-4bf5-996b-68784777380d # Design decisions Windows installs using the older standalone bin layout still require an interactive migration confirmation. Noninteractive mode does not auto-migrate that existing directory because replacing it is a destructive transition for an early, limited-use layout; unattended installs on that layout fail with an instruction to rerun interactively. # Testing Tests: installer syntax validation, release-selector acceptance and rejection coverage including PowerShell `Latest` compatibility, macOS live-terminal installer smoke testing with environment-controlled stable and prerelease installation and competing PATH precedence, shell rejection of the omitted noninteractive flag, and Windows ARM64 PowerShell smoke testing with environment-only noninteractive behavior, retained release input, and competing PATH precedence through Parallels. --- scripts/install/install.ps1 | 59 ++++++++++++++++++++++++++++++++++--- scripts/install/install.sh | 32 ++++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 8ebc4fbb82..6973d482e2 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -1,11 +1,18 @@ +[CmdletBinding()] param( - [string]$Release = "latest" + [string]$Release = $env:CODEX_RELEASE ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" +if ([string]::IsNullOrWhiteSpace($Release)) { + $Release = "latest" +} + +$NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$" + function Write-Step { param( [string]$Message @@ -27,6 +34,10 @@ function Prompt-YesNo { [string]$Prompt ) + if ($NonInteractive) { + return $false + } + if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { return $false } @@ -55,6 +66,16 @@ function Normalize-Version { return $RawVersion } +function Assert-ValidReleaseVersion { + param( + [string]$Version + ) + + if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+)?)?$") { + throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." + } +} + function Find-ReleaseAssetMetadata { param( [string]$AssetName, @@ -141,6 +162,22 @@ function Path-Contains { return $false } +function Prepend-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + $needle = $Entry.TrimEnd("\") + $segments = @($Entry) + if (-not [string]::IsNullOrWhiteSpace($PathValue)) { + $segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) | + Where-Object { $_.TrimEnd("\") -ine $needle } + } + + return ($segments -join ";") +} + function Invoke-WithInstallLock { param( [string]$LockPath, @@ -181,6 +218,7 @@ function Remove-StaleInstallArtifacts { function Resolve-Version { $normalizedVersion = Normalize-Version -RawVersion $Release + Assert-ValidReleaseVersion -Version $normalizedVersion if ($normalizedVersion -ne "latest") { return $normalizedVersion } @@ -191,7 +229,9 @@ function Resolve-Version { exit 1 } - return (Normalize-Version -RawVersion $release.tag_name) + $resolvedVersion = Normalize-Version -RawVersion $release.tag_name + Assert-ValidReleaseVersion -Version $resolvedVersion + return $resolvedVersion } function Get-VersionFromBinary { @@ -839,7 +879,16 @@ try { Maybe-HandleConflictingInstall -Conflict $conflictingInstall $userPath = [Environment]::GetEnvironmentVariable("Path", "User") -if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { +$prioritizeVisibleBin = $null -ne $conflictingInstall +if ($prioritizeVisibleBin) { + $newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir + if ($newUserPath -cne $userPath) { + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + Write-Step "PATH updated for future PowerShell sessions." + } else { + Write-Step "$visibleBinDir is already first on PATH." + } +} elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { $newUserPath = $visibleBinDir } else { @@ -854,7 +903,9 @@ if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { Write-Step "PATH is already configured for future PowerShell sessions." } -if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { +if ($prioritizeVisibleBin) { + $env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir +} elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($env:Path)) { $env:Path = $visibleBinDir } else { diff --git a/scripts/install/install.sh b/scripts/install/install.sh index cc4fc91345..23980b0c5e 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -2,7 +2,8 @@ set -eu -RELEASE="latest" +RELEASE="${CODEX_RELEASE:-latest}" +NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}" BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" BIN_PATH="$BIN_DIR/codex" @@ -46,6 +47,19 @@ normalize_version() { esac } +validate_version() { + version="$1" + + if [ "$version" = "latest" ]; then + return + fi + + if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$'; then + echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." >&2 + exit 1 + fi +} + parse_args() { while [ "$#" -gt 0 ]; do case "$1" in @@ -60,6 +74,10 @@ parse_args() { --help | -h) cat </dev/null; then printf '%s [y/N] ' "$prompt" >/dev/tty if ! IFS= read -r answer Date: Tue, 26 May 2026 22:22:34 -0700 Subject: [PATCH 022/204] Allow runtime enablement for remote plugins (#24707) experimentalFeature/enablement/set now accepts remote_plugin as a supported runtime feature key --- codex-rs/app-server/README.md | 2 +- .../src/request_processors/config_processor.rs | 1 + .../tests/suite/v2/experimental_feature_list.rs | 11 ++++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index db76e363ec..88edc22f9e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -192,7 +192,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 72948270a6..39dbd17fe6 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -54,6 +54,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mentions_v2", "plugins", "remote_control", + "remote_plugin", "tool_suggest", "tool_call_mcp_elicitation", ]; diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index d6101995fa..782dba3edf 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -340,6 +340,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -352,6 +353,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -381,6 +383,13 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("remote_plugin")), + Some(&json!(true)) + ); assert_eq!( config .additional @@ -474,7 +483,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message From e88626621bad4bb2655fa8b5fc7d2f73d68e168d Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 27 May 2026 00:23:15 -0700 Subject: [PATCH 023/204] fix(auto-review) skip legacy notify for auto review threads (#24714) ## Summary Clear inherited legacy `notify` from Guardian review session config, since we should not be passing auto review threads into `notify` targets. Keeps legacy notify payload and hook runtime behavior unchanged for normal user turns. ## Testing - [x] add a Guardian config regression and dedicated Guardian integration test so review sessions cannot inherit parent notify hooks --- codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/guardian/tests.rs | 19 +++ codex-rs/core/tests/suite/guardian_review.rs | 169 +++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 2 + 4 files changed, 191 insertions(+) create mode 100644 codex-rs/core/tests/suite/guardian_review.rs diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index e56d5ce170..67f733e0ba 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -905,6 +905,7 @@ pub(crate) fn build_guardian_review_session_config( .map(guardian_policy_prompt_with_config) .unwrap_or_else(guardian_policy_prompt), ); + guardian_config.notify = None; guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); guardian_config diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 0544b27770..00f0d9be38 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2215,6 +2215,25 @@ async fn guardian_review_session_config_clears_parent_developer_instructions() { ); } +#[tokio::test] +async fn guardian_review_session_config_clears_legacy_notify() { + let mut parent_config = test_config().await; + parent_config.notify = Some(vec![ + "/path/to/notify".to_string(), + "turn-ended".to_string(), + ]); + + let guardian_config = build_guardian_review_session_config_for_test( + &parent_config, + /*live_network_config*/ None, + "active-model", + /*reasoning_effort*/ None, + ) + .expect("guardian config"); + + assert_eq!(guardian_config.notify, None); +} + #[tokio::test] async fn guardian_review_session_config_uses_live_network_proxy_state() { let mut parent_config = test_config().await; diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs new file mode 100644 index 0000000000..5b76d09ae9 --- /dev/null +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -0,0 +1,169 @@ +#![cfg(not(target_os = "windows"))] + +use anyhow::Result; +use codex_core::config::Constrained; +use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::fs_wait; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_sandbox; +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 std::fs; +use std::os::unix::fs::PermissionsExt; +use std::time::Duration; +use tempfile::TempDir; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let notify_dir = TempDir::new()?; + let notify_script = notify_dir.path().join("notify.sh"); + fs::write( + ¬ify_script, + r#"#!/bin/bash +set -e +payload_path="$(dirname "${0}")/notify.jsonl" +printf '%s\n' "${@: -1}" >> "${payload_path}""#, + )?; + fs::set_permissions(¬ify_script, fs::Permissions::from_mode(0o755))?; + let notify_file = notify_dir.path().join("notify.jsonl"); + let notify_script_str = notify_script.to_str().unwrap().to_string(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.notify = Some(vec![notify_script_str]); + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + }); + let test = builder.build(&server).await?; + + let output_file = test.cwd.path().join("guardian-review-notify.txt"); + let command = format!("printf guardian-approved > {}", output_file.display()); + let tool_args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian approval routing.", + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-parent-tool"), + ev_function_call( + "exec-call", + "exec_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-parent-tool"), + ]), + sse(vec![ + ev_response_created("resp-guardian-review"), + ev_assistant_message( + "msg-guardian-review", + &json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The command writes a marker file in the workspace.", + }) + .to_string(), + ), + ev_completed("resp-guardian-review"), + ]), + sse(vec![ + ev_response_created("resp-parent-done"), + ev_assistant_message("msg-parent-done", "done"), + ev_completed("resp-parent-done"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.cwd.path().to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_policy: Some(sandbox_policy), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let guardian_request = responses + .requests() + .into_iter() + .find(|request| request.body_contains_text("Exercise Guardian approval routing.")) + .expect("expected Guardian review request"); + assert!(guardian_request.body_contains_text(&command)); + + fs_wait::wait_for_path_exists(¬ify_file, Duration::from_secs(5)).await?; + tokio::time::sleep(Duration::from_millis(100)).await; + let notify_payload_raw = tokio::fs::read_to_string(¬ify_file).await?; + let payloads: Vec = notify_payload_raw + .lines() + .map(serde_json::from_str::) + .collect::>()?; + + assert_eq!( + payloads.len(), + 1, + "unexpected notify payloads: {payloads:?}" + ); + assert_eq!( + payloads[0]["input-messages"], + json!(["run a command that requires Guardian review"]) + ); + assert_eq!(payloads[0]["last-assistant-message"], json!("done")); + assert!( + !notify_payload_raw.contains( + "The following is the Codex agent history whose request action you are assessing." + ), + "Guardian review transcript leaked into legacy notify payload: {notify_payload_raw}" + ); + assert_eq!(fs::read_to_string(&output_file)?, "guardian-approved"); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f47de672bd..f4ab6c1d6d 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -50,6 +50,8 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; +#[cfg(not(target_os = "windows"))] +mod guardian_review; mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; From ca2e343f6564faaf740f50a0cf27b2092b6c40fd Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 27 May 2026 03:48:40 -0700 Subject: [PATCH 024/204] Revert "Add Bedrock Mantle GovCloud region (#23860)" (#24690) This reverts commit 5381240f57fe326b13bc81325f3c61596592fc7a. Gov cloud should not be supported # External (non-OpenAI) Pull Request Requirements External code contributions are by invitation only. Please read the dedicated "Contributing" markdown file for details: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- codex-rs/model-provider/src/amazon_bedrock/mantle.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs index e5b497d355..7881845e45 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mantle.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mantle.rs @@ -7,11 +7,10 @@ use super::auth::BedrockAuthMethod; use super::auth::resolve_auth_method; const BEDROCK_MANTLE_SERVICE_NAME: &str = "bedrock-mantle"; -const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 13] = [ +const BEDROCK_MANTLE_SUPPORTED_REGIONS: [&str; 12] = [ "us-east-2", "us-east-1", "us-west-2", - "us-gov-west-1", "ap-southeast-3", "ap-south-1", "ap-northeast-1", @@ -73,10 +72,6 @@ mod tests { base_url("ap-northeast-1").expect("supported region"), "https://bedrock-mantle.ap-northeast-1.api.aws/openai/v1" ); - assert_eq!( - base_url("us-gov-west-1").expect("supported region"), - "https://bedrock-mantle.us-gov-west-1.api.aws/openai/v1" - ); } #[test] From bc005029bdd88749f71be5fc9134b6a5b2aea941 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 27 May 2026 13:00:06 +0200 Subject: [PATCH 025/204] feat: handle goal usage limits in goal extension (#24628) ## Why The extracted goal runtime needs a host-callable path for turns that stop because the workspace usage limit is reached. In that case, any in-turn goal progress should be accounted before the goal becomes terminal, and active goal accounting must be cleared so later tool-finish or turn-stop handling does not keep charging usage to a stopped goal. ## What changed - Adds `GoalRuntimeHandle::usage_limit_active_goal_for_turn`, which accounts current active-goal progress, marks the active or budget-limited thread goal as `UsageLimited`, records terminal metrics when the status changes, clears active goal accounting, and emits the updated goal event. - Covers both active and budget-limited goals in `ext/goal/tests/goal_extension_backend.rs`, including the invariant that later token/tool events do not add usage after the goal has been usage-limited. ## Testing - Added `usage_limit_active_goal_accounts_progress_and_clears_accounting`. - Added `usage_limit_budget_limited_goal_accounts_remaining_progress`. --- codex-rs/ext/goal/src/accounting.rs | 11 + codex-rs/ext/goal/src/runtime.rs | 48 ++++ .../ext/goal/tests/goal_extension_backend.rs | 245 +++++++++++++++++- 3 files changed, 303 insertions(+), 1 deletion(-) diff --git a/codex-rs/ext/goal/src/accounting.rs b/codex-rs/ext/goal/src/accounting.rs index 2a679837cc..e0a84f5f8b 100644 --- a/codex-rs/ext/goal/src/accounting.rs +++ b/codex-rs/ext/goal/src/accounting.rs @@ -83,6 +83,17 @@ impl GoalAccountingState { self.inner().current_turn_id.clone() } + pub(crate) fn turn_is_current_active_goal(&self, turn_id: &str) -> bool { + let inner = self.inner(); + if inner.current_turn_id.as_deref() != Some(turn_id) { + return false; + } + let Some(turn) = inner.turns.get(turn_id) else { + return false; + }; + turn.account_tokens && turn.active_goal_id.is_some() + } + pub(crate) fn record_token_usage( &self, turn_id: impl Into, diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index 0139cc2c52..16a4ff6a2f 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -192,6 +192,54 @@ impl GoalRuntimeHandle { Ok(()) } + pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { + if !self.is_enabled() { + return Ok(()); + } + + if !self + .inner + .accounting_state + .turn_is_current_active_goal(turn_id) + { + return Ok(()); + } + + let progress_event_id = format!("{turn_id}:usage-limit-progress"); + self.account_active_goal_progress( + turn_id, + progress_event_id.as_str(), + codex_state::GoalAccountingMode::ActiveOnly, + BudgetLimitedGoalDisposition::ClearActive, + ) + .await?; + + let previous_status = self + .current_goal_status_for_metrics(/*expected_goal_id*/ None) + .await?; + let Some(goal) = self + .inner + .state_dbs + .thread_goals() + .usage_limit_active_thread_goal(self.thread_id()) + .await + .map_err(|err| err.to_string())? + else { + return Ok(()); + }; + self.inner + .metrics + .record_terminal_if_status_changed(previous_status, &goal); + self.inner.accounting_state.clear_active_goal(); + let goal = protocol_goal_from_state(goal); + self.inner.event_emitter.thread_goal_updated( + format!("{turn_id}:usage-limit"), + Some(turn_id.to_string()), + goal, + ); + Ok(()) + } + pub async fn restore_after_resume(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 0a36439b25..584f7362b7 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -363,6 +363,243 @@ async fn budget_limited_goal_keeps_accounting_after_later_tool_finish() -> anyho Ok(()) } +#[tokio::test] +async fn usage_limit_active_goal_accounts_progress_and_clears_accounting() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, /*output_tokens*/ 8, + /*reasoning_output_tokens*/ 2, /*total_tokens*/ 30, + ), + ) + .await; + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "turn-1:usage-limit-progress".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::Active, + tokens_used: 23, + }, + CapturedGoalEvent { + event_id: "turn-1:usage-limit".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::UsageLimited, + tokens_used: 23, + }, + ], + harness.sink.goal_events() + ); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 50, /*cached_input_tokens*/ 5, + /*output_tokens*/ 20, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 70, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell-after-usage-limit", "shell") + .await; + harness.stop_turn("turn-1").await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(23, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ + "objective": "ship goal extension backend", + "token_budget": 25, + }), + )) + .await?; + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 20, /*cached_input_tokens*/ 5, + /*output_tokens*/ 10, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 30, + ), + ) + .await; + harness + .notify_tool_finish("turn-1", "call-shell", "shell") + .await; + harness.sink.clear(); + + harness + .record_token_usage( + "turn-1", + &token_usage( + /*input_tokens*/ 24, /*cached_input_tokens*/ 5, + /*output_tokens*/ 16, /*reasoning_output_tokens*/ 0, + /*total_tokens*/ 40, + ), + ) + .await; + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(35, goal.tokens_used); + assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); + assert_eq!( + vec![ + CapturedGoalEvent { + event_id: "turn-1:usage-limit-progress".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::BudgetLimited, + tokens_used: 35, + }, + CapturedGoalEvent { + event_id: "turn-1:usage-limit".to_string(), + turn_id: Some("turn-1".to_string()), + status: ThreadGoalStatus::UsageLimited, + tokens_used: 35, + }, + ], + harness.sink.goal_events() + ); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_plan_turn_does_not_stop_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .start_turn_with_mode("turn-plan", ModeKind::Plan, &TokenUsage::default()) + .await; + harness.sink.clear(); + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-plan") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); + assert_eq!(Vec::::new(), harness.sink.goal_events()); + Ok(()) +} + +#[tokio::test] +async fn usage_limit_stale_turn_does_not_stop_current_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + let create_tool = tool_by_name(&tools, "create_goal"); + create_tool + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + harness.stop_turn("turn-1").await; + harness.start_turn("turn-2", &TokenUsage::default()).await; + harness.sink.clear(); + + harness + .runtime_handle() + .usage_limit_active_goal_for_turn("turn-1") + .await + .map_err(anyhow::Error::msg)?; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); + assert_eq!(Vec::::new(), harness.sink.goal_events()); + Ok(()) +} + #[tokio::test] async fn update_goal_can_block_and_accounts_final_progress() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -719,8 +956,14 @@ impl GoalExtensionHarness { } async fn start_turn(&self, turn_id: &str, usage: &TokenUsage) { + self.start_turn_with_mode(turn_id, ModeKind::Default, usage) + .await; + } + + async fn start_turn_with_mode(&self, turn_id: &str, mode: ModeKind, usage: &TokenUsage) { let turn_store = ExtensionData::new(turn_id); - let collaboration_mode = default_collaboration_mode(); + let mut collaboration_mode = default_collaboration_mode(); + collaboration_mode.mode = mode; for contributor in self.registry.turn_lifecycle_contributors() { contributor .on_turn_start(TurnStartInput { From 7df8431bbdcdd6bdbac7d2dacd5b59aca0fb7732 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 27 May 2026 14:14:30 +0200 Subject: [PATCH 026/204] Fix guardian review test user input (#24746) ## Summary - Add the missing additional_context field to the guardian review Op::UserInput test initializer. ## Test plan - just fmt - just test -p codex-core guardian_review - just test -p codex-core (compiles, then fails on local environment issues: sandbox-exec Operation not permitted, missing test_stdio_server helper binary, and unrelated timeouts) --- codex-rs/core/tests/suite/guardian_review.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index 5b76d09ae9..df38fb4353 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -118,6 +118,7 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(test.cwd.path().to_path_buf()), approval_policy: Some(approval_policy), From d2ebb8d8caf6bc487466ee50f61eaa2dc53fa091 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 27 May 2026 15:17:23 +0200 Subject: [PATCH 027/204] feat: add thread idle lifecycle hook (#24744) ## Why Extensions can currently observe thread start, resume, and stop, but they do not have a lifecycle point for the host to say that immediately pending thread work has drained. That makes idle follow-up behavior harder to express as extension-owned logic instead of host-specific plumbing. This adds an explicit idle lifecycle hook so an extension can react when a thread becomes idle while the host keeps ownership of whether any submitted follow-up input starts a turn, is queued, or is ignored. ## What changed - Added `ThreadIdleInput` with access to the session-scoped and thread-scoped extension stores. - Added a default `on_thread_idle` method to `ThreadLifecycleContributor`. - Re-exported `ThreadIdleInput` from the extension API surface. ## Testing Not run; this only extends the extension API trait surface with a default hook and exported input type. --- codex-rs/ext/extension-api/src/contributors.rs | 8 ++++++++ .../extension-api/src/contributors/thread_lifecycle.rs | 8 ++++++++ codex-rs/ext/extension-api/src/lib.rs | 1 + 3 files changed, 17 insertions(+) diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 2113fdef8e..fda295b7f7 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -16,6 +16,7 @@ mod turn_lifecycle; pub use prompt::PromptFragment; pub use prompt::PromptSlot; +pub use thread_lifecycle::ThreadIdleInput; pub use thread_lifecycle::ThreadResumeInput; pub use thread_lifecycle::ThreadStartInput; pub use thread_lifecycle::ThreadStopInput; @@ -51,6 +52,13 @@ pub trait ThreadLifecycleContributor: Send + Sync { /// Called after the host constructs a runtime from persisted history. async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} + /// Called after the host has drained immediately pending thread work. + /// + /// Implementations may use host capabilities captured by the extension to + /// submit follow-up input. The host remains responsible for deciding + /// whether that input starts a turn, is queued, or is ignored. + async fn on_thread_idle(&self, _input: ThreadIdleInput<'_>) {} + /// Called before the host drops the thread runtime and thread-scoped store. async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} } diff --git a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs index d50f5a1e2f..07c7e7fa81 100644 --- a/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/thread_lifecycle.rs @@ -18,6 +18,14 @@ pub struct ThreadResumeInput<'a> { pub thread_store: &'a ExtensionData, } +/// Input supplied when the host has no immediately pending thread work. +pub struct ThreadIdleInput<'a> { + /// Store scoped to the host session runtime. + pub session_store: &'a ExtensionData, + /// Store scoped to this thread runtime. + pub thread_store: &'a ExtensionData, +} + /// Input supplied when the host stops a thread runtime. pub struct ThreadStopInput<'a> { /// Store scoped to the host session runtime. diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index f3c3dc526e..80ca67e290 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -27,6 +27,7 @@ pub use contributors::ConfigContributor; pub use contributors::ContextContributor; pub use contributors::PromptFragment; pub use contributors::PromptSlot; +pub use contributors::ThreadIdleInput; pub use contributors::ThreadLifecycleContributor; pub use contributors::ThreadResumeInput; pub use contributors::ThreadStartInput; From 61cbf3574eca870df6fa7f49648ec7e001901b5a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 27 May 2026 15:49:08 +0200 Subject: [PATCH 028/204] Drop startup context when truncating forked rollouts (#24751) ## Summary - Change last-`n` fork truncation to start at the first fork-turn boundary instead of returning the full rollout when the fork history is shorter than the requested window. - Add coverage for the startup-prefix case in both rollout truncation tests and agent control spawn behavior. - Ensure bounded forked children still rebuild context after the cached prefix is truncated. ## Testing - Added unit coverage for truncation behavior when the parent history is under the requested fork-turn limit. - Added an agent control test covering bounded fork spawn behavior with startup context present. - Not run (not requested). --- codex-rs/core/src/agent/control_tests.rs | 102 ++++++++++++++++++ .../core/src/thread_rollout_truncation.rs | 16 +-- .../src/thread_rollout_truncation_tests.rs | 28 +++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 8209272f10..b5ba938ef7 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1113,6 +1113,108 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .expect("parent shutdown should submit"); } +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let startup_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + startup_turn_context.as_ref(), + &[ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "parent startup developer context".to_string(), + }], + phase: None, + }], + ) + .await; + parent_thread + .inject_user_message_without_turn("current parent task".to_string()) + .await; + let spawn_turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + spawn_turn_context.as_ref(), + &[spawn_agent_call(&parent_spawn_call_id)], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("bounded forked spawn should drop startup prefix") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "current parent task"), + "bounded fork should retain the requested recent parent turn" + ); + assert!( + !history_contains_text(history.raw_items(), "parent startup developer context"), + "bounded fork should drop parent startup context even when fewer turns exist than requested" + ); + assert!( + child_thread + .codex + .session + .reference_context_item() + .await + .is_none(), + "bounded forked child should still rebuild context after truncating the cached prefix" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + #[tokio::test] async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/thread_rollout_truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs index e20ee53d47..69e615c4fb 100644 --- a/codex-rs/core/src/thread_rollout_truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -130,7 +130,8 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start( /// Return a suffix of `items` that keeps the last `n_from_end` fork turns. /// -/// If fewer than or equal to `n_from_end` fork turns exist, this returns the full rollout. +/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn +/// boundary and still drops pre-turn startup context. pub(crate) fn truncate_rollout_to_last_n_fork_turns( items: &[RolloutItem], n_from_end: usize, @@ -140,11 +141,14 @@ pub(crate) fn truncate_rollout_to_last_n_fork_turns( } let fork_turn_positions = fork_turn_positions_in_rollout(items); - if fork_turn_positions.len() <= n_from_end { - return items.to_vec(); - } - - let keep_idx = fork_turn_positions[fork_turn_positions.len() - n_from_end]; + let Some(keep_idx) = fork_turn_positions + .len() + .checked_sub(n_from_end) + .map(|position| fork_turn_positions[position]) + .or_else(|| fork_turn_positions.first().copied()) + else { + return Vec::new(); + }; items[keep_idx..].to_vec() } diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index df370a0546..e0a8837165 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -29,6 +29,17 @@ fn assistant_msg(text: &str) -> ResponseItem { } } +fn developer_msg(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase: None, + } +} + fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem { let communication = InterAgentCommunication::new( AgentPath::root(), @@ -197,6 +208,23 @@ fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() { ); } +#[test] +fn truncates_rollout_to_last_n_fork_turns_drops_startup_prefix_even_when_under_limit() { + let rollout = vec![ + RolloutItem::ResponseItem(developer_msg("startup developer context")), + RolloutItem::ResponseItem(user_msg("current task")), + RolloutItem::ResponseItem(assistant_msg("answer")), + ]; + + let truncated = truncate_rollout_to_last_n_fork_turns(&rollout, /*n_from_end*/ 2); + let expected = rollout[1..].to_vec(); + + assert_eq!( + serde_json::to_value(&truncated).unwrap(), + serde_json::to_value(&expected).unwrap() + ); +} + #[test] fn truncates_rollout_to_last_n_fork_turns_applies_thread_rollback_markers() { let rollout = vec![ From f20904c4d60c9c4d3349b2cb357ef83c6e1a4a2a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 27 May 2026 07:22:30 -0700 Subject: [PATCH 029/204] TUI config cleanup: plugin marketplace (#24257) ## Why Plugin and marketplace mutations are applied by the app server, but several TUI follow-up paths still refreshed state from the TUI host config. In remote workspace mode, that can leave plugin UI state tied to stale client-local `config.toml` after the server has already applied the mutation. ## What - Stop reloading the TUI host config after app-server-owned plugin, marketplace, skill, and app mutations. - Use the same app-server-owned refresh path for local and remote sessions: ask the app server to reload user config where the running session needs it, then refetch plugin list/detail state from the app server. - Build plugin mention candidates from existing app-server `plugin/list` and `plugin/read` data in both local and remote sessions instead of TUI-host plugin config. - Avoid the duplicate local config reload after `ReloadUserConfig` asks the app server to reload config. ## Verification Manually launched a local WebSocket app-server with a temp server `CODEX_HOME`, launched the TUI with a separate temp host `CODEX_HOME` and `--remote`, installed a sample plugin from a temp local marketplace through `/plugins`, and confirmed the TUI refreshed to installed state while only the server config gained `[plugins."sample@debug"]`. Trace logs showed the TUI using app-server `plugin/list` and `plugin/read` for the refresh path. --- codex-rs/tui/src/app/background_requests.rs | 6 +-- codex-rs/tui/src/app/event_dispatch.rs | 60 ++++----------------- codex-rs/tui/src/app/thread_routing.rs | 1 - 3 files changed, 13 insertions(+), 54 deletions(-) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 4b8b5f8920..b3e1edb901 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -400,16 +400,16 @@ impl App { } pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) { - let config = self.config.clone(); + let cwd = self.config.cwd.to_path_buf(); let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); - if !config.features.enabled(Feature::Plugins) { + if !self.config.features.enabled(Feature::Plugins) { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); return; } tokio::spawn(async move { - match fetch_plugin_mentions(request_handle, config.cwd.to_path_buf()).await { + match fetch_plugin_mentions(request_handle, cwd).await { Ok(plugins) => { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: Some(plugins), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 18ded9bbbc..5af2c49233 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -506,9 +506,6 @@ impl App { self.chat_widget .on_marketplace_add_loaded(cwd.clone(), source, result); if add_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after marketplace add"); - } self.fetch_plugins_list(app_server, cwd); } } @@ -516,14 +513,7 @@ impl App { let marketplace_contents_changed = matches!(&result, Ok(response) if !response.upgraded_roots.is_empty()); if marketplace_contents_changed { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after marketplace upgrade" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_marketplace_upgrade_loaded(cwd.clone(), result); @@ -558,11 +548,7 @@ impl App { ); if remove_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after marketplace remove"); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); self.fetch_plugins_list(app_server, cwd); } } @@ -609,11 +595,7 @@ impl App { } => { let install_succeeded = result.is_ok(); if install_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!(error = %err, "failed to refresh config after plugin install"); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } let should_refresh_plugin_detail = self.chat_widget.on_plugin_install_loaded( cwd.clone(), @@ -665,14 +647,7 @@ impl App { self.pending_plugin_enabled_writes.remove(&plugin_id); let update_succeeded = result.is_ok(); if update_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin toggle" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget .on_plugin_enabled_set(cwd, plugin_id, enabled, result); @@ -1303,14 +1278,7 @@ impl App { } => { let uninstall_succeeded = result.is_ok(); if uninstall_succeeded { - if let Err(err) = self.refresh_in_memory_config_from_disk().await { - tracing::warn!( - error = %err, - "failed to refresh config after plugin uninstall" - ); - } - self.chat_widget.refresh_plugin_mentions(); - self.chat_widget.submit_op(AppCommand::reload_user_config()); + self.refresh_plugin_mentions_after_config_write(); } self.chat_widget.on_plugin_uninstall_loaded( cwd.clone(), @@ -1705,14 +1673,6 @@ impl App { { Ok(()) => { self.chat_widget.update_skill_enabled(path, enabled); - if !app_server.uses_remote_workspace() - && let Err(err) = self.refresh_in_memory_config_from_disk().await - { - tracing::warn!( - error = %err, - "failed to refresh config after skill toggle" - ); - } } Err(err) => { let path_display = path.display(); @@ -1749,11 +1709,6 @@ impl App { { Ok(_) => { self.chat_widget.update_connector_enabled(&id, enabled); - if !app_server.uses_remote_workspace() - && let Err(err) = self.refresh_in_memory_config_from_disk().await - { - tracing::warn!(error = %err, "failed to refresh config after app toggle"); - } } Err(err) => { self.chat_widget.add_error_message(format!( @@ -2098,6 +2053,11 @@ impl App { } } + fn refresh_plugin_mentions_after_config_write(&mut self) { + self.chat_widget.refresh_plugin_mentions(); + self.chat_widget.submit_op(AppCommand::reload_user_config()); + } + async fn apply_keymap_clear(&mut self, context: String, action: String) { let keymap_config = match crate::keymap_setup::keymap_without_custom_binding( &self.config.tui_keymap, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 8f15aed79a..a403dc232c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -684,7 +684,6 @@ impl App { } AppCommand::ReloadUserConfig => { app_server.reload_user_config().await?; - self.refresh_in_memory_config_from_disk().await?; Ok(true) } AppCommand::OverrideTurnContext { .. } => { From aa184548b1ee0a559fba7be658240053e270e16f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 27 May 2026 11:36:52 -0300 Subject: [PATCH 030/204] fix(tui): complete vim word-end and line-end behavior (#24380) ## Why The TUI Vim composer currently diverges from normal Vim editing in two common workflows: pressing `e` repeatedly can remain stuck at an existing word end, and normal mode does not support `C` for changing through the end of the line. The existing `D` behavior also removes the newline when the cursor is already at the line boundary, which makes the new `C` action and existing deletion action surprising in multiline prompts. Closes #23926. Closes #24238. ## What Changed - Make normal-mode `e` advance from the current word end to the next word end, including for operator motions such as `de`. - Add configurable Vim normal-mode `change_to_line_end` behavior, bound to `C` by default, which deletes to the end of the current line and enters Insert mode. - Keep the newline intact when `D` or `C` is pressed at the end-of-line boundary. - Add regression coverage for repeated `e`, `de`, `C`, and the multiline `C`/`D` boundary behavior. - Regenerate the config schema and update the keymap picker snapshots for the new Vim action. ## How to Test 1. Run Codex with Vim composer mode enabled: ```bash cd codex-rs cargo run --bin codex -- -c tui.vim_mode_default=true ``` 2. Enter `alpha beta gamma`, press `Esc`, `0`, then press `e` repeatedly. Confirm the cursor advances through the ends of `alpha`, `beta`, and `gamma`. 3. Enter `hello world`, press `Esc`, `0`, `w`, then `C`. Confirm `world` is deleted and the composer enters Insert mode. 4. Enter a multiline prompt with `hello` above `world`, press `Esc`, `k`, `$`, and then `D`. Confirm the newline is preserved and the two lines do not join. 5. At the same boundary, press `C` and type `!`. Confirm the composer enters Insert mode and yields `hello!` above `world`, preserving the newline. Targeted automated verification: - `just fix -p codex-tui` - `just argument-comment-lint-from-source -p codex-tui -p codex-config` - `cargo insta pending-snapshots` reports no pending snapshots. - `just test -p codex-tui` validates the new Vim and keymap snapshot coverage, but the command remains red due to two reproducible unrelated failures in `app::tests::update_feature_flags_disabling_guardian_*`. ## Validation Note The workspace-wide `just argument-comment-lint` form is currently blocked during Bazel analysis by the existing LLVM `compiler-rt` missing `include/sanitizer/*.h` failure; package-scoped source linting for the changed Rust crates passed. --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 ++ ...ts__vim_e_advances_from_each_word_end.snap | 12 ++ codex-rs/tui/src/bottom_pane/textarea.rs | 154 +++++++++++++++++- codex-rs/tui/src/keymap.rs | 10 ++ codex-rs/tui/src/keymap_setup/actions.rs | 3 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 11 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 5b1e7d4cc8..5848d1f444 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap { pub delete_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, + /// Change from cursor to end of line and enter insert mode (`C`). + pub change_to_line_end: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6af30b4071..06b7c56d5b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2834,6 +2834,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3500,6 +3501,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3765,6 +3767,14 @@ ], "description": "Cancel a pending operator and return to normal mode." }, + "change_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Change from cursor to end of line and enter insert mode (`C`)." + }, "delete_char": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap new file mode 100644 index 0000000000..0a41c2d80f --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__textarea__tests__vim_e_advances_from_each_word_end.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/textarea.rs +expression: "states.join(\"\\n\\n\")" +--- +alpha beta gamma + ^ + +alpha beta gamma + ^ + +alpha beta gamma + ^ diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 96f6cf8cbb..d931b14b2c 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -755,7 +755,12 @@ impl TextArea { return; } if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) { - self.kill_to_end_of_line(); + self.vim_kill_to_end_of_line(); + return; + } + if self.vim_normal_keymap.change_to_line_end.is_pressed(event) { + self.vim_kill_to_end_of_line(); + self.vim_mode = VimMode::Insert; return; } if self.vim_normal_keymap.yank_line.is_pressed(event) { @@ -914,7 +919,7 @@ impl TextArea { VimMotion::Down => self.move_cursor_down(), VimMotion::WordForward => self.set_cursor(self.beginning_of_next_word()), VimMotion::WordBackward => self.set_cursor(self.beginning_of_previous_word()), - VimMotion::WordEnd => self.set_cursor(self.end_of_next_word()), + VimMotion::WordEnd => self.set_cursor(self.vim_word_end_exclusive()), VimMotion::LineStart => self.set_cursor(self.beginning_of_current_line()), VimMotion::LineEnd => self.set_cursor(self.end_of_current_line()), } @@ -1007,6 +1012,13 @@ impl TextArea { } } + fn vim_kill_to_end_of_line(&mut self) { + let eol = self.end_of_current_line(); + if self.cursor_pos < eol { + self.kill_range(self.cursor_pos..eol); + } + } + pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); let range = if self.cursor_pos == bol { @@ -1734,7 +1746,11 @@ impl TextArea { } pub(crate) fn end_of_next_word(&self) -> usize { - let suffix = &self.text[self.cursor_pos..]; + self.end_of_next_word_from(self.cursor_pos) + } + + fn end_of_next_word_from(&self, cursor_pos: usize) -> usize { + let suffix = &self.text[cursor_pos..]; let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else { return self.text.len(); }; @@ -1742,16 +1758,16 @@ impl TextArea { let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())]; let mut pieces = split_word_pieces(run).into_iter().peekable(); let Some((start, piece)) = pieces.next() else { - return self.cursor_pos + first_non_ws; + return cursor_pos + first_non_ws; }; - let word_start = self.cursor_pos + first_non_ws + start; + let word_start = cursor_pos + first_non_ws + start; let mut end = word_start + piece.len(); if piece.chars().all(is_word_separator) { while let Some((idx, piece)) = pieces.peek() { if !piece.chars().all(is_word_separator) { break; } - end = self.cursor_pos + first_non_ws + *idx + piece.len(); + end = cursor_pos + first_non_ws + *idx + piece.len(); pieces.next(); } } @@ -1759,8 +1775,22 @@ impl TextArea { self.adjust_pos_out_of_elements(end, /*prefer_start*/ false) } - fn vim_word_end_cursor(&self) -> usize { + fn vim_word_end_exclusive(&self) -> usize { let end = self.end_of_next_word(); + let target = if end > self.cursor_pos { + self.prev_atomic_boundary(end) + } else { + end + }; + if target == self.cursor_pos && end < self.text.len() { + self.end_of_next_word_from(end) + } else { + end + } + } + + fn vim_word_end_cursor(&self) -> usize { + let end = self.vim_word_end_exclusive(); if end > self.cursor_pos { self.prev_atomic_boundary(end) } else { @@ -2275,6 +2305,60 @@ mod tests { assert_eq!(t.cursor(), 11); } + #[test] + fn vim_shift_c_changes_to_line_end_and_enters_insert_mode() { + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SHIFT)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + assert_eq!(t.kill_buffer, "world"); + } + + #[test] + fn vim_uppercase_c_changes_to_line_end() { + let mut t = ta_with("hello world\nnext line"); + t.set_cursor(/*pos*/ 6); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello \nnext line"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), 6); + } + + #[test] + fn vim_d_at_line_end_does_not_remove_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_c_at_line_end_enters_insert_without_removing_newline() { + let mut t = ta_with("hello\nworld"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello\nworld"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "hello".len()); + assert_eq!(t.kill_buffer, ""); + } + #[test] fn vim_shift_o_opens_line_above_with_shift_only_binding() { let mut t = ta_with("hello\nworld"); @@ -2348,6 +2432,62 @@ mod tests { assert_eq!(t.kill_buffer, "c"); } + #[test] + fn vim_e_advances_from_each_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + let mut states = Vec::new(); + + for _ in 0..3 { + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + states.push(format!("{}\n{}^", t.text(), " ".repeat(t.cursor()))); + } + + insta::assert_snapshot!("vim_e_advances_from_each_word_end", states.join("\n\n")); + } + + #[test] + fn vim_delete_to_word_end_advances_from_existing_word_end() { + let mut t = ta_with("alpha beta gamma"); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "alph gamma"); // codespell:ignore alph + assert_eq!(t.kill_buffer, "a beta"); + } + + #[test] + fn vim_e_from_word_end_can_land_on_trailing_space() { + let mut t = ta_with("alpha "); + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!(t.cursor(), "alpha ".len()); + } + + #[test] + fn vim_e_advances_across_atomic_element_word_ends() { + let mut t = TextArea::new(); + t.insert_str("alpha "); + t.insert_element(""); + t.insert_str(" gamma"); + let element_start = t.elements[0].range.start; + t.set_cursor("alph".len()); // codespell:ignore alph + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), element_start); + + t.input(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(t.cursor(), "alpha gamm".len()); + } + #[test] fn vim_dollar_lands_on_line_end_character() { let mut t = ta_with("abc\n123"); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 533e28f3a2..bc54224c40 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -156,6 +156,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_line_end: Vec, pub(crate) delete_char: Vec, pub(crate) delete_to_line_end: Vec, + pub(crate) change_to_line_end: Vec, pub(crate) yank_line: Vec, pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, @@ -469,6 +470,7 @@ impl RuntimeKeymap { move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), + change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end), yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after), start_delete_operator: resolve_local!( @@ -786,6 +788,10 @@ impl RuntimeKeymap { shift(KeyCode::Char('d')), plain(KeyCode::Char('D')) ], + change_to_line_end: default_bindings![ + shift(KeyCode::Char('c')), + plain(KeyCode::Char('C')) + ], yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))], paste_after: default_bindings![plain(KeyCode::Char('p'))], start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], @@ -1176,6 +1182,10 @@ impl RuntimeKeymap { "delete_to_line_end", self.vim_normal.delete_to_line_end.as_slice(), ), + ( + "change_to_line_end", + self.vim_normal.change_to_line_end.as_slice(), + ), ("yank_line", self.vim_normal.yank_line.as_slice()), ("paste_after", self.vim_normal.paste_after.as_slice()), ( diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 9ce827b8a9..fb3ecd9f2c 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), + action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."), action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."), action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."), action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."), @@ -263,6 +264,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), + ("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end), ("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line), ("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after), ("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator), @@ -373,6 +375,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), + ("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()), ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 898108c755..bc41e50b24 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 93 actions, 1 customized, 2 unbound. + 94 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 4d9440cb4e..e0c1aa7ffe 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 94 actions, 0 customized, 3 unbound. + 95 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 58b1b80803..856ac05d1e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,14 +2,14 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (93 selectable) +tab: All (94 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (34 selectable) +tab: Vim (35 selectable) tab: Navigation (20 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 6a98da9238..57b0131002 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 93 actions, 0 customized, 2 unbound. + 94 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 9d651ee4f0..4d10013649 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 93 actions, 0 customized, 2 unbound. + 94 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug From 379511dceac290c273afc0e805e606f8ba438f2e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 27 May 2026 18:44:07 +0200 Subject: [PATCH 031/204] Bump SQLx to pick up newer bundled SQLite (#24728) ## Why Codex stores thread, log, goal, and memory state in bundled SQLite databases through SQLx. We have a suspected SQLite WAL-reset corruption issue under heavy concurrent writer load, especially when multiple subagents are active. The existing `sqlx 0.8.6` dependency kept us on an older `libsqlite3-sys` / bundled SQLite, so this PR moves the SQLx stack far enough forward to pick up the newer bundled SQLite library. ## What changed - Bump the workspace `sqlx` dependency to `0.9.0`. - Use the SQLx 0.9 feature names explicitly: `runtime-tokio`, `tls-rustls`, and `sqlite-bundled`. - Update `Cargo.lock` so `sqlx-sqlite` resolves through `libsqlite3-sys 0.37.0`. - Refresh `MODULE.bazel.lock` for the dependency changes. - Adapt `codex-state` to SQLx 0.9: - build dynamic state queries with `QueryBuilder` instead of passing dynamic `String`s to `sqlx::query`; - remove the old `QueryBuilder` lifetime parameter from helper signatures; - preserve SQLx's new `Migrator` fields when constructing runtime migrators. ## Verification - `just test -p codex-state` - `just bazel-lock-check` - `cargo check -p codex-state --tests` --- MODULE.bazel.lock | 55 ++- codex-rs/Cargo.lock | 673 +++++++++++++++++--------- codex-rs/Cargo.toml | 7 +- codex-rs/state/src/migrations.rs | 2 + codex-rs/state/src/runtime/goals.rs | 87 ++-- codex-rs/state/src/runtime/logs.rs | 43 +- codex-rs/state/src/runtime/threads.rs | 66 +-- 7 files changed, 591 insertions(+), 342 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 8a3cdeb66e..d5ef0b241c 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -704,6 +704,7 @@ "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "blake2_0.10.6": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"}],\"features\":{\"default\":[\"std\"],\"reset\":[],\"simd\":[],\"simd_asm\":[\"simd_opt\"],\"simd_opt\":[\"simd\"],\"size_opt\":[],\"std\":[\"digest/std\"]}}", "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", + "block-buffer_0.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{}}", "block-padding_0.3.3": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[]}}", "block2_0.6.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"req\":\">=0.6.2, <0.8.0\"}],\"features\":{\"alloc\":[],\"compiler-rt\":[\"objc2/unstable-compiler-rt\"],\"default\":[\"std\"],\"gnustep-1-7\":[\"objc2/gnustep-1-7\"],\"gnustep-1-8\":[\"gnustep-1-7\",\"objc2/gnustep-1-8\"],\"gnustep-1-9\":[\"gnustep-1-8\",\"objc2/gnustep-1-9\"],\"gnustep-2-0\":[\"gnustep-1-9\",\"objc2/gnustep-2-0\"],\"gnustep-2-1\":[\"gnustep-2-0\",\"objc2/gnustep-2-1\"],\"std\":[\"alloc\"],\"unstable-coerce-pointee\":[],\"unstable-objfw\":[],\"unstable-private\":[],\"unstable-winobjc\":[\"gnustep-1-8\"]}}", "blocking_1.6.2": "{\"dependencies\":[{\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"name\":\"async-task\",\"req\":\"^4.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"piper\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{}}", @@ -738,6 +739,7 @@ "cfg-if_1.0.4": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"rustc-dep-of-std\":[\"core\"]}}", "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", + "chacha20_0.10.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"stream-wrapper\"],\"name\":\"cipher\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8.1\"}],\"features\":{\"default\":[\"cipher\"],\"legacy\":[\"cipher\"],\"rng\":[\"dep:rand_core\"],\"xchacha\":[\"cipher\"]}}", "chacha20_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"std\":[\"cipher/std\"],\"zeroize\":[\"cipher/zeroize\"]}}", "chacha20poly1305_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"reduced-round\":[],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", @@ -757,6 +759,7 @@ "clipboard-win_5.4.1": "{\"dependencies\":[{\"name\":\"error-code\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-win\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"monitor\":[\"windows-win\"],\"std\":[\"error-code/std\"]}}", "clru_0.6.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"req\":\"^0.16\"}],\"features\":{}}", "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", + "cmov_0.5.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.9\",\"target\":\"cfg(any(unix, windows))\"}],\"features\":{}}", "cmp_any_0.8.1": "{\"dependencies\":[],\"features\":{}}", "codespan-reporting_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.6.3\"},{\"kind\":\"dev\",\"name\":\"peg\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rustyline\",\"req\":\"^6\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1.0.4\"},{\"name\":\"unicode-width\",\"req\":\">=0.1, <0.3\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.1\"}],\"features\":{\"ascii-only\":[],\"default\":[\"std\",\"termcolor\"],\"serialization\":[\"serde\"],\"std\":[\"serde?/std\"],\"termcolor\":[\"std\",\"dep:termcolor\"]}}", "color-eyre_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8.0\"},{\"name\":\"backtrace\",\"req\":\"^0.3.59\"},{\"name\":\"color-spantrace\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"eyre\",\"req\":\"^0.6\"},{\"name\":\"indenter\",\"req\":\"^0.3.0\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.13\"},{\"name\":\"tracing-error\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.15\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"capture-spantrace\":[\"tracing-error\",\"color-spantrace\"],\"default\":[\"track-caller\",\"capture-spantrace\"],\"issue-url\":[\"url\"],\"track-caller\":[]}}", @@ -789,6 +792,7 @@ "coreaudio-sys_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"}],\"features\":{\"audio_server_plugin\":[],\"audio_toolbox\":[],\"audio_unit\":[],\"core_audio\":[],\"core_midi\":[],\"default\":[\"audio_toolbox\",\"audio_unit\",\"core_audio\",\"audio_server_plugin\",\"open_al\",\"core_midi\"],\"io_kit_audio\":[],\"open_al\":[]}}", "cpal_0.15.3": "{\"dependencies\":[{\"name\":\"alsa\",\"req\":\"^0.9\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"asio-sys\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\",\"audio_toolbox\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"dasp_sample\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hound\",\"req\":\"^3.5\"},{\"name\":\"jack\",\"optional\":true,\"req\":\"^0.11\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"mach2\",\"req\":\"^0.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"name\":\"ndk\",\"req\":\"^0.8\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\"^0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.6\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"java-interface\"],\"name\":\"oboe\",\"req\":\"^0.6\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ringbuf\",\"req\":\"^0.3\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.58\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.33\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"Win32_Media_Audio\",\"Win32_Foundation\",\"Win32_Devices_Properties\",\"Win32_Media_KernelStreaming\",\"Win32_System_Com_StructuredStorage\",\"Win32_System_Threading\",\"Win32_Security\",\"Win32_System_SystemServices\",\"Win32_System_Variant\",\"Win32_Media_Multimedia\",\"Win32_UI_Shell_PropertiesSystem\"],\"name\":\"windows\",\"req\":\"^0.54.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"asio\":[\"asio-sys\",\"num-traits\"],\"oboe-shared-stdcxx\":[\"oboe/shared-stdcxx\"]}}", "cpufeatures_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"aarch64-linux-android\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", + "cpufeatures_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"android\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", "crc-catalog_2.4.0": "{\"dependencies\":[],\"features\":{}}", "crc32fast_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", "crc_3.4.0": "{\"dependencies\":[{\"name\":\"crc-catalog\",\"req\":\"^2.4.0\"}],\"features\":{}}", @@ -802,6 +806,7 @@ "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-bigint_0.5.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"der\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"generic-array\",\"optional\":true,\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"rlp\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serdect?/alloc\"],\"default\":[\"rand\"],\"extra-sizes\":[],\"rand\":[\"rand_core/std\"],\"serde\":[\"dep:serdect\"]}}", "crypto-common_0.1.7": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"=0.14.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", + "crypto-common_0.2.2": "{\"dependencies\":[{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"getrandom\":[\"rand_core\",\"dep:getrandom\"],\"rand_core\":[\"dep:rand_core\"],\"zeroize\":[\"hybrid-array/zeroize\"]}}", "crypto_box_0.9.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"blake2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"crypto_secretbox\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1\"},{\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"chacha20\":[\"dep:chacha20\",\"crypto_secretbox/chacha20\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"salsa20\":[\"dep:salsa20\",\"crypto_secretbox/salsa20\"],\"seal\":[\"dep:blake2\",\"alloc\"],\"serde\":[\"dep:serdect\"],\"std\":[\"aead/std\"]}}", "crypto_secretbox_0.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"cipher\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"generic-array\",\"req\":\"^0.14.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"features\":[\"zeroize\"],\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "csv-core_0.1.13": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"libc\":[\"memchr/libc\"]}}", @@ -809,6 +814,7 @@ "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctutils_0.4.2": "{\"dependencies\":[{\"name\":\"cmov\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"subtle\":[\"dep:subtle\"]}}", "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", "curve25519-dalek_4.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2.6\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.2.1\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"group\":[\"dep:group\",\"rand_core\"],\"group-bits\":[\"group\",\"ff/bits\"],\"legacy_compatibility\":[],\"precomputed-tables\":[]}}", "cxx-build_1.0.194": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.101\"},{\"name\":\"codespan-reporting\",\"req\":\"^0.13.1\"},{\"kind\":\"dev\",\"name\":\"cxx\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"cxx-gen\",\"req\":\"^0.7\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"kind\":\"dev\",\"name\":\"pkg-config\",\"req\":\"^0.3.27\"},{\"default_features\":false,\"features\":[\"span-locations\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"scratch\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{\"parallel\":[\"cc/parallel\"]}}", @@ -850,6 +856,7 @@ "difflib_0.4.0": "{\"dependencies\":[],\"features\":{}}", "diffy_0.4.2": "{\"dependencies\":[{\"name\":\"nu-ansi-term\",\"req\":\"^0.50\"}],\"features\":{}}", "digest_0.10.7": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.4\"}],\"features\":{\"alloc\":[],\"core-api\":[\"block-buffer\"],\"default\":[\"core-api\"],\"dev\":[\"blobby\"],\"mac\":[\"subtle\"],\"oid\":[\"const-oid\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"]}}", + "digest_0.11.3": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"alloc\":[],\"block-api\":[\"dep:block-buffer\"],\"default\":[\"block-api\"],\"dev\":[\"blobby\"],\"getrandom\":[\"common/getrandom\",\"rand_core\"],\"mac\":[\"dep:ctutils\"],\"oid\":[\"dep:const-oid\"],\"rand_core\":[\"common/rand_core\"],\"zeroize\":[\"dep:zeroize\",\"block-buffer?/zeroize\"]}}", "diplomat-runtime_0.15.1": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", "diplomat_0.15.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", "diplomat_core_0.15.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", @@ -899,7 +906,7 @@ "erased-serde_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.83\"}],\"features\":{\"alloc\":[\"serde/alloc\"],\"default\":[\"std\"],\"std\":[\"serde/std\"],\"unstable-debug\":[]}}", "errno_0.3.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"hermit\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"libc/std\"]}}", "error-code_3.3.2": "{\"dependencies\":[],\"features\":{\"std\":[]}}", - "etcetera_0.8.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"home\",\"req\":\"^0.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.48\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "etcetera_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Com\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "event-listener-strategy_0.5.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"event-listener\",\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"event-listener/loom\"],\"portable-atomic\":[\"event-listener/portable-atomic\"],\"std\":[\"event-listener/std\"]}}", "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", "eventsource-stream_0.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"nom\",\"req\":\"^7.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.8\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"nom/std\"]}}", @@ -926,7 +933,6 @@ "fluent-langneg_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-locale\",\"req\":\"^0.9\"}],\"features\":{\"cldr\":[\"unic-langid/likelysubtags\"],\"default\":[]}}", "fluent-syntax_0.11.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"}],\"features\":{\"all-benchmarks\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"]}}", "fluent_0.16.1": "{\"dependencies\":[{\"name\":\"fluent-bundle\",\"req\":\"^0.15.3\"},{\"name\":\"fluent-pseudo\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", - "flume_0.11.1": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"features\":[\"getrandom\"],\"name\":\"nanorand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"nanorand\"],\"select\":[],\"spin\":[]}}", "flume_0.12.0": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\",\"js\"],\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"fastrand\"],\"select\":[],\"spin\":[]}}", "fnv_1.0.7": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -938,20 +944,15 @@ "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", "fslock_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.66\",\"target\":\"cfg(unix)\"},{\"features\":[\"minwindef\",\"minwinbase\",\"winbase\",\"errhandlingapi\",\"winerror\",\"winnt\",\"synchapi\",\"handleapi\",\"fileapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "futures-channel_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", "futures-channel_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", - "futures-core_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-core_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-executor_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"name\":\"num_cpus\",\"optional\":true,\"req\":\"^1.8.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\",\"num_cpus\"]}}", + "futures-executor_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.32\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\"]}}", "futures-intrusive_0.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"crossbeam\",\"req\":\"^0.7\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"lock_api\",\"req\":\"^0.4.1\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.1.11\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"parking_lot\"]}}", - "futures-io_0.3.31": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", + "futures-io_0.3.32": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", "futures-lite_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.5\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.3.3\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"spin_on\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"race\",\"std\"],\"race\":[\"fastrand\"],\"std\":[\"alloc\",\"fastrand/std\",\"futures-io\",\"parking\"]}}", - "futures-macro_0.3.31": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", - "futures-sink_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "futures-macro_0.3.32": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "futures-sink_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "futures-task_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-task_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-util_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures-util_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.26\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"default_features\":false,\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"spin\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"slab\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\",\"libc\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\",\"libc\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab/std\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-channel\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-io\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"futures-sink/alloc\",\"futures-channel/alloc\",\"futures-util/alloc\"],\"async-await\":[\"futures-util/async-await\",\"futures-util/async-await-macro\"],\"bilock\":[\"futures-util/bilock\"],\"cfg-target-has-atomic\":[],\"compat\":[\"std\",\"futures-util/compat\"],\"default\":[\"std\",\"async-await\",\"executor\"],\"executor\":[\"std\",\"futures-executor/std\"],\"io-compat\":[\"compat\",\"futures-util/io-compat\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"futures-io/std\",\"futures-sink/std\",\"futures-util/std\",\"futures-util/io\",\"futures-util/channel\"],\"thread-pool\":[\"executor\",\"futures-executor/thread-pool\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\",\"futures-channel/unstable\",\"futures-io/unstable\",\"futures-util/unstable\"],\"write-all-vectored\":[\"futures-util/write-all-vectored\"]}}", "fxhash_0.2.1": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"}],\"features\":{}}", @@ -1048,7 +1049,7 @@ "hashbrown_0.14.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8.7\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.42\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7.42\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"ahash\",\"inline-more\",\"allocator-api2\"],\"inline-more\":[],\"nightly\":[\"allocator-api2?/nightly\",\"bumpalo/allocator_api\"],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.15.5": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.16.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", - "hashlink_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", + "hashlink_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", "headers-core_0.3.0": "{\"dependencies\":[{\"name\":\"http\",\"req\":\"^1.0.0\"}],\"features\":{}}", "headers_0.4.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"headers-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"httpdate\",\"req\":\"^1\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"sha1\",\"req\":\"^0.10\"}],\"features\":{\"nightly\":[]}}", "heapless_0.8.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.3.0\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.2\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"defmt-03\":[\"dep:defmt\"],\"mpmc_large\":[],\"portable-atomic\":[\"dep:portable-atomic\"],\"portable-atomic-critical-section\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/critical-section\"],\"portable-atomic-unsafe-assume-single-core\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/unsafe-assume-single-core\"],\"serde\":[\"dep:serde\"],\"ufmt\":[\"dep:ufmt-write\"]}}", @@ -1059,7 +1060,9 @@ "hickory-proto_0.25.2": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"features\":[\"prebuilt-nasm\"],\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.12.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"data-encoding\",\"req\":\"^2.2.0\"},{\"name\":\"enum-as-inner\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-channel\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"features\":[\"stream\"],\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.7\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.9\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"compiled_data\"],\"name\":\"idna\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"ipnet\",\"req\":\"^2.3.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.44\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"std\"],\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.10\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1.1\"},{\"features\":[\"io-util\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"rt\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"features\":[\"early-data\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"wasm-bindgen-crate\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2.58\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[\"dep:bitflags\",\"dep:rustls-pki-types\",\"dep:time\",\"std\"],\"__h3\":[\"dep:h3\",\"dep:h3-quinn\",\"dep:http\",\"std\"],\"__https\":[\"dep:bytes\",\"dep:h2\",\"dep:http\",\"std\"],\"__quic\":[\"dep:bytes\",\"dep:pin-project-lite\",\"dep:quinn\",\"std\"],\"__tls\":[\"dep:bytes\",\"dep:rustls\",\"dep:tokio-rustls\",\"std\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"std\"],\"default\":[\"std\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/ring-io\",\"__dnssec\"],\"dnssec-ring\":[\"dep:ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"quic-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"quic-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"tls-aws-lc-rs\",\"__https\"],\"https-ring\":[\"tls-ring\",\"__https\"],\"mdns\":[\"socket2/all\",\"std\"],\"no-std-rand\":[\"once_cell/critical-section\",\"dep:critical-section\"],\"quic-aws-lc-rs\":[\"quinn/rustls-aws-lc-rs\",\"tls-aws-lc-rs\",\"__quic\"],\"quic-ring\":[\"quinn/rustls-ring\",\"tls-ring\",\"__quic\"],\"rustls-platform-verifier\":[\"dep:rustls-platform-verifier\",\"std\"],\"serde\":[\"dep:serde\",\"std\",\"url/serde\"],\"std\":[\"data-encoding/std\",\"futures-channel/std\",\"futures-io/std\",\"futures-util/std\",\"ipnet/std\",\"rand/std\",\"rand/thread_rng\",\"ring?/std\",\"thiserror/std\",\"tracing-subscriber/env-filter\",\"tracing-subscriber/fmt\",\"tracing-subscriber/std\",\"tracing/std\",\"url/std\"],\"testing\":[\"std\"],\"text-parsing\":[\"std\"],\"tls-aws-lc-rs\":[\"tokio-rustls/aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"tokio-rustls/ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"std\",\"tokio/net\",\"tokio/rt\",\"tokio/time\",\"tokio/rt-multi-thread\"],\"wasm-bindgen\":[\"dep:wasm-bindgen-crate\",\"dep:js-sys\"]}}", "hickory-resolver_0.25.2": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"hickory-proto\",\"req\":\"^0.25\"},{\"name\":\"ipconfig\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(windows)\"},{\"features\":[\"sync\"],\"name\":\"moka\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"system\"],\"name\":\"resolv-conf\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smallvec\",\"req\":\"^1.6\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[],\"__h3\":[\"__quic\"],\"__https\":[\"__tls\"],\"__quic\":[\"dep:quinn\",\"__tls\"],\"__tls\":[\"dep:rustls\",\"dep:tokio-rustls\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"hickory-proto/backtrace\"],\"default\":[\"system-config\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"hickory-proto/dnssec-aws-lc-rs\",\"__dnssec\"],\"dnssec-ring\":[\"hickory-proto/dnssec-ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"hickory-proto/h3-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"hickory-proto/h3-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"hickory-proto/https-aws-lc-rs\",\"__https\"],\"https-ring\":[\"hickory-proto/https-ring\",\"__https\"],\"quic-aws-lc-rs\":[\"hickory-proto/quic-aws-lc-rs\",\"__quic\",\"quinn/rustls-aws-lc-rs\"],\"quic-ring\":[\"hickory-proto/quic-ring\",\"__quic\",\"quinn/rustls-ring\"],\"rustls-platform-verifier\":[\"hickory-proto/rustls-platform-verifier\"],\"serde\":[\"dep:serde\",\"hickory-proto/serde\"],\"system-config\":[\"dep:ipconfig\",\"dep:resolv-conf\"],\"tls-aws-lc-rs\":[\"hickory-proto/tls-aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"hickory-proto/tls-ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"hickory-proto/tokio\"],\"webpki-roots\":[\"dep:webpki-roots\",\"hickory-proto/webpki-roots\"]}}", "hkdf_0.12.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"std\":[\"hmac/std\"]}}", + "hkdf_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"}],\"features\":{}}", "hmac_0.12.1": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"reset\":[],\"std\":[\"digest/std\"]}}", + "hmac_0.13.0": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.11\"}],\"features\":{\"zeroize\":[\"digest/zeroize\"]}}", "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", @@ -1071,6 +1074,7 @@ "http_1.4.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "httparse_1.10.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "httpdate_1.0.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"}],\"features\":{}}", + "hybrid-array_0.4.12": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"const-generics\"],\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"const-generics\"],\"name\":\"typenum\",\"req\":\"^1.20\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[],\"extra-sizes\":[]}}", "hyper-rustls_0.27.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server-auto\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.23\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"aws-lc-rs\"],\"fips\":[\"aws-lc-rs\",\"rustls/fips\"],\"http1\":[\"hyper-util/http1\"],\"http2\":[\"hyper-util/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"rustls-native-certs\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"webpki-tokio\":[\"webpki-roots\"]}}", "hyper-timeout_0.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"hyper-tls\",\"req\":\"^0.6\"},{\"features\":[\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"features\":[\"client-legacy\",\"http1\",\"server\",\"server-graceful\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{}}", "hyper-tls_0.6.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"name\":\"native-tls\",\"req\":\"^0.2.1\"},{\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io-std\",\"macros\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"name\":\"tokio-native-tls\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{\"alpn\":[\"native-tls/alpn\"],\"vendored\":[\"native-tls/vendored\"]}}", @@ -1173,7 +1177,7 @@ "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", - "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", + "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", @@ -1203,7 +1207,7 @@ "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "matchit_0.9.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"wayfind\",\"req\":\"^0.8\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "maybe-async_0.2.10": "{\"dependencies\":[{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit-mut\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"is_sync\":[]}}", - "md-5_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"md5-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"md5-asm\"],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "md-5_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "md5_0.8.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memchr_2.8.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", @@ -1235,7 +1239,6 @@ "notify-types_2.1.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.89\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"}],\"features\":{\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"serialization-compat-6\":[]}}", "notify_8.2.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.7.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"crossbeam-channel\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"flume\",\"optional\":true,\"req\":\"^0.11.1\"},{\"name\":\"fsevent-sys\",\"optional\":true,\"req\":\"^4.0.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"default_features\":false,\"name\":\"inotify\",\"req\":\"^0.11.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"name\":\"kqueue\",\"req\":\"^1.1.1\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"kqueue\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.4\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\"},{\"name\":\"notify-types\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.0\"},{\"kind\":\"dev\",\"name\":\"trash\",\"req\":\"^5.2.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"walkdir\",\"req\":\"^2.4.0\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_WindowsProgramming\",\"Win32_System_IO\"],\"name\":\"windows-sys\",\"req\":\"^0.60.1\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"macos_fsevent\"],\"macos_fsevent\":[\"fsevent-sys\"],\"macos_kqueue\":[\"kqueue\",\"mio\"],\"serde\":[\"notify-types/serde\"],\"serialization-compat-6\":[\"notify-types/serialization-compat-6\"]}}", "nu-ansi-term_0.50.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.152\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.94\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_Security\"],\"name\":\"windows\",\"package\":\"windows-sys\",\"req\":\">=0.59, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"derive_serde_style\":[\"serde\"],\"gnu_legacy\":[],\"std\":[]}}", - "num-bigint-dig_0.8.6": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"spin_no_std\"],\"name\":\"lazy_static\",\"req\":\"^1.2.0\"},{\"name\":\"libm\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"name\":\"num-iter\",\"req\":\"^0.1.37\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_isaac\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"u64_digit\"],\"fuzz\":[\"arbitrary\",\"smallvec/arbitrary\"],\"i128\":[],\"nightly\":[],\"prime\":[\"rand/std_rng\"],\"std\":[\"num-integer/std\",\"num-traits/std\",\"smallvec/write\",\"rand/std\",\"serde/std\"],\"u64_digit\":[]}}", "num-bigint_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"quickcheck\":[\"dep:quickcheck\"],\"rand\":[\"dep:rand\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-integer/std\",\"num-traits/std\"]}}", "num-complex_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytecheck\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"bytecheck\":[\"dep:bytecheck\"],\"bytemuck\":[\"dep:bytemuck\"],\"default\":[\"std\"],\"libm\":[\"num-traits/libm\"],\"rand\":[\"dep:rand\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-traits/std\"]}}", "num-conv_0.2.0": "{\"dependencies\":[],\"features\":{}}", @@ -1324,7 +1327,6 @@ "pin-project_1.1.11": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.11\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-utils_0.1.0": "{\"dependencies\":[],\"features\":{}}", "piper_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"async-executor\",\"req\":\"^1.5.1\"},{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.0.0\"},{\"name\":\"atomic-waker\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"}],\"features\":{\"default\":[\"std\"],\"portable-atomic\":[\"atomic-waker/portable-atomic\",\"portable_atomic_crate\",\"portable-atomic-util\"],\"std\":[\"fastrand/std\",\"futures-io\"]}}", - "pkcs1_0.7.5": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\",\"pkcs8?/alloc\"],\"pem\":[\"alloc\",\"der/pem\",\"pkcs8?/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", @@ -1400,10 +1402,12 @@ "rama-udp_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"net\"],\"name\":\"tokio-util\",\"req\":\"^0.7\"}],\"features\":{\"default\":[]}}", "rama-unix_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.48\"}],\"features\":{\"default\":[]}}", "rama-utils_0.3.0-alpha.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"const_format\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rama-macros\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"regex\",\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"},{\"features\":[\"write\",\"serde\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"name\":\"smol_str\",\"req\":\"^0.3\"},{\"features\":[\"time\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wildcard\",\"req\":\"^0.3\"}],\"features\":{}}", + "rand_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rng\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"chacha\":[\"dep:chacha20\"],\"default\":[\"std\",\"std_rng\",\"sys_rng\",\"thread_rng\"],\"log\":[],\"serde\":[\"dep:serde\"],\"simd_support\":[],\"std\":[\"alloc\",\"getrandom?/std\"],\"std_rng\":[\"dep:chacha20\"],\"sys_rng\":[\"dep:getrandom\",\"getrandom/sys_rng\"],\"thread_rng\":[\"std\",\"std_rng\",\"sys_rng\"],\"unbiased\":[]}}", "rand_0.8.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.22\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"features\":[\"into_bits\"],\"name\":\"packed_simd\",\"optional\":true,\"package\":\"packed_simd_2\",\"req\":\"^0.3.7\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"}],\"features\":{\"alloc\":[\"rand_core/alloc\"],\"default\":[\"std\",\"std_rng\"],\"getrandom\":[\"rand_core/getrandom\"],\"min_const_gen\":[],\"nightly\":[],\"serde1\":[\"serde\",\"rand_core/serde1\"],\"simd_support\":[\"packed_simd\"],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha/std\",\"alloc\",\"getrandom\",\"libc\"],\"std_rng\":[\"rand_chacha\"]}}", "rand_0.9.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"std_rng\",\"os_rng\",\"small_rng\",\"thread_rng\"],\"log\":[],\"nightly\":[],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\",\"rand_core/serde\"],\"simd_support\":[],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha?/std\",\"alloc\"],\"std_rng\":[\"dep:rand_chacha\"],\"thread_rng\":[\"std\",\"std_rng\",\"os_rng\"],\"unbiased\":[]}}", "rand_chacha_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.8\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde1\":[\"serde\"],\"simd\":[],\"std\":[\"ppv-lite86/std\"]}}", "rand_chacha_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.14\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"os_rng\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\"],\"std\":[\"ppv-lite86/std\",\"rand_core/std\"]}}", + "rand_core_0.10.1": "{\"dependencies\":[],\"features\":{}}", "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", "rand_core_0.9.5": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", "rand_xorshift_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.118\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", @@ -1432,7 +1436,6 @@ "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", - "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", @@ -1509,8 +1512,10 @@ "serial_test_derive_3.3.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\">=0.6.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"printing\",\"parsing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"async\":[],\"default\":[],\"file_locks\":[],\"test_logging\":[]}}", "sha1-checked_0.10.0": "{\"dependencies\":[{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"compress\"],\"name\":\"sha1\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"default\":[\"oid\",\"std\"],\"oid\":[\"digest/oid\",\"sha1/oid\"],\"std\":[\"digest/std\",\"sha1/std\"],\"zeroize\":[\"dep:zeroize\"]}}", "sha1_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha1-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"sha1-asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "sha1_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "sha1_smol_1.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "sha2_0.10.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha2-asm\",\"optional\":true,\"req\":\"^0.6.1\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"}],\"features\":{\"asm\":[\"sha2-asm\"],\"asm-aarch64\":[\"asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"force-soft-compact\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "sha2_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "sharded-slab_0.1.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"^1\"},{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"features\":[\"checkpoint\"],\"name\":\"loom\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"features\":[\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"memory-stats\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"slab\",\"req\":\"^0.4.2\"}],\"features\":{}}", "shared_library_0.1.9": "{\"dependencies\":[{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "shell-words_1.1.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -1533,13 +1538,13 @@ "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "spin_0.9.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"name\":\"lock_api_crate\",\"optional\":true,\"package\":\"lock_api\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"barrier\":[\"mutex\"],\"default\":[\"lock_api\",\"mutex\",\"spin_mutex\",\"rwlock\",\"once\",\"lazy\",\"barrier\"],\"fair_mutex\":[\"mutex\"],\"lazy\":[\"once\"],\"lock_api\":[\"lock_api_crate\"],\"mutex\":[],\"once\":[],\"portable_atomic\":[\"portable-atomic\"],\"rwlock\":[],\"spin_mutex\":[\"mutex\"],\"std\":[],\"ticket_mutex\":[\"mutex\"],\"use_ticket_mutex\":[\"mutex\",\"ticket_mutex\"]}}", "spki_0.7.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"der/alloc\"],\"arbitrary\":[\"std\",\"dep:arbitrary\",\"der/arbitrary\"],\"base64\":[\"dep:base64ct\"],\"fingerprint\":[\"sha2\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"]}}", - "sqlx-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-io\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"name\":\"hashlink\",\"req\":\"^0.10.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.15\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.132\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.73\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"sqlite\",\"mysql\",\"migrate\",\"macros\",\"time\",\"uuid\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"async-io\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"]}}", - "sqlx-macros-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.79\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.52\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", - "sqlx-macros_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.36\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", - "sqlx-mysql_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"digest\",\"req\":\"^0.10.0\"},{\"name\":\"dotenvy\",\"req\":\"^0.15.5\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rsa\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.144\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"mysql\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-postgres_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.8.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"home\",\"req\":\"^0.5.5\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.144\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"^1.0.85\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"serde\"],\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"derive\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"features\":[\"json\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-sqlite_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\",\"unlock_notify\"],\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"macros\",\"runtime-tokio\",\"tls-none\",\"sqlite\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx_0.8.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.52\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.12\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.19\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand_xoshiro\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-async-std-native-tls\":[\"runtime-async-std\",\"tls-native-tls\"],\"runtime-async-std-rustls\":[\"runtime-async-std\",\"tls-rustls-ring\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"runtime-tokio-native-tls\":[\"runtime-tokio\",\"tls-native-tls\"],\"runtime-tokio-rustls\":[\"runtime-tokio\",\"tls-rustls-ring\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", + "sqlx-core_0.9.0": "{\"dependencies\":[{\"name\":\"async-fs\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"async-task\",\"optional\":true,\"req\":\"^4.7.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.0\"},{\"name\":\"hashlink\",\"req\":\"^0.11.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.24\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8.16\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-async-io\":[\"async-io\",\"async-fs\"],\"_rt-async-std\":[\"async-std\",\"_rt-async-io\"],\"_rt-async-task\":[\"async-task\"],\"_rt-smol\":[\"smol\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"_unstable-doc\":[\"sqlx-toml\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"],\"sqlx-toml\":[\"serde\",\"toml/parse\"]}}", + "sqlx-macros-core_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.87\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2.0.18\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"sqlx-core/_rt-async-global-executor\"],\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-smol\":[\"smol\",\"sqlx-core/_rt-smol\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"thiserror\"],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", + "sqlx-macros_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{\"_rt-async-global-executor\":[\"sqlx-macros-core/_rt-async-global-executor\"],\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-smol\":[\"sqlx-macros-core/_rt-smol\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"mysql-rsa\":[\"sqlx-macros-core/mysql-rsa\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-load-extension\":[\"sqlx-macros-core/sqlite-load-extension\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"sqlx-toml\":[\"sqlx-macros-core/sqlx-toml\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", + "sqlx-mysql_0.9.0": "{\"dependencies\":[{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.10.0-rc.18\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"default\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\",\"bitflags/serde\"],\"rsa\":[\"dep:rand\",\"dep:rsa\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx-postgres_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.13.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^2.0.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"dep:serde\",\"dep:serde_json\",\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"json\",\"sqlx-core/offline\",\"smallvec/serde\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx-sqlite_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.12.0\"},{\"name\":\"form_urlencoded\",\"req\":\"^1.2.2\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\"],\"name\":\"libsqlite3-sys\",\"req\":\">=0.30.1, <0.38.0\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"_unstable-all-sqlite-features\":[\"deserialize\",\"load-extension\",\"preupdate-hook\",\"unlock-notify\"],\"_unstable-all-types\":[\"json\",\"chrono\",\"time\",\"uuid\"],\"_unstable-docs\":[\"bundled\",\"any\",\"_unstable-all-types\",\"_unstable-all-sqlite-features\"],\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"deserialize\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"load-extension\":[],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"rust_decimal\":[],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"unlock-notify\":[\"libsqlite3-sys/unlock_notify\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"features\":[\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.47\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[],\"_rt-async-std\":[],\"_rt-smol\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"_unstable-docs\":[\"all-databases\",\"_unstable-all-types\",\"sqlx-sqlite/_unstable-docs\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\",\"sqlx-core/offline\",\"sqlx-mysql?/offline\",\"sqlx-postgres?/offline\",\"sqlx-sqlite?/offline\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\",\"sqlx-macros?/mysql-rsa\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-global-executor\":[\"_rt-async-global-executor\",\"sqlx-core/_rt-async-global-executor\",\"sqlx-macros?/_rt-async-global-executor\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-smol\":[\"_rt-smol\",\"sqlx-core/_rt-smol\",\"sqlx-macros?/_rt-smol\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"sqlite-bundled\",\"sqlite-deserialize\",\"sqlite-load-extension\",\"sqlite-unlock-notify\"],\"sqlite-bundled\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-deserialize\":[\"sqlx-sqlite/deserialize\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\",\"sqlx-macros?/sqlite-load-extension\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"sqlite-unlock-notify\":[\"sqlx-sqlite/unlock-notify\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-macros?/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", "sse-stream_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"tracing\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"tracing\":[\"dep:tracing\"]}}", "stable_deref_trait_1.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "starlark_0.13.0": "{\"dependencies\":[{\"features\":[\"bumpalo\",\"num-bigint\"],\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"name\":\"bumpalo\",\"req\":\"^3.8\"},{\"name\":\"cmp_any\",\"req\":\"^0.8.1\"},{\"name\":\"debugserver-types\",\"req\":\"^0.5.0\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"display_container\",\"req\":\"^0.9.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.3.12\"},{\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"name\":\"inventory\",\"req\":\"^0.3.8\"},{\"name\":\"itertools\",\"req\":\"^0.13.0\"},{\"name\":\"maplit\",\"req\":\"^1.0.2\"},{\"name\":\"memoffset\",\"req\":\"^0.6.4\"},{\"name\":\"num-bigint\",\"req\":\"^0.4.3\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"name\":\"rustyline\",\"req\":\"^14.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_derive\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_syntax\",\"req\":\"^0.13.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"req\":\"^0.10.0\"},{\"name\":\"textwrap\",\"req\":\"^0.11\"},{\"name\":\"thiserror\",\"req\":\"^1.0.36\"}],\"features\":{}}", @@ -1659,7 +1664,7 @@ "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", - "typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", + "typenum_1.20.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"i128\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "unarray_0.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -1725,12 +1730,12 @@ "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "webbrowser_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-files\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.0\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\">=0.3, <=0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"NSDictionary\",\"NSString\",\"NSURL\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.10\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"Window\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"disable-wsl\":[],\"hardened\":[],\"wasm-console\":[\"web-sys/console\"]}}", "webpki-root-certs_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}", - "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "webpki-roots_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.12": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "which_6.0.3": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.9.0\"},{\"name\":\"home\",\"req\":\"^0.5.9\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^0.38.30\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "whoami_1.6.1": "{\"dependencies\":[{\"name\":\"libredox\",\"req\":\"^0.1.1\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"wasite\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"web\"],\"web\":[\"web-sys\"]}}", + "whoami_2.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.186\",\"target\":\"cfg(all(any(target_vendor = \\\"apple\\\", target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"openbsd\\\", target_os = \\\"illumos\\\", target_os = \\\"hurd\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"features\":[\"call\"],\"name\":\"libredox\",\"optional\":true,\"req\":\"^0.1.16\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"features\":[\"SCDynamicStore\",\"SCDynamicStoreCopySpecific\"],\"name\":\"objc2-system-configuration\",\"optional\":true,\"req\":\"^0.3.2\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"default_features\":false,\"name\":\"wasite\",\"optional\":true,\"req\":\"^1.0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3.85\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"std\",\"wasi-wasite\",\"wasm-web\"],\"force-stub\":[],\"std\":[\"dep:libc\",\"dep:libredox\",\"dep:objc2-system-configuration\",\"libc?/std\",\"web-sys?/std\"],\"wasi-wasite\":[\"dep:wasite\"],\"wasm-web\":[\"dep:web-sys\"]}}", "widestring_1.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"features\":[\"Win32_System_Diagnostics_Debug\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.59\"}],\"features\":{\"alloc\":[],\"debugger_visualizer\":[\"alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "wildcard_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.5\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"kind\":\"dev\",\"name\":\"wildmatch\",\"req\":\"^2.3.4\"}],\"features\":{\"fatal-warnings\":[]}}", "wildmatch_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.2\"},{\"kind\":\"dev\",\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1b1bc0e59a..33e81b0f44 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -181,7 +181,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -193,7 +193,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -207,7 +207,7 @@ dependencies = [ "bech32", "chacha20poly1305", "cookie-factory", - "hmac", + "hmac 0.12.1", "i18n-embed", "i18n-embed-fl", "lazy_static", @@ -216,7 +216,7 @@ dependencies = [ "rand 0.8.5", "rust-embed", "scrypt", - "sha2", + "sha2 0.10.9", "subtle", "x25519-dalek", "zeroize", @@ -231,12 +231,12 @@ dependencies = [ "base64 0.21.7", "chacha20poly1305", "cookie-factory", - "hkdf", + "hkdf 0.12.4", "io_tee", "nom 7.1.3", "rand 0.8.5", "secrecy", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -776,7 +776,7 @@ dependencies = [ "p256", "rand 0.8.5", "ring", - "sha2", + "sha2 0.10.9", "time", "tokio", "tracing", @@ -946,11 +946,11 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", "http 1.4.0", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] @@ -1164,7 +1164,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -1328,7 +1328,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1340,6 +1340,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1613,7 +1622,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -1623,7 +1643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1666,7 +1686,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1759,6 +1779,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "cmp_any" version = "0.8.1" @@ -1807,7 +1833,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1825,7 +1851,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tokio", "tracing", ] @@ -1933,7 +1959,7 @@ dependencies = [ "core_test_support", "flate2", "futures", - "hmac", + "hmac 0.12.1", "opentelemetry", "opentelemetry_sdk", "pretty_assertions", @@ -1942,7 +1968,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "shlex", "tar", "tempfile", @@ -2003,7 +2029,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "tokio-tungstenite", @@ -2079,13 +2105,13 @@ dependencies = [ "constant_time_eq 0.3.1", "futures", "gethostname", - "hmac", + "hmac 0.12.1", "jsonwebtoken", "owo-colors", "pretty_assertions", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "time", "tokio", @@ -2333,11 +2359,11 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-absolute-path", - "hmac", + "hmac 0.12.1", "pretty_assertions", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2455,7 +2481,7 @@ dependencies = [ "serde_ignored", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2480,7 +2506,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "tokio", "tracing", @@ -2581,7 +2607,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha1", + "sha1 0.10.6", "shlex", "similar", "tempfile", @@ -2601,7 +2627,7 @@ dependencies = [ "uuid", "walkdir", "which 8.0.0", - "whoami", + "whoami 1.6.1", "wiremock", "zstd 0.13.3", ] @@ -2871,7 +2897,7 @@ dependencies = [ "codex-utils-output-truncation", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -3052,7 +3078,7 @@ dependencies = [ "seccompiler", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "url", @@ -3102,7 +3128,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tiny_http", @@ -3137,7 +3163,7 @@ dependencies = [ "rmcp", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "thiserror 2.0.18", "tokio", @@ -3523,7 +3549,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "sse-stream", "tempfile", "thiserror 2.0.18", @@ -3614,7 +3640,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tracing", ] @@ -3857,7 +3883,7 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -3925,7 +3951,7 @@ name = "codex-utils-cache" version = "0.0.0" dependencies = [ "lru 0.16.3", - "sha1", + "sha1 0.10.6", "tokio", ] @@ -4262,7 +4288,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -4490,6 +4516,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -4617,6 +4652,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "crypto_box" version = "0.9.1" @@ -4694,6 +4738,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -4701,9 +4754,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -4935,10 +4988,10 @@ dependencies = [ "cbc", "dbus", "fastrand", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", - "sha2", + "sha2 0.10.9", "zeroize", ] @@ -5128,12 +5181,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "diplomat" version = "0.15.0" @@ -5351,7 +5415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -5377,7 +5441,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -5399,7 +5463,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -5548,13 +5612,12 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5810,17 +5873,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "flume" version = "0.12.0" @@ -5927,9 +5979,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -5937,15 +5989,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -5965,9 +6017,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -5984,9 +6036,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -5995,21 +6047,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -6019,7 +6071,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -6099,11 +6150,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -7161,11 +7226,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -7180,7 +7245,7 @@ dependencies = [ "http 1.4.0", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -7278,7 +7343,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -7287,7 +7361,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -7383,6 +7466,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -7421,7 +7513,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", + "webpki-roots", ] [[package]] @@ -7738,6 +7830,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -8294,9 +8392,12 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -8342,9 +8443,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -8648,12 +8749,12 @@ dependencies = [ [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -8983,22 +9084,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -9063,7 +9148,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -9122,7 +9206,7 @@ dependencies = [ "serde", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "url", ] @@ -9609,7 +9693,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9731,10 +9815,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", "password-hash", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9743,8 +9827,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -9845,17 +9929,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -9918,7 +9991,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -10155,7 +10228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.5.0", + "heck 0.4.1", "itertools 0.11.0", "log", "multimap", @@ -10175,7 +10248,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -10371,6 +10444,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -10534,7 +10613,7 @@ dependencies = [ "rama-utils", "rand 0.9.3", "serde", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -10587,7 +10666,7 @@ checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" dependencies = [ "ahash", "const_format", - "flume 0.12.0", + "flume", "hex", "ipnet", "itertools 0.14.0", @@ -10602,7 +10681,7 @@ dependencies = [ "rama-macros", "rama-utils", "serde", - "sha2", + "sha2 0.10.9", "socket2 0.6.2", "tokio", ] @@ -10655,7 +10734,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "webpki-roots 1.0.5", + "webpki-roots", "x509-parser", ] @@ -10722,6 +10801,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -10760,6 +10850,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -10981,7 +11077,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.5", + "webpki-roots", ] [[package]] @@ -11006,7 +11102,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -11073,26 +11169,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rtrb" version = "0.3.3" @@ -11134,7 +11210,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -11460,7 +11536,7 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2 0.12.2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -11511,12 +11587,12 @@ dependencies = [ "cbc", "futures-util", "generic-array", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", "rand 0.8.5", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -11907,8 +11983,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -11917,8 +12004,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ - "digest", - "sha1", + "digest 0.10.7", + "sha1 0.10.6", ] [[package]] @@ -11934,8 +12021,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -12006,7 +12104,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -12118,9 +12216,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -12131,12 +12229,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64 0.22.1", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -12146,17 +12245,16 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap 2.13.0", "log", "memchr", - "once_cell", "percent-encoding", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "time", @@ -12165,14 +12263,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -12183,79 +12281,64 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck 0.5.0", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.114", + "thiserror 2.0.18", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64 0.22.1", "bitflags 2.10.0", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.5", - "rsa", "serde", - "sha1", - "sha2", - "smallvec", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror 2.0.18", "time", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", @@ -12269,18 +12352,16 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", - "home", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.5", + "rand 0.10.1", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -12288,18 +12369,19 @@ dependencies = [ "time", "tracing", "uuid", - "whoami", + "whoami 2.1.2", ] [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", - "flume 0.11.1", + "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -12309,7 +12391,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror 2.0.18", "time", @@ -12707,7 +12788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -13540,7 +13621,7 @@ dependencies = [ "rand 0.9.3", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] @@ -13567,9 +13648,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -13702,7 +13783,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -13914,6 +13995,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -13979,6 +14069,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -13992,6 +14104,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -14107,15 +14231,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.5", -] - [[package]] name = "webpki-roots" version = "1.0.5" @@ -14193,6 +14308,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + [[package]] name = "widestring" version = "1.2.1" @@ -14824,6 +14945,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -14996,7 +15199,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_repr", - "sha1", + "sha1 0.10.6", "static_assertions", "tracing", "uds_windows", @@ -15140,9 +15343,9 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac", + "hmac 0.12.1", "pbkdf2 0.11.0", - "sha1", + "sha1 0.10.6", "time", "zstd 0.11.2+zstd.1.5.2", ] @@ -15163,12 +15366,12 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.13.0", "lzma-rs", "memchr", "pbkdf2 0.12.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "time", "xz2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6c9737e4ad..c85a55890c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -366,13 +366,14 @@ sha2 = "0.10" shlex = "1.3.0" similar = "2.7.0" socket2 = "0.6.1" -sqlx = { version = "0.8.6", default-features = false, features = [ +sqlx = { version = "0.9.0", default-features = false, features = [ "chrono", "json", "macros", "migrate", - "runtime-tokio-rustls", - "sqlite", + "runtime-tokio", + "tls-rustls", + "sqlite-bundled", "time", "uuid", ] } diff --git a/codex-rs/state/src/migrations.rs b/codex-rs/state/src/migrations.rs index 641d584530..0e12f17c45 100644 --- a/codex-rs/state/src/migrations.rs +++ b/codex-rs/state/src/migrations.rs @@ -19,6 +19,8 @@ fn runtime_migrator(base: &'static Migrator) -> Migrator { ignore_missing: true, locking: base.locking, no_tx: base.no_tx, + table_name: base.table_name.clone(), + create_schemas: base.create_schemas.clone(), } } diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs index 38a64d0845..aa0e90462b 100644 --- a/codex-rs/state/src/runtime/goals.rs +++ b/codex-rs/state/src/runtime/goals.rs @@ -399,44 +399,78 @@ WHERE thread_id = ? } let now_ms = datetime_to_epoch_millis(Utc::now()); + let active_or_stopped_status_filter = + "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')"; let status_filter = match mode { GoalAccountingMode::ActiveStatusOnly => "status = 'active'", GoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')", GoalAccountingMode::ActiveOrComplete => { "status IN ('active', 'budget_limited', 'complete')" } - GoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" - } + GoalAccountingMode::ActiveOrStopped => active_or_stopped_status_filter, }; let budget_limit_status_filter = match mode { GoalAccountingMode::ActiveStatusOnly | GoalAccountingMode::ActiveOnly | GoalAccountingMode::ActiveOrComplete => "status = 'active'", - GoalAccountingMode::ActiveOrStopped => { - "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')" - } + GoalAccountingMode::ActiveOrStopped => active_or_stopped_status_filter, }; - let goal_id_filter = if expected_goal_id.is_some() { - "goal_id = ?" - } else { - "1 = 1" - }; - let query = format!( + let mut builder = QueryBuilder::::new( r#" UPDATE thread_goals SET - time_used_seconds = time_used_seconds + ?, - tokens_used = tokens_used + ?, + time_used_seconds = time_used_seconds + + "#, + ); + builder.push_bind(time_delta_seconds); + builder.push( + r#", + tokens_used = tokens_used + + "#, + ); + builder.push_bind(token_delta); + builder.push( + r#", status = CASE - WHEN {budget_limit_status_filter} AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget - THEN ? + WHEN + "#, + ); + builder.push(budget_limit_status_filter); + builder.push( + r#" + AND token_budget IS NOT NULL + AND tokens_used + + "#, + ); + builder.push_bind(token_delta); + builder.push( + r#" + >= token_budget + THEN + "#, + ); + builder.push_bind(crate::ThreadGoalStatus::BudgetLimited.as_str()); + builder.push( + r#" ELSE status END, - updated_at_ms = ? -WHERE thread_id = ? - AND {status_filter} - AND {goal_id_filter} + updated_at_ms = + "#, + ); + builder.push_bind(now_ms); + builder.push( + r#" +WHERE thread_id = + "#, + ); + builder.push_bind(thread_id.to_string()); + builder.push(" AND "); + builder.push(status_filter); + if let Some(expected_goal_id) = expected_goal_id { + builder.push(" AND goal_id = ").push_bind(expected_goal_id); + } + builder.push( + r#" RETURNING thread_id, goal_id, @@ -450,18 +484,7 @@ RETURNING "#, ); - let mut query = sqlx::query(&query) - .bind(time_delta_seconds) - .bind(token_delta) - .bind(token_delta) - .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) - .bind(now_ms) - .bind(thread_id.to_string()); - if let Some(expected_goal_id) = expected_goal_id { - query = query.bind(expected_goal_id); - } - - let row = query.fetch_optional(self.pool.as_ref()).await?; + let row = builder.build().fetch_optional(self.pool.as_ref()).await?; let Some(row) = row else { return Ok(GoalAccountingOutcome::Unchanged( diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index e21df25e90..75b9ce82b2 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -343,11 +343,23 @@ WHERE id IN ( let max_bytes = usize::try_from(LOG_PARTITION_SIZE_LIMIT_BYTES).unwrap_or(usize::MAX); // Bound the fetched rows in SQL first so over-retained partitions do not have to load // every row into memory, then apply the exact whole-line byte cap after formatting. - let requested_threads = vec!["(?)"; thread_ids.len()].join(", "); - let query = format!( + let mut builder = QueryBuilder::::new( r#" WITH requested_threads(thread_id) AS ( - VALUES {requested_threads} + VALUES + "#, + ); + { + let mut separated = builder.separated(", "); + for thread_id in thread_ids { + separated + .push("(") + .push_bind_unseparated(*thread_id) + .push_unseparated(")"); + } + } + builder.push( + r#" ), latest_processes AS ( SELECT ( @@ -388,16 +400,13 @@ bounded_feedback_logs AS ( ) SELECT ts, ts_nanos, level, feedback_log_body FROM bounded_feedback_logs -WHERE cumulative_estimated_bytes <= ? -ORDER BY ts DESC, ts_nanos DESC, id DESC -"# +WHERE cumulative_estimated_bytes <= +"#, ); - let mut sql = sqlx::query_as::<_, FeedbackLogRow>(query.as_str()); - for thread_id in thread_ids { - sql = sql.bind(thread_id); - } - let rows = sql - .bind(LOG_PARTITION_SIZE_LIMIT_BYTES) + builder.push_bind(LOG_PARTITION_SIZE_LIMIT_BYTES); + builder.push(" ORDER BY ts DESC, ts_nanos DESC, id DESC"); + let rows = builder + .build_query_as::() .fetch_all(self.logs_pool.as_ref()) .await?; @@ -463,7 +472,7 @@ fn format_feedback_log_line( line } -fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQuery) { +fn push_log_filters(builder: &mut QueryBuilder, query: &LogQuery) { if !query.levels_upper.is_empty() { builder.push(" AND UPPER(level) IN ("); { @@ -511,11 +520,7 @@ fn push_log_filters<'a>(builder: &mut QueryBuilder<'a, Sqlite>, query: &'a LogQu } } -fn push_like_filters<'a>( - builder: &mut QueryBuilder<'a, Sqlite>, - column: &str, - filters: &'a [String], -) { +fn push_like_filters(builder: &mut QueryBuilder, column: &str, filters: &[String]) { if filters.is_empty() { return; } @@ -613,6 +618,8 @@ mod tests { ignore_missing: false, locking: true, no_tx: false, + table_name: LOGS_MIGRATOR.table_name.clone(), + create_schemas: LOGS_MIGRATOR.create_schemas.clone(), }; let pool = SqlitePool::connect_with( SqliteConnectOptions::new() diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 78180e7c7d..63bfe09f54 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -252,20 +252,16 @@ LIMIT 2 parent_thread_id: ThreadId, status: Option, ) -> anyhow::Result> { - let mut query = String::from( - "SELECT child_thread_id FROM thread_spawn_edges WHERE parent_thread_id = ?", + let mut builder = QueryBuilder::::new( + "SELECT child_thread_id FROM thread_spawn_edges WHERE parent_thread_id = ", ); - if status.is_some() { - query.push_str(" AND status = ?"); - } - query.push_str(" ORDER BY child_thread_id"); - - let mut sql = sqlx::query(query.as_str()).bind(parent_thread_id.to_string()); + builder.push_bind(parent_thread_id.to_string()); if let Some(status) = status { - sql = sql.bind(status.to_string()); + builder.push(" AND status = ").push_bind(status.to_string()); } + builder.push(" ORDER BY child_thread_id"); - let rows = sql.fetch_all(self.pool.as_ref()).await?; + let rows = builder.build().fetch_all(self.pool.as_ref()).await?; rows.into_iter() .map(|row| { ThreadId::try_from(row.try_get::("child_thread_id")?).map_err(Into::into) @@ -278,36 +274,48 @@ LIMIT 2 root_thread_id: ThreadId, status: Option, ) -> anyhow::Result> { - let status_filter = if status.is_some() { - " AND status = ?" - } else { - "" - }; - let query = format!( + let mut builder = QueryBuilder::::new( r#" WITH RECURSIVE subtree(child_thread_id, depth) AS ( SELECT child_thread_id, 1 FROM thread_spawn_edges - WHERE parent_thread_id = ?{status_filter} + WHERE parent_thread_id = + "#, + ); + builder.push_bind(root_thread_id.to_string()); + if let Some(status) = status { + let status = status.to_string(); + builder.push(" AND status = ").push_bind(status.clone()); + builder.push( + r#" UNION ALL SELECT edge.child_thread_id, subtree.depth + 1 FROM thread_spawn_edges AS edge JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id - WHERE 1 = 1{status_filter} + WHERE status = + "#, + ); + builder.push_bind(status); + } else { + builder.push( + r#" + UNION ALL + SELECT edge.child_thread_id, subtree.depth + 1 + FROM thread_spawn_edges AS edge + JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id + "#, + ); + } + builder.push( + r#" ) SELECT child_thread_id FROM subtree ORDER BY depth ASC, child_thread_id ASC - "# + "#, ); - let mut sql = sqlx::query(query.as_str()).bind(root_thread_id.to_string()); - if let Some(status) = status { - let status = status.to_string(); - sql = sql.bind(status.clone()).bind(status); - } - - let rows = sql.fetch_all(self.pool.as_ref()).await?; + let rows = builder.build().fetch_all(self.pool.as_ref()).await?; rows.into_iter() .map(|row| { ThreadId::try_from(row.try_get::("child_thread_id")?).map_err(Into::into) @@ -999,7 +1007,7 @@ fn one_thread_id_from_rows( } } -pub(super) fn push_thread_select_columns(builder: &mut QueryBuilder<'_, Sqlite>) { +pub(super) fn push_thread_select_columns(builder: &mut QueryBuilder) { builder.push( r#" SELECT @@ -1076,7 +1084,7 @@ pub struct ThreadFilterOptions<'a> { } pub(super) fn push_thread_filters<'a>( - builder: &mut QueryBuilder<'a, Sqlite>, + builder: &mut QueryBuilder, options: ThreadFilterOptions<'a>, ) { let ThreadFilterOptions { @@ -1156,7 +1164,7 @@ pub(super) fn push_thread_filters<'a>( } pub(super) fn push_thread_order_and_limit( - builder: &mut QueryBuilder<'_, Sqlite>, + builder: &mut QueryBuilder, sort_key: SortKey, sort_direction: SortDirection, limit: usize, From 5314f55097c2c56e4ca60e98ce6f4c9f755fc12f Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Wed, 27 May 2026 09:45:10 -0700 Subject: [PATCH 032/204] fix: run standalone updates noninteractively (#24637) # Summary The standalone update action currently downloads and runs the Codex installer as an interactive command. When an existing managed Codex install is present, accepting an update can therefore enter an installer prompt instead of completing the update. This change runs the standalone installer with `CODEX_NON_INTERACTIVE=1` on macOS/Linux and Windows. The installer environment-variable support is introduced by the parent PR; this PR wires that behavior into the Codex CLI update action. The rendered Windows command remains shell-safe, and long update commands wrap within the update-notice card. The standard test target snapshots the standalone notice for both platforms. # Stack 1. [#21567](https://github.com/openai/codex/pull/21567) - Adds environment-controlled release selection and noninteractive installer behavior. 2. [#24637](https://github.com/openai/codex/pull/24637) - Runs standalone updates with `CODEX_NON_INTERACTIVE=1`. (current) 3. [#24639](https://github.com/openai/codex/pull/24639) - Removes explicit release argument inputs in favor of `CODEX_RELEASE`. # Evidence Standalone updater-shaped macOS install with an existing npm-managed Codex on `PATH`: https://github.com/user-attachments/assets/a27fe9e9-db3a-4c39-a514-24bd3d1f01e8 # Testing Tests: targeted `codex-tui` update-action and update-notice snapshot tests, Rust formatting, benchmark smoke validation, macOS live-terminal standalone-update smoke testing, Windows ARM64 PowerShell standalone-update smoke testing through Parallels, and CI. --- codex-rs/tui/src/history_cell/notices.rs | 3 ++- ...update_available_history_cell_snapshot.snap | 11 +++++++++++ ...update_available_history_cell_snapshot.snap | 12 ++++++++++++ codex-rs/tui/src/history_cell/tests.rs | 18 ++++++++++++++++++ codex-rs/tui/src/update_action.rs | 18 ++++++++++++------ 5 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 3344dc45cd..7fb994442c 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -52,7 +52,8 @@ impl HistoryCell for UpdateAvailableHistoryCell { .width() .min(usize::from(width.saturating_sub(4))) .max(1); - with_border_with_inner_width(content.lines, inner_width) + let lines = adaptive_wrap_lines(content.lines, RtOptions::new(inner_width)); + with_border_with_inner_width(lines, inner_width) } fn raw_lines(&self) -> Vec> { diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000..c8f352dbb4 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_unix_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run sh -c 'curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh' to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap new file mode 100644 index 0000000000..fb15a05775 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__standalone_windows_update_available_history_cell_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run powershell -ExecutionPolicy Bypass -c '$env:CODEX_NON_INTERACTIVE=1; irm │ +│ https://chatgpt.com/codex/install.ps1 | iex' to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 7f846d39fb..8c48ced86a 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1027,6 +1027,24 @@ fn web_search_history_cell_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn standalone_unix_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::StandaloneUnix)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + +#[test] +fn standalone_windows_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::StandaloneWindows)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[test] fn web_search_history_cell_wraps_with_indented_continuation() { let query = "example search query with several generic words to exercise wrapping".to_string(); diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 0dbb145390..420562f13b 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -14,9 +14,9 @@ pub enum UpdateAction { BunGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, - /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | sh`. + /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh`. StandaloneUnix, - /// Update via `irm https://chatgpt.com/codex/install.ps1|iex`. + /// Update via `$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex`. StandaloneWindows, } @@ -43,7 +43,10 @@ impl UpdateAction { UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), UpdateAction::StandaloneUnix => ( "sh", - &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + &[ + "-c", + "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh", + ], ), UpdateAction::StandaloneWindows => ( "powershell", @@ -51,7 +54,7 @@ impl UpdateAction { "-ExecutionPolicy", "Bypass", "-c", - "irm https://chatgpt.com/codex/install.ps1 | iex", + "$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex", ], ), } @@ -140,7 +143,10 @@ mod tests { UpdateAction::StandaloneUnix.command_args(), ( "sh", - &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"][..], + &[ + "-c", + "curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh" + ][..], ) ); assert_eq!( @@ -151,7 +157,7 @@ mod tests { "-ExecutionPolicy", "Bypass", "-c", - "irm https://chatgpt.com/codex/install.ps1 | iex" + "$env:CODEX_NON_INTERACTIVE=1; irm https://chatgpt.com/codex/install.ps1 | iex" ][..], ) ); From 155905cf725d6c393cd29b1cd2ab9482fe651a32 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 27 May 2026 10:00:05 -0700 Subject: [PATCH 033/204] make vercel webhook url an env secret (#24778) move `DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL` to a repo environment secret. to keep scope of use of that env secret small, move the vercel website redeploy to its own post-release job. --- .github/workflows/rust-release.yml | 43 +++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 29d61d6a54..eaf886cd01 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1282,19 +1282,6 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy - # Only trigger the deploy if the release is not a pre-release. - # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers @@ -1507,6 +1494,36 @@ jobs: packages-dir: dist/python-runtime skip-existing: true + deploy-dev-website: + name: Trigger developers.openai.com deploy + needs: release + # Only trigger the deploy for a stable signed release. + # The deploy updates developers.openai.com with the new config schema json file. + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' && + !contains(needs.release.outputs.version, '-') + }} + runs-on: ubuntu-latest + continue-on-error: true + permissions: {} + environment: + name: dev-website-vercel-deploy + deployment: false + + steps: + - name: Trigger developers.openai.com deploy + continue-on-error: true + env: + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi + winget: name: winget needs: release From 8fcf2ad931b90589dd29a571f367e3185d26bbe0 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 27 May 2026 11:05:42 -0700 Subject: [PATCH 034/204] fix: Preserve draft text when completing argument-taking slash commands (#23950) This adds slash command completion behavior for argument-taking commands, where text after the partially typed command becomes inline arguments instead of being discarded. This addresses the workflow of drafting text first, moving to the start, and completing a slash command around that existing draft. Before this change, this workflow would remove all user-input text aside from the slash command, which can be frustrating if the user had just typed out a long and well thought out goal. - Preserves the draft tail for inline-argument slash commands like `/goal` and `/review` when completing with `Tab` or `Enter`. - Keeps popup filtering focused on the command fragment under the cursor rather than the full draft text. - Leaves slash commands that do not support inline arguments unchanged, so completion still replaces the existing draft tail for those commands. - Adds focused TUI tests under slash input covering preserved arguments, cursor edge cases, and the negative case for a command without inline args. Follow-up simplification and test relocation from #24683 folded into this PR. --------- Co-authored-by: Eric Traut --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 13 +- .../bottom_pane/chat_composer/slash_input.rs | 233 ++++++++++++++++-- 2 files changed, 226 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8fcda378fc..9fc03764da 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3504,6 +3504,9 @@ impl ChatComposer { && self .slash_input() .is_editing_command_name(first_line, cursor); + let command_filter_text = caret_on_first_line + .then(|| slash_input::command_popup_filter_text(first_line, cursor)) + .flatten(); // If the cursor is currently positioned within an `@token`, prefer the // file-search popup over the slash popup so users can insert a file path @@ -3517,14 +3520,18 @@ impl ChatComposer { match &mut self.popups.active { ActivePopup::Command(popup) => { if is_editing_slash_command_name { - popup.on_composer_text_change(first_line.to_string()); + if let Some(command_filter_text) = command_filter_text.as_deref() { + popup.on_composer_text_change(command_filter_text.to_string()); + } } else { self.popups.active = ActivePopup::None; } } _ => { - if is_editing_slash_command_name { - let command_popup = self.slash_input().command_popup(first_line); + if is_editing_slash_command_name + && let Some(command_filter_text) = command_filter_text.as_deref() + { + let command_popup = self.slash_input().command_popup(command_filter_text); self.popups.active = ActivePopup::Command(command_popup); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs index a7205d237a..bb9d2c6904 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -126,7 +126,11 @@ impl<'a> SlashInput<'a> { self.enabled && !text.starts_with(' ') && text.trim().starts_with('/') } - pub(super) fn command_element_range(&self, first_line: &str) -> Option> { + pub(super) fn command_element_range( + &self, + first_line: &str, + cursor: usize, + ) -> Option> { if self.is_bash_mode { return None; } @@ -135,6 +139,11 @@ impl<'a> SlashInput<'a> { return None; } let element_end = 1 + name.len(); + // A draft tail can make an in-progress prefix look complete ("/re" + "view"). + // Keep it editable until the cursor leaves the command name. + if cursor <= first_line.len() && (1..element_end).contains(&cursor) { + return None; + } let has_space_after = first_line .get(element_end..) .and_then(|tail| tail.chars().next()) @@ -159,7 +168,7 @@ impl<'a> SlashInput<'a> { has_slash_command_prefix(name, self.command_flags, self.service_tier_commands) } - pub(super) fn command_popup(&self, first_line: &str) -> CommandPopup { + pub(super) fn command_popup(&self, filter_text: &str) -> CommandPopup { let mut command_popup = CommandPopup::new( CommandPopupFlags { collaboration_modes_enabled: self.command_flags.collaboration_modes_enabled, @@ -175,7 +184,7 @@ impl<'a> SlashInput<'a> { }, self.service_tier_commands.to_vec(), ); - command_popup.on_composer_text_change(first_line.to_string()); + command_popup.on_composer_text_change(filter_text.to_string()); command_popup } @@ -255,8 +264,12 @@ impl ChatComposer { } => { // Ensure popup filtering/selection reflects the latest composer text // before applying completion. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { if selected_command_dispatches_immediately_on_tab(&selected_cmd) && let CommandItem::Builtin(cmd) = &selected_cmd @@ -267,8 +280,16 @@ impl ChatComposer { return (InputResult::Command(*cmd), true); } + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + if let Some(completed_text) = - selected_command_completion(first_line, &selected_cmd) + selected_command_completion(&first_line, &selected_cmd) { self.draft .textarea @@ -293,11 +314,23 @@ impl ChatComposer { } => { // Treat "/" as accepting the highlighted command as text completion // while the slash-command popup is active. - let first_line = self.draft.textarea.text().lines().next().unwrap_or(""); - popup.on_composer_text_change(first_line.to_string()); + let text = self.draft.textarea.text(); + let first_line = text.lines().next().unwrap_or("").to_owned(); + let cursor = self.draft.textarea.cursor(); + let filter_text = command_popup_filter_text(&first_line, cursor) + .unwrap_or_else(|| first_line.clone()); + popup.on_composer_text_change(filter_text); if let Some(selected_cmd) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &selected_cmd, + ) + { + return (InputResult::None, true); + } + if let Some(completed_text) = - selected_command_completion(first_line, &selected_cmd) + selected_command_completion(&first_line, &selected_cmd) { self.draft .textarea @@ -318,6 +351,15 @@ impl ChatComposer { .. } => { if let Some(sel) = popup.selected_item() { + if self + .complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &sel, + ) + && let Some(result) = self.try_dispatch_slash_command_with_args() + { + return (result, true); + } + self.stage_selected_slash_command_history(&sel); self.draft.textarea.set_text_clearing_elements(""); self.draft.is_bash_mode = false; @@ -338,6 +380,71 @@ impl ChatComposer { } } + fn complete_selected_slash_command_preserving_existing_draft_tail_as_inline_args( + &mut self, + selected_cmd: &CommandItem, + ) -> bool { + let CommandItem::Builtin(cmd) = selected_cmd else { + return false; + }; + if !cmd.supports_inline_args() { + return false; + } + + let text = self.draft.textarea.text(); + let first_line_end = text.find('\n').unwrap_or(text.len()); + let cursor = self.draft.textarea.cursor(); + if cursor > first_line_end || !text.starts_with('/') || !text.is_char_boundary(cursor) { + return false; + } + + let command_token_end = text[1..first_line_end] + .find(char::is_whitespace) + .map(|idx| 1 + idx) + .unwrap_or(first_line_end); + let typed_command_name = &text[1..command_token_end]; + let rest_after_token_is_empty = text[command_token_end..].trim().is_empty(); + if rest_after_token_is_empty && (cursor <= 1 || cursor >= command_token_end) { + return false; + } + let replace_end = + if cursor <= 1 || (typed_command_name == cmd.command() && rest_after_token_is_empty) { + command_token_end + } else { + cursor + }; + let tail = &text[replace_end..]; + let tail_starts_with_whitespace = tail.chars().next().is_some_and(char::is_whitespace); + let selected_command_text = format!("/{}", cmd.command()); + let replacement = if tail_starts_with_whitespace { + selected_command_text + } else { + format!("{selected_command_text} ") + }; + + let ranges_to_unmark = self + .draft + .textarea + .text_elements() + .into_iter() + .filter_map(|element| { + let range = element.byte_range.start..element.byte_range.end; + (range.start < replace_end && replace_end < range.end).then_some(range) + }) + .collect::>(); + for range in ranges_to_unmark { + self.draft.textarea.remove_element_range(range); + } + self.draft + .textarea + .replace_range(0..replace_end, &replacement); + self.draft.is_bash_mode = false; + self.draft + .textarea + .set_cursor(self.draft.textarea.text().len()); + true + } + /// Keep slash command elements aligned with the current first line. pub(super) fn sync_slash_command_elements(&mut self) { if !self.slash_commands_enabled() { @@ -346,7 +453,8 @@ impl ChatComposer { let text = self.draft.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); let first_line = &text[..first_line_end]; - let desired_range = self.slash_input().command_element_range(first_line); + let cursor = self.draft.textarea.cursor(); + let desired_range = self.slash_input().command_element_range(first_line, cursor); // Slash commands are only valid at byte 0 of the first line. // Any slash-shaped element not matching the current desired prefix is stale. let mut has_desired = false; @@ -424,12 +532,20 @@ pub(super) fn args_elements( .collect() } +pub(super) fn command_popup_filter_text(first_line: &str, cursor: usize) -> Option { + let (name, _rest) = command_under_cursor(first_line, cursor)?; + Some(format!("/{name}")) +} + /// If the cursor is currently within a slash command on the first line, -/// extract the command name and the rest of the line after it. +/// extract the command fragment before the cursor and the rest of the line after it. fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> { if !first_line.starts_with('/') { return None; } + if cursor > first_line.len() || !first_line.is_char_boundary(cursor) { + return None; + } let name_start = 1usize; let name_end = first_line[name_start..] @@ -437,16 +553,99 @@ fn command_under_cursor(first_line: &str, cursor: usize) -> Option<(&str, &str)> .map(|idx| name_start + idx) .unwrap_or_else(|| first_line.len()); + let cursor = if cursor <= name_start { + name_end + } else { + cursor + }; if cursor > name_end { return None; } - let name = &first_line[name_start..name_end]; - let rest_start = first_line[name_end..] - .find(|c: char| !c.is_whitespace()) - .map(|idx| name_end + idx) - .unwrap_or(name_end); - let rest = &first_line[rest_start..]; + let name = &first_line[name_start..cursor]; + let rest = &first_line[cursor..]; Some((name, rest)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::unbounded_channel; + + fn test_composer() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ) + } + + fn press(composer: &mut ChatComposer, code: KeyCode) -> InputResult { + composer + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)) + .0 + } + + fn composer_with_text_at_cursor(text: &str, cursor: usize) -> ChatComposer { + let mut composer = test_composer(); + composer.draft.textarea.set_text_clearing_elements(text); + composer.draft.textarea.set_cursor(cursor); + composer.sync_popups(); + composer + } + + fn composer_with_draft_tail(prefix: &str, draft: &str) -> ChatComposer { + composer_with_text_at_cursor(&format!("{prefix}{draft}"), prefix.len()) + } + + #[test] + fn slash_completion_preserves_existing_draft_tail_for_inline_arg_commands() { + let draft = "view the diff"; + let expected_text = "/review view the diff"; + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), expected_text); + assert_eq!(composer.draft.textarea.cursor(), expected_text.len()); + + let mut composer = composer_with_draft_tail("/re", draft); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::CommandWithArgs(SlashCommand::Review, draft.to_string(), Vec::new()) + ); + assert_eq!(composer.draft.textarea.text(), expected_text); + } + + #[test] + fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() { + let mut composer = composer_with_draft_tail( + "/mo", + "preserve this draft only for opted-in slash commands", + ); + + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/model "); + assert_eq!(composer.draft.textarea.cursor(), "/model ".len()); + } + + #[test] + fn slash_completion_does_not_turn_command_suffix_into_args() { + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None); + assert_eq!(composer.draft.textarea.text(), "/review "); + + let mut composer = composer_with_text_at_cursor("/review", "/re".len()); + assert_eq!( + press(&mut composer, KeyCode::Enter), + InputResult::Command(SlashCommand::Review) + ); + assert!(composer.draft.textarea.is_empty()); + } +} From f0f483e8b2a2630bf8dfa5f8451e81eba20def6c Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 27 May 2026 11:08:16 -0700 Subject: [PATCH 035/204] [codex] Remove stale composer narrative doc references (#24641) ## Context `docs/tui-chat-composer.md` was removed by #20896 as part of removing local-only docs/specs from the repository. I checked the #20896 file list and the merge commit: the composer doc was deleted, not moved or copied, and current `main` does not contain a replacement composer narrative doc. Current guidance should keep contributors and agents focused on the docs that still exist: the module docs in `chat_composer.rs` and `paste_burst.rs`. ## Summary - Removes the scoped TUI bottom-pane AGENTS.md requirement to update `docs/tui-chat-composer.md`. - Removes stale module-doc references to that deleted narrative doc from `chat_composer.rs` and `paste_burst.rs`. ## Validation - Checked #20896 and the merge commit with rename/copy detection to confirm `docs/tui-chat-composer.md` was deleted rather than moved. - Searched current `main` for a replacement composer narrative doc. - Not run; documentation-only change. --- codex-rs/tui/src/bottom_pane/AGENTS.md | 2 -- codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 - codex-rs/tui/src/bottom_pane/paste_burst.rs | 3 --- 3 files changed, 6 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md index b5328217db..b07fa2eca8 100644 --- a/codex-rs/tui/src/bottom_pane/AGENTS.md +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -4,8 +4,6 @@ When changing the paste-burst or chat-composer state machines in this folder, ke - Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a readable, top-down explanation of the current behavior. -- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter - handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling). - Keep implementations/docstrings aligned unless a divergence is intentional and documented. Practical check: diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 9fc03764da..e62cab9e86 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -111,7 +111,6 @@ //! composer flushes/clears any in-flight burst state so it cannot leak into subsequent input. //! //! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`. -//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`. //! //! # PasteBurst Integration Points //! diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 44e3898db6..4ea8310c35 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -16,9 +16,6 @@ //! - buffer a burst as a single pasted string, or //! - let input flow through as normal typing. //! -//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see -//! `docs/tui-chat-composer.md`. -//! //! # Call Pattern //! //! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds From bee78806a9b8fad5f250f7822da08008b2c1dd01 Mon Sep 17 00:00:00 2001 From: ningyi-oai Date: Wed, 27 May 2026 11:09:33 -0700 Subject: [PATCH 036/204] [codex] add compaction metadata to turn headers (#24368) ## Summary - Add `request_kind` values for foreground turn, startup prewarm, compaction, and detached memory model requests. - Attach compaction dispatch metadata to local Responses, legacy `/v1/responses/compact`, and remote v2 compact requests. - Add the existing logical context-window identifier as `window_id` on turn-owned model request metadata. - Keep identity fields optional for detached memory requests, while still emitting `request_kind="memory"` in non-git/no-sandbox workspaces. ## Root Cause `x-codex-turn-metadata` has more than one producer. Foreground turns and compaction requests own a real turn and should carry that turn identity. Detached memory stage-one requests do not own a foreground turn, so absent identity fields are valid rather than missing data. Startup websocket prewarm is also a model request, but it has `generate=false` and must not be counted as a foreground turn. `thread_source` or session source identifies where a thread came from (for example review, guardian, or another subagent). `request_kind` identifies what the current outbound model request is doing (`turn`, `prewarm`, `compaction`, or `memory`). A review or guardian thread can issue either a normal turn request or a compaction request, so source cannot replace request kind. ## Behavior / Impact - Ordinary foreground requests send `request_kind="turn"`, their real identity fields, and `window_id=":"`. - Startup websocket warmup requests send `request_kind="prewarm"` so they are not counted as foreground turns. - Compaction requests send `request_kind="compaction"`, their real owning turn identity, the existing `window_id`, and `compaction.{trigger,reason,implementation,phase,strategy}`. - Detached memory stage-one requests send `request_kind="memory"` without `session_id`, `thread_id`, `turn_id`, or `window_id`; when no workspace metadata exists, the kind-only header is still emitted. - `session_id`, `thread_id`, `turn_id`, and `window_id` remain optional in the header schema because detached memory requests do not own a foreground turn or context window. - `window_id` is not a new ID system: it is copied from the already-sent `x-codex-window-id` / WS client metadata value at model-request dispatch time. - Existing `x-codex-window-id` HTTP/WS emission, value format, generation advancement, resume behavior, and fork reset behavior are unchanged. - `request_kind`, `window_id`, and upstream turn-owned identity fields remain schema-owned; input `responsesapi_client_metadata` cannot replace their canonical values. - No table, DAG, export, app-server API, or MCP `_meta` schema changes are included. A compaction attempt stopped by a pre-compact hook issues no model request and therefore has no request header; its outcome remains in analytics events. Status, error, duration, and token deltas also remain analytics fields rather than request-header fields. Future detached-memory attribution using a real initiating turn ID as `trigger_turn_id` is intentionally not part of this PR. ## Sync With Main - Final pushed head `716342e79` is rebased onto `origin/main@0d37db4b2`. - The metadata conflict came from upstream `#24160`, which added `forked_from_thread_id` on the same `turn_metadata` surface. Resolution preserves that field and its protection from client metadata override alongside this PR's request-kind, compaction, and window-id fields. - While resolving the overlapping commits, I removed an accidental recursive model-request overlay and a duplicate detached-memory header builder before completing the rebase. ## Latency / User Experience Boundary - Foreground turns perform no new filesystem, git, or network work. New fields are inserted into metadata already serialized for outgoing requests. - Compaction issues the same model/HTTP requests with the same prompt, model, service tier, and sampling settings; only metadata bytes change. - Startup prewarm already sent metadata; it is now correctly classified as `prewarm`. - Non-git detached memory now sends a small kind-only metadata header rather than no header. - This client diff adds no user-visible latency mechanism beyond negligible serialization and header bytes on already-existing requests. ## Validation On conflict-resolved head `1d35c2cfb` based on `origin/main@487521733`: - `just fmt` (passed) - `just fix -p codex-core` (passed) - `git diff --check origin/main...HEAD` (passed) - `just test -p codex-core -E 'test(turn_metadata) | test(websocket_first_turn_uses_startup_prewarm_and_create) | test(responses_stream_includes_turn_metadata_header_for_git_workspace_e2e) | test(responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create) | test(remote_compact_v2_retries_failures_with_stream_retry_budget) | test(window_id_advances_after_compact_persists_on_resume_and_resets_on_fork)'` (`23 passed`; `bench-smoke` passed) - `just test -p codex-app-server -E 'test(turn_start_forwards_client_metadata_to_responses_request_v2) | test(turn_start_forwards_client_metadata_to_responses_websocket_request_body_v2) | test(auto_compaction_remote_emits_started_and_completed_items)'` (`3 passed`; `bench-smoke` passed) - `just test -p codex-memories-write` (`29 passed`; `bench-smoke` passed) --- .../tests/suite/v2/client_metadata.rs | 8 + .../app-server/tests/suite/v2/compaction.rs | 56 +++++ codex-rs/core/src/client.rs | 5 +- codex-rs/core/src/compact.rs | 10 +- codex-rs/core/src/compact_remote.rs | 22 +- codex-rs/core/src/compact_remote_v2.rs | 14 +- codex-rs/core/src/session/turn.rs | 5 +- codex-rs/core/src/session_startup_prewarm.rs | 3 +- codex-rs/core/src/turn_metadata.rs | 216 ++++++++++++++---- codex-rs/core/src/turn_metadata_tests.rs | 98 +++++++- codex-rs/core/tests/suite/agent_websocket.rs | 16 ++ codex-rs/core/tests/suite/compact.rs | 47 ++++ codex-rs/core/tests/suite/compact_remote.rs | 149 +++++++++++- codex-rs/memories/write/src/runtime.rs | 2 +- codex-rs/memories/write/src/startup_tests.rs | 33 ++- 15 files changed, 628 insertions(+), 56 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 3a00a433d1..9daa8d34f1 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -105,6 +105,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); + assert_eq!( + metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref() + ); Ok(()) } @@ -497,6 +501,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body assert_eq!(metadata["origin"].as_str(), Some("gaas")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); + assert_eq!( + metadata["window_id"].as_str(), + request["client_metadata"]["x-codex-window-id"].as_str() + ); websocket_server.shutdown().await; Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 6db031b278..2cf9f6b36c 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -190,6 +190,58 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() let response_requests = responses_log.requests(); assert_eq!(response_requests.len(), 3); + let turn_metadata = response_requests + .iter() + .map(|request| { + request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("turn request should include turn metadata")) + }) + .collect::>(); + for (request, metadata) in response_requests.iter().zip(&turn_metadata) { + assert_eq!(metadata["request_kind"].as_str(), Some("turn")); + assert!( + metadata["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()), + "turn request should carry a non-empty turn id" + ); + assert_eq!( + metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref() + ); + assert!(metadata.get("compaction").is_none()); + } + + let compact_metadata = compact_requests[0] + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("compact request should include turn metadata")); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["compaction"], + serde_json::json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compact", + "phase": "pre_turn", + "strategy": "memento", + }) + ); + assert_eq!( + compact_metadata["turn_id"], turn_metadata[2]["turn_id"], + "pre-turn compaction should carry the current turn id" + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_requests[0].header("x-codex-window-id").as_deref() + ); Ok(()) } @@ -407,3 +459,7 @@ async fn wait_for_context_compaction_completed( } } } + +fn parse_json_header(value: &str) -> serde_json::Value { + serde_json::from_str(value).unwrap_or_else(|err| panic!("turn metadata should be json: {err}")) +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d84b32a8b4..6c87400edd 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -383,7 +383,7 @@ impl ModelClient { self.store_cached_websocket_session(WebsocketSession::default()); } - fn current_window_id(&self) -> String { + pub(crate) fn current_window_id(&self) -> String { let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); format!("{thread_id}:{window_generation}") @@ -441,6 +441,7 @@ impl ModelClient { settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, + turn_metadata_header: Option<&str>, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); @@ -496,7 +497,7 @@ impl ModelClient { extra_headers.extend(build_responses_headers( self.state.beta_features_header.as_deref(), /*turn_state*/ None, - /*turn_metadata_header*/ None, + parse_turn_metadata_header(turn_metadata_header).as_ref(), )); extra_headers.extend(self.build_responses_identity_headers()); extra_headers.extend(build_session_headers( diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index f2bded8d06..1a6256ad76 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -13,6 +13,7 @@ use crate::session::PreviousTurnSettings; use crate::session::session::Session; use crate::session::turn::get_last_assistant_message_from_turn; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use crate::util::backoff; use codex_analytics::CodexCompactionEvent; use codex_analytics::CompactionImplementation; @@ -128,6 +129,8 @@ async fn run_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = + CompactionTurnMetadata::new(trigger, reason, CompactionImplementation::Responses, phase); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -153,6 +156,7 @@ async fn run_compact_task_inner( Arc::clone(&turn_context), input, initial_context_injection, + compaction_metadata, ) .await; let status = compaction_status_from_result(&result); @@ -173,6 +177,7 @@ async fn run_compact_task_inner_impl( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -204,7 +209,10 @@ async fn run_compact_task_inner_impl( personality: turn_context.personality, ..Default::default() }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 969da47544..b4456dc717 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -17,6 +17,7 @@ use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -89,6 +90,12 @@ async fn run_remote_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + trigger, + reason, + CompactionImplementation::ResponsesCompact, + phase, + ); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -113,8 +120,13 @@ async fn run_remote_compact_task_inner( return Err(CodexErr::TurnAborted); } } - let result = - run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await; + let result = run_remote_compact_task_inner_impl( + sess, + turn_context, + initial_context_injection, + compaction_metadata, + ) + .await; let status = compaction_status_from_result(&result); let error = result.as_ref().err().map(ToString::to_string); if result.is_ok() { @@ -139,6 +151,7 @@ async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -186,6 +199,10 @@ async fn run_remote_compact_task_inner_impl( output_schema: None, output_schema_strict: true, }; + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let mut new_history = sess .services .model_client @@ -203,6 +220,7 @@ async fn run_remote_compact_task_inner_impl( }, &turn_context.session_telemetry, &compaction_trace, + turn_metadata_header.as_deref(), ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index dcf241f990..0da3017f7d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -21,6 +21,7 @@ use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -105,6 +106,12 @@ async fn run_remote_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + trigger, + reason, + CompactionImplementation::ResponsesCompactionV2, + phase, + ); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -134,6 +141,7 @@ async fn run_remote_compact_task_inner( turn_context, client_session, initial_context_injection, + compaction_metadata, ) .await; let status = compaction_status_from_result(&result); @@ -161,6 +169,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -208,7 +217,10 @@ async fn run_remote_compact_task_inner_impl( output_schema_strict: true, }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ "model": turn_context.model_info.slug.as_str(), "instructions": prompt.base_instructions.text.as_str(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2b9fa02529..fe20b76e4d 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -234,7 +234,10 @@ pub(crate) async fn run_turn( .for_prompt(&turn_context.model_info.input_modalities) }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_model_request(&window_id); match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index ca21e3ea72..0669e54469 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -256,9 +256,10 @@ async fn schedule_startup_prewarm_inner( build_prompt_started_at.elapsed(), /*status*/ None, ); + let window_id = session.services.model_client.current_window_id(); let startup_turn_metadata_header = startup_turn_context .turn_metadata_state - .current_header_value(); + .current_header_value_for_prewarm(&window_id); let mut client_session = session.services.model_client.new_session(); let websocket_warmup_started_at = Instant::now(); client_session diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 52a714960a..4f9406d4b0 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -6,6 +6,11 @@ use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use codex_analytics::CompactionImplementation; +use codex_analytics::CompactionPhase; +use codex_analytics::CompactionReason; +use codex_analytics::CompactionStrategy; +use codex_analytics::CompactionTrigger; use codex_utils_string::to_ascii_json_string; use serde::Serialize; use serde_json::Value; @@ -27,12 +32,54 @@ const MODEL_KEY: &str = "model"; const REASONING_EFFORT_KEY: &str = "reasoning_effort"; const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; const USER_INPUT_REQUESTED_DURING_TURN_KEY: &str = "user_input_requested_during_turn"; +const REQUEST_KIND_KEY: &str = "request_kind"; +const COMPACTION_KEY: &str = "compaction"; +const WINDOW_ID_KEY: &str = "window_id"; pub(crate) struct McpTurnMetadataContext<'a> { pub(crate) model: &'a str, pub(crate) reasoning_effort: Option, } +/// Metadata present only on outbound model requests that perform compaction. +/// +/// These fields describe the operation at dispatch time. Post-response outcomes such as status, +/// error, duration, and token deltas remain in compaction analytics events. +#[derive(Clone, Copy, Debug, Serialize)] +pub(crate) struct CompactionTurnMetadata { + trigger: CompactionTrigger, + reason: CompactionReason, + implementation: CompactionImplementation, + phase: CompactionPhase, + strategy: CompactionStrategy, +} + +impl CompactionTurnMetadata { + pub(crate) fn new( + trigger: CompactionTrigger, + reason: CompactionReason, + implementation: CompactionImplementation, + phase: CompactionPhase, + ) -> Self { + Self { + trigger, + reason, + implementation, + phase, + strategy: CompactionStrategy::Memento, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum TurnMetadataRequestKind { + Turn, + Prewarm, + Compaction, + Memory, +} + #[derive(Clone, Debug, Default)] struct WorkspaceGitMetadata { associated_remote_urls: Option>, @@ -68,8 +115,15 @@ impl From for TurnMetadataWorkspace { } } -#[derive(Clone, Debug, Serialize, Default)] +/// Base payload for the outbound model request `x-codex-turn-metadata` header. +/// +/// Turn-owned state populates identity fields, including optional fork lineage. A concrete +/// request kind is added at outbound model dispatch so turns, startup prewarm, and compaction +/// remain distinguishable. Detached memory requests are constructed as `memory` directly. +#[derive(Clone, Debug, Serialize)] pub(crate) struct TurnMetadataBag { + #[serde(default, skip_serializing_if = "Option::is_none")] + request_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -87,6 +141,41 @@ pub(crate) struct TurnMetadataBag { } impl TurnMetadataBag { + fn new( + request_kind: Option, + session_id: Option, + thread_id: Option, + forked_from_thread_id: Option, + thread_source: Option, + turn_id: Option, + sandbox: Option, + ) -> Self { + Self { + request_kind, + session_id, + thread_id, + forked_from_thread_id, + thread_source, + turn_id, + workspaces: BTreeMap::new(), + sandbox, + } + } + + fn with_workspace_git_metadata( + mut self, + repo_root: Option, + workspace_git_metadata: Option, + ) -> Self { + if let (Some(repo_root), Some(workspace_git_metadata)) = (repo_root, workspace_git_metadata) + && !workspace_git_metadata.is_empty() + { + self.workspaces + .insert(repo_root, workspace_git_metadata.into()); + } + self + } + fn to_header_value(&self) -> Option { to_ascii_json_string(self).ok() } @@ -117,6 +206,9 @@ fn merge_turn_metadata( | "turn_id" | TURN_STARTED_AT_UNIX_MS_KEY | "forked_from_thread_id" + | REQUEST_KIND_KEY + | COMPACTION_KEY + | WINDOW_ID_KEY ) { continue; } @@ -140,32 +232,24 @@ pub async fn build_turn_metadata_header( get_has_changes(cwd), ); let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0); - if latest_git_commit_hash.is_none() - && associated_remote_urls.is_none() - && has_changes.is_none() - && sandbox.is_none() - { - return None; - } - - let workspace_git_metadata = WorkspaceGitMetadata { - associated_remote_urls, - latest_git_commit_hash, - has_changes, - }; - let mut metadata = TurnMetadataBag { - sandbox: sandbox.map(ToString::to_string), - ..Default::default() - }; - if let Some(repo_root) = repo_root - && !workspace_git_metadata.is_empty() - { - metadata - .workspaces - .insert(repo_root, workspace_git_metadata.into()); - } - - metadata.to_header_value() + TurnMetadataBag::new( + Some(TurnMetadataRequestKind::Memory), + /*session_id*/ None, + /*thread_id*/ None, + /*forked_from_thread_id*/ None, + /*thread_source*/ None, + /*turn_id*/ None, + sandbox.map(ToString::to_string), + ) + .with_workspace_git_metadata( + repo_root, + Some(WorkspaceGitMetadata { + associated_remote_urls, + latest_git_commit_hash, + has_changes, + }), + ) + .to_header_value() } #[derive(Clone, Debug)] @@ -173,7 +257,7 @@ pub(crate) struct TurnMetadataState { cwd: AbsolutePathBuf, repo_root: Option, base_metadata: TurnMetadataBag, - base_header: String, + base_header: Option, enriched_header: Arc>>, turn_started_at_unix_ms: Arc>>, responsesapi_client_metadata: Arc>>>, @@ -203,18 +287,16 @@ impl TurnMetadataState { ) .to_string(), ); - let base_metadata = TurnMetadataBag { - session_id: Some(session_id), - thread_id: Some(thread_id), + let base_metadata = TurnMetadataBag::new( + /*request_kind*/ None, + Some(session_id), + Some(thread_id), forked_from_thread_id, thread_source, - turn_id: Some(turn_id), + Some(turn_id), sandbox, - ..Default::default() - }; - let base_header = base_metadata - .to_header_value() - .unwrap_or_else(|| "{}".to_string()); + ); + let base_header = base_metadata.to_header_value(); Self { cwd, @@ -239,7 +321,7 @@ impl TurnMetadataState { { header } else { - self.base_header.clone() + self.base_header.clone()? }; let turn_started_at_unix_ms = *self .turn_started_at_unix_ms @@ -264,6 +346,7 @@ impl TurnMetadataState { ) -> Option { let header = self.current_header_value()?; let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.remove(REQUEST_KIND_KEY); metadata.insert( MODEL_KEY.to_string(), Value::String(context.model.to_string()), @@ -293,6 +376,52 @@ impl TurnMetadataState { Some(Value::Object(metadata)) } + fn current_header_value_for_model_request_kind( + &self, + window_id: &str, + request_kind: TurnMetadataRequestKind, + ) -> Option { + let header = self.current_header_value()?; + let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.insert( + REQUEST_KIND_KEY.to_string(), + serde_json::to_value(request_kind).ok()?, + ); + metadata.insert( + WINDOW_ID_KEY.to_string(), + Value::String(window_id.to_string()), + ); + to_ascii_json_string(&metadata).ok() + } + + pub(crate) fn current_header_value_for_model_request(&self, window_id: &str) -> Option { + self.current_header_value_for_model_request_kind(window_id, TurnMetadataRequestKind::Turn) + } + + pub(crate) fn current_header_value_for_prewarm(&self, window_id: &str) -> Option { + self.current_header_value_for_model_request_kind( + window_id, + TurnMetadataRequestKind::Prewarm, + ) + } + + pub(crate) fn current_header_value_for_compaction( + &self, + window_id: &str, + compaction: CompactionTurnMetadata, + ) -> Option { + let header = self.current_header_value_for_model_request_kind( + window_id, + TurnMetadataRequestKind::Compaction, + )?; + let mut metadata = serde_json::from_str::>(&header).ok()?; + metadata.insert( + COMPACTION_KEY.to_string(), + serde_json::to_value(compaction).ok()?, + ); + to_ascii_json_string(&metadata).ok() + } + pub(crate) fn mark_user_input_requested_during_turn(&self) { self.user_input_requested_during_turn .store(true, Ordering::Relaxed); @@ -335,13 +464,14 @@ impl TurnMetadataState { let Some(repo_root) = state.repo_root.clone() else { return; }; - if workspace_git_metadata.is_empty() { + + let enriched_metadata = state + .base_metadata + .clone() + .with_workspace_git_metadata(Some(repo_root), Some(workspace_git_metadata)); + if enriched_metadata.workspaces.is_empty() { return; } - let mut enriched_metadata = state.base_metadata.clone(); - enriched_metadata - .workspaces - .insert(repo_root, workspace_git_metadata.into()); if let Some(header_value) = enriched_metadata.to_header_value() { *state diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index a874b0db9b..a482392726 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -20,7 +20,7 @@ fn test_mcp_turn_metadata_context() -> McpTurnMetadataContext<'static> { } #[tokio::test] -async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { +async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity() { let temp_dir = TempDir::new().expect("temp dir"); let repo_path = temp_dir.path().join("repo-東京").abs(); std::fs::create_dir_all(&repo_path).expect("create repo"); @@ -64,6 +64,13 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { assert!(header.is_ascii()); assert!(!header.contains("東京")); let parsed: Value = serde_json::from_str(&header).expect("valid json"); + assert_eq!(parsed["request_kind"].as_str(), Some("memory")); + assert!(parsed.get("session_id").is_none()); + assert!(parsed.get("thread_id").is_none()); + assert!(parsed.get("forked_from_thread_id").is_none()); + assert!(parsed.get("turn_id").is_none()); + assert!(parsed.get(WINDOW_ID_KEY).is_none()); + let expected_repo_path = repo_path.to_string_lossy().into_owned(); let actual_repo_path = parsed .get("workspaces") @@ -77,13 +84,25 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() { .and_then(|workspaces| workspaces.values().next()) .cloned() .expect("workspace"); - assert_eq!( workspace.get("has_changes").and_then(Value::as_bool), Some(false) ); } +#[tokio::test] +async fn build_turn_metadata_header_marks_memory_without_workspace_metadata() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + + let header = build_turn_metadata_header(&cwd, /*sandbox*/ None) + .await + .expect("detached memory should emit its request kind"); + let parsed: Value = serde_json::from_str(&header).expect("valid json"); + + assert_eq!(parsed, serde_json::json!({"request_kind": "memory"})); +} + #[test] fn turn_metadata_state_uses_platform_sandbox_tag() { let temp_dir = TempDir::new().expect("temp dir"); @@ -109,6 +128,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); + assert!(json.get("request_kind").is_none()); let expected_sandbox = permission_profile_sandbox_tag( &permission_profile, WindowsSandboxLevel::Disabled, @@ -228,6 +248,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let meta = state .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) .expect("turn metadata should be present"); + assert!(meta.get("request_kind").is_none()); assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); @@ -367,7 +388,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "forked_from_thread_id".to_string(), "client-supplied".to_string(), ), + ("turn_id".to_string(), "client-supplied".to_string()), + (WINDOW_ID_KEY.to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), + ("request_kind".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), "client-supplied".to_string(), @@ -392,14 +416,84 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( ); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); + assert!(json.get("request_kind").is_none()); + assert!(json.get(WINDOW_ID_KEY).is_none()); assert_eq!( json["turn_started_at_unix_ms"].as_i64(), Some(1_700_000_000_123) ); + let model_request_header = state + .current_header_value_for_model_request("thread-a:1") + .expect("model request header"); + let model_request_json: Value = + serde_json::from_str(&model_request_header).expect("model request json"); + assert_eq!(model_request_json["request_kind"].as_str(), Some("turn")); + assert_eq!( + model_request_json[WINDOW_ID_KEY].as_str(), + Some("thread-a:1") + ); + let meta = state .current_meta_value_for_mcp_request(test_mcp_turn_metadata_context()) .expect("turn metadata should be present"); assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); + assert!(meta.get(WINDOW_ID_KEY).is_none()); +} + +#[test] +fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*forked_from_thread_id*/ None, + Some(ThreadSource::User), + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + state.set_responsesapi_client_metadata(HashMap::from([( + "compaction".to_string(), + "client-supplied".to_string(), + )])); + + let compact_header = state + .current_header_value_for_compaction( + "thread-a:2", + CompactionTurnMetadata::new( + CompactionTrigger::Auto, + CompactionReason::ContextLimit, + CompactionImplementation::ResponsesCompactionV2, + CompactionPhase::MidTurn, + ), + ) + .expect("compact header"); + let compact_json: Value = serde_json::from_str(&compact_header).expect("json"); + assert_eq!(compact_json["request_kind"].as_str(), Some("compaction")); + assert_eq!(compact_json["turn_id"].as_str(), Some("turn-a")); + assert_eq!(compact_json[WINDOW_ID_KEY].as_str(), Some("thread-a:2")); + assert_eq!( + compact_json["compaction"], + serde_json::json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compaction_v2", + "phase": "mid_turn", + "strategy": "memento", + }) + ); + + let regular_header = state + .current_header_value_for_model_request("thread-a:3") + .expect("regular header"); + let regular_json: Value = serde_json::from_str(®ular_header).expect("json"); + assert_eq!(regular_json["request_kind"].as_str(), Some("turn")); + assert_eq!(regular_json[WINDOW_ID_KEY].as_str(), Some("thread-a:3")); + assert!(regular_json.get("compaction").is_none()); } diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 6fe697d2b1..c405e35995 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -95,6 +95,16 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { let turn = connection.get(1).expect("missing turn request").body_json(); assert_eq!(warmup["type"].as_str(), Some("response.create")); assert_eq!(warmup["generate"].as_bool(), Some(false)); + let warmup_metadata: Value = serde_json::from_str( + warmup["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("warmup turn metadata"), + )?; + assert_eq!(warmup_metadata["request_kind"].as_str(), Some("prewarm")); + assert_eq!( + warmup_metadata["window_id"].as_str(), + warmup["client_metadata"]["x-codex-window-id"].as_str() + ); assert!( turn["tools"] .as_array() @@ -102,6 +112,12 @@ async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { "expected request tools to be populated" ); assert_eq!(turn["type"].as_str(), Some("response.create")); + let turn_metadata: Value = serde_json::from_str( + turn["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("turn metadata"), + )?; + assert_eq!(turn_metadata["request_kind"].as_str(), Some("turn")); server.shutdown().await; Ok(()) diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 20f4eae489..0e2c02de91 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2788,11 +2788,58 @@ async fn manual_compact_twice_preserves_latest_user_messages() { contains_user_text(&requests[1], first_user_message), "first compact request should include history before compaction" ); + let compact_metadata: Value = serde_json::from_str( + &requests[1] + .header("x-codex-turn-metadata") + .expect("local compact request should include turn metadata"), + ) + .expect("local compact turn metadata should be valid json"); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + requests[1].header("x-codex-window-id").as_deref() + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "manual", + "reason": "user_requested", + "implementation": "responses", + "phase": "standalone_turn", + "strategy": "memento", + }) + ); assert!( contains_user_text(&requests[2], second_user_message), "second turn request missing second user message" ); + let next_turn_metadata: Value = serde_json::from_str( + &requests[2] + .header("x-codex-turn-metadata") + .expect("next regular request should include turn metadata"), + ) + .expect("next regular turn metadata should be valid json"); + assert_eq!( + next_turn_metadata["request_kind"].as_str(), + Some("turn"), + "regular requests after compaction should remain turn requests" + ); + assert_eq!( + next_turn_metadata["window_id"].as_str(), + requests[2].header("x-codex-window-id").as_deref() + ); + assert_ne!( + compact_metadata["window_id"], next_turn_metadata["window_id"], + "the next request should use the new compacted context window" + ); + assert!( + next_turn_metadata.get("compaction").is_none(), + "regular requests after compaction should not be marked as compact requests" + ); assert!( contains_user_text(&requests[2], first_user_message), "second turn request should include the compacted user history" diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index bd290cad41..1c5b80f35f 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -368,6 +368,36 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_request.header("thread-id").as_deref(), Some(thread_id.as_str()) ); + let compact_metadata: Value = serde_json::from_str( + &compact_request + .header("x-codex-turn-metadata") + .expect("remote compact request should include turn metadata"), + ) + .expect("remote compact turn metadata should be valid json"); + assert!( + compact_metadata["turn_id"] + .as_str() + .is_some_and(|id| !id.is_empty()), + "remote compact turn metadata should include its turn id" + ); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_request.header("x-codex-window-id").as_deref() + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "manual", + "reason": "user_requested", + "implementation": "responses_compact", + "phase": "standalone_turn", + "strategy": "memento", + }) + ); let compact_body = compact_request.body_json(); assert_eq!( compact_body.get("model").and_then(|v| v.as_str()), @@ -375,6 +405,16 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { ); let response_requests = responses_mock.requests(); let first_response_request = response_requests.first().expect("initial request missing"); + let first_response_metadata: Value = serde_json::from_str( + &first_response_request + .header("x-codex-turn-metadata") + .expect("initial request should include turn metadata"), + ) + .expect("initial turn metadata should be valid json"); + assert_ne!( + first_response_metadata["turn_id"], compact_metadata["turn_id"], + "manual compaction should use its own turn id" + ); assert_eq!( compact_body["tools"], first_response_request.body_json()["tools"], @@ -407,6 +447,33 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let response_requests = responses_mock.requests(); let follow_up_request = response_requests.last().expect("follow-up request missing"); + let follow_up_metadata: Value = serde_json::from_str( + &follow_up_request + .header("x-codex-turn-metadata") + .expect("follow-up request should include turn metadata"), + ) + .expect("follow-up turn metadata should be valid json"); + assert_eq!( + follow_up_metadata["request_kind"].as_str(), + Some("turn"), + "regular requests after compaction should remain turn requests" + ); + assert!( + follow_up_metadata.get("compaction").is_none(), + "regular requests after compaction should not be marked as compact requests" + ); + assert_ne!( + follow_up_metadata["turn_id"], compact_metadata["turn_id"], + "the following user turn should not reuse a manual compact turn id" + ); + assert_eq!( + follow_up_metadata["window_id"].as_str(), + follow_up_request.header("x-codex-window-id").as_deref() + ); + assert_ne!( + follow_up_metadata["window_id"], compact_metadata["window_id"], + "the following user turn should use the new compacted context window" + ); let follow_up_body = follow_up_request.body_json().to_string(); assert!( follow_up_body.contains("\"type\":\"compaction\""), @@ -797,6 +864,30 @@ async fn remote_compact_v2_reuses_compaction_trigger_for_followups() -> Result<( "expected compact request to advertise the remote_compaction_v2 beta feature" ); assert_eq!(compact_request.path(), "/v1/responses"); + let compact_metadata: Value = serde_json::from_str( + &compact_request + .header("x-codex-turn-metadata") + .expect("v2 compact request should include turn metadata"), + ) + .expect("v2 compact turn metadata should be valid json"); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_request.header("x-codex-window-id").as_deref() + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "manual", + "reason": "user_requested", + "implementation": "responses_compaction_v2", + "phase": "standalone_turn", + "strategy": "memento", + }) + ); let compact_body = compact_request.body_json().to_string(); assert!( compact_body.contains("\"type\":\"compaction_trigger\""), @@ -1139,7 +1230,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { let session_id = harness.test().session_configured.session_id.to_string(); let thread_id = harness.test().session_configured.thread_id.to_string(); - mount_sse_once( + let initial_request = mount_sse_once( harness.server(), sse(vec![ responses::ev_shell_command_call("m1", "echo 'hi'"), @@ -1195,7 +1286,63 @@ async fn remote_compact_runs_automatically() -> Result<()> { compact_mock.single_request().header("thread-id").as_deref(), Some(thread_id.as_str()) ); + let compact_metadata: Value = serde_json::from_str( + &compact_mock + .single_request() + .header("x-codex-turn-metadata") + .expect("auto remote compact request should include turn metadata"), + ) + .expect("auto remote compact turn metadata should be valid json"); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["compaction"], + json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compact", + "phase": "mid_turn", + "strategy": "memento", + }) + ); + let initial_metadata: Value = serde_json::from_str( + &initial_request + .single_request() + .header("x-codex-turn-metadata") + .expect("initial request should include turn metadata"), + ) + .expect("initial turn metadata should be valid json"); + assert_eq!( + initial_metadata["turn_id"], compact_metadata["turn_id"], + "automatic mid-turn compaction should keep the current turn id" + ); + assert_eq!( + initial_metadata["window_id"], compact_metadata["window_id"], + "automatic mid-turn compaction summarizes the current context window" + ); let follow_up_request = responses_mock.single_request(); + let follow_up_metadata: Value = serde_json::from_str( + &follow_up_request + .header("x-codex-turn-metadata") + .expect("post-compaction continuation should include turn metadata"), + ) + .expect("post-compaction turn metadata should be valid json"); + assert_eq!( + follow_up_metadata["request_kind"].as_str(), + Some("turn"), + "post-compaction continuation should be a regular request" + ); + assert!(follow_up_metadata.get("compaction").is_none()); + assert_eq!( + follow_up_metadata["turn_id"], compact_metadata["turn_id"], + "automatic mid-turn continuation should keep the current turn id" + ); + assert_ne!( + follow_up_metadata["window_id"], compact_metadata["window_id"], + "post-compaction continuation should use the next context window" + ); let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 7bf2fe159d..2aeda32a55 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -153,7 +153,6 @@ impl MemoryStartupContext { StageOneRequestContext { model_info, - turn_metadata_header, session_telemetry: self .session_telemetry .clone() @@ -161,6 +160,7 @@ impl MemoryStartupContext { reasoning_effort: Some(reasoning_effort), reasoning_summary, service_tier: config_snapshot.service_tier, + turn_metadata_header, } } diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 4af8560e67..1a735b3668 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -237,11 +237,13 @@ async fn memories_startup_phase2_prunes_old_extension_resources_without_stage1_i } #[tokio::test] -async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Result<()> { +async fn memories_startup_phase1_uses_live_thread_service_tier_and_detached_metadata() +-> anyhow::Result<()> { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); let test = build_test_codex(&server, home).await?; assert_eq!(test.config.service_tier, None); + reset_git_repository(&test.config.cwd).await?; core_test_support::submit_thread_settings( &test.codex, @@ -279,6 +281,35 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu Some(ServiceTier::Fast.request_value().to_string()) ); + let stage_one = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-phase1"), + ev_assistant_message("msg-phase1", "phase1 complete"), + ev_completed("resp-phase1"), + ]), + ) + .await; + context + .stream_stage_one_prompt( + &test.config, + &codex_core::Prompt::default(), + &request_context, + ) + .await?; + let request = wait_for_single_request(&stage_one).await; + let metadata_header = request + .header("x-codex-turn-metadata") + .expect("detached memory request should include workspace metadata"); + let metadata: serde_json::Value = + serde_json::from_str(&metadata_header).expect("turn metadata json"); + assert_eq!(metadata["request_kind"].as_str(), Some("memory")); + assert!(metadata.get("session_id").is_none()); + assert!(metadata.get("thread_id").is_none()); + assert!(metadata.get("turn_id").is_none()); + assert!(metadata.get("window_id").is_none()); + assert!(metadata.get("workspaces").is_some()); + shutdown_test_codex(&test).await?; Ok(()) } From b1cbf622ad4b669bea2b45216284377b71131279 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 27 May 2026 11:11:04 -0700 Subject: [PATCH 037/204] [codex] Add friendly Python SDK sandbox presets (#24772) ## Why The Python SDK currently exposes sandbox selection differently depending on where it is used: thread lifecycle methods accept `SandboxMode`, while turns accept the lower-level `SandboxPolicy` shape. For the common case of choosing an access level, that leaks app-server wire details into otherwise straightforward SDK usage. This makes the common path explicit and discoverable: callers choose a named sandbox preset once, using the same keyword on threads and turns. The preset name `workspace_write` also makes the granted capability clear at the callsite. ## What changed - Added a root-level `Sandbox` enum with documented presets: - `Sandbox.read_only`: read files without allowing writes. - `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots. - `Sandbox.full_access`: run without filesystem access restrictions. - Documented that omitting `sandbox=` delegates to app-server's configured default, while explicit turn overrides remain sticky for subsequent turns. - Updated sync and async thread lifecycle and turn APIs to consistently accept `sandbox=Sandbox...`, translating to the existing app-server thread and turn representations internally. - Updated the public API artifact generator so regenerated SDK wrappers retain the friendly enum shape. - Replaced low-level policy construction in Python docs, examples, and the walkthrough notebook with the preset API. - Added focused coverage for root exports, method signatures, preset-to-wire mapping, and rejection of raw string sandbox inputs. ## API impact High-level turn calls now use `sandbox=` instead of `sandbox_policy=`: ```python from openai_codex import Codex, Sandbox with Codex() as codex: thread = codex.thread_start(sandbox=Sandbox.workspace_write) result = thread.run("Review the diff only.", sandbox=Sandbox.read_only) ``` `thread_start(...)` already defaults to `ApprovalMode.auto_review`, so normal writable usage is concise: ```python with Codex() as codex: thread = codex.thread_start(sandbox=Sandbox.workspace_write) thread.run("Update the files in this workspace.") ``` With that combination, edits inside `cwd` and configured writable roots run within the workspace-write sandbox. Operations that require approval, such as edits outside those roots, are routed through auto review. When `sandbox=` is omitted, app-server resolves its configured default. A sandbox supplied to `run(...)` or `turn(...)` applies to that turn and subsequent turns. ## Test coverage - `sdk/python/tests/test_public_api_signatures.py` covers the public export and parameter names, including the default approval mode. - `sdk/python/tests/test_public_api_runtime_behavior.py` covers preset mappings to the existing wire types and raw string rejection. --- sdk/python/README.md | 27 ++++++- sdk/python/docs/api-reference.md | 42 +++++++--- sdk/python/docs/faq.md | 21 ++++- sdk/python/docs/getting-started.md | 40 ++++++++-- .../13_model_select_and_turn_params/async.py | 11 +-- .../13_model_select_and_turn_params/sync.py | 12 +-- sdk/python/notebooks/sdk_walkthrough.ipynb | 14 ++-- sdk/python/scripts/update_sdk_artifacts.py | 40 +++++++++- sdk/python/src/openai_codex/__init__.py | 2 + sdk/python/src/openai_codex/_sandbox.py | 78 +++++++++++++++++++ sdk/python/src/openai_codex/api.py | 43 +++++----- .../tests/test_public_api_runtime_behavior.py | 35 +++++++++ .../tests/test_public_api_signatures.py | 19 ++++- 13 files changed, 310 insertions(+), 74 deletions(-) create mode 100644 sdk/python/src/openai_codex/_sandbox.py diff --git a/sdk/python/README.md b/sdk/python/README.md index 5b8e6b8178..72290f38e0 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -23,12 +23,12 @@ when you intentionally want to run against a specific local app-server binary. ## Quickstart ```python -from openai_codex import Codex +from openai_codex import Codex, Sandbox with Codex() as codex: # Call login_api_key(...) first when this app-server session is not # already authenticated. - thread = codex.thread_start(model="gpt-5") + thread = codex.thread_start(model="gpt-5", sandbox=Sandbox.workspace_write) result = thread.run("Say hello in one sentence.") print(result.final_response) print(len(result.items)) @@ -38,6 +38,29 @@ with Codex() as codex: `final_response` is `None` when the turn completes without a final-answer or phase-less assistant message item. +## Sandbox + +Use the same enum when creating a thread or changing its sandbox for a turn: + +```python +from openai_codex import Codex, Sandbox + +with Codex() as codex: + thread = codex.thread_start(sandbox=Sandbox.workspace_write) + thread.run("Make the requested change.") + review = thread.run("Review the diff only.", sandbox=Sandbox.read_only) +``` + +Available presets: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +When `sandbox=` is omitted, app-server uses its configured default. A sandbox +passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns +on the thread. + ## Login Use the auth helper that matches your app: diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index db78ff5f1d..a353d32fa2 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -12,6 +12,7 @@ from openai_codex import ( Codex, AsyncCodex, ApprovalMode, + Sandbox, ChatgptLoginHandle, DeviceCodeLoginHandle, AsyncChatgptLoginHandle, @@ -63,10 +64,10 @@ Properties/methods: - `login_chatgpt_device_code() -> DeviceCodeLoginHandle` - `account(*, refresh_token: bool = False) -> GetAccountResponse` - `logout() -> None` -- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` +- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Thread` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> ThreadListResponse` -- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Thread` -- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox=None) -> Thread` +- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Thread` +- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, sandbox: Sandbox | None = None) -> Thread` - `thread_archive(thread_id: str) -> ThreadArchiveResponse` - `thread_unarchive(thread_id: str) -> Thread` - `models(*, include_hidden: bool = False) -> ModelListResponse` @@ -103,10 +104,10 @@ Properties/methods: - `login_chatgpt_device_code() -> Awaitable[AsyncDeviceCodeLoginHandle]` - `account(*, refresh_token: bool = False) -> Awaitable[GetAccountResponse]` - `logout() -> Awaitable[None]` -- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` +- `thread_start(*, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]` - `thread_list(*, archived=None, cursor=None, cwd=None, limit=None, model_providers=None, sort_key=None, source_kinds=None) -> Awaitable[ThreadListResponse]` -- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox=None) -> Awaitable[AsyncThread]` -- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, sandbox=None) -> Awaitable[AsyncThread]` +- `thread_resume(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, model=None, model_provider=None, personality=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]` +- `thread_fork(thread_id: str, *, approval_mode=ApprovalMode.auto_review, base_instructions=None, config=None, cwd=None, developer_instructions=None, ephemeral=None, model=None, model_provider=None, sandbox: Sandbox | None = None) -> Awaitable[AsyncThread]` - `thread_archive(thread_id: str) -> Awaitable[ThreadArchiveResponse]` - `thread_unarchive(thread_id: str) -> Awaitable[AsyncThread]` - `models(*, include_hidden: bool = False) -> Awaitable[ModelListResponse]` @@ -148,16 +149,16 @@ attempt. API-key login completes synchronously and does not return a handle. ### Thread -- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnResult` -- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> TurnHandle` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnResult` +- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> TurnHandle` - `read(*, include_turns: bool = False) -> ThreadReadResponse` - `set_name(name: str) -> ThreadSetNameResponse` - `compact() -> ThreadCompactStartResponse` ### AsyncThread -- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[TurnResult]` -- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox_policy=None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]` +- `run(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[TurnResult]` +- `turn(input: str | Input, *, approval_mode=None, cwd=None, effort=None, model=None, output_schema=None, personality=None, sandbox: Sandbox | None = None, service_tier=None, summary=None) -> Awaitable[AsyncTurnHandle]` - `read(*, include_turns: bool = False) -> Awaitable[ThreadReadResponse]` - `set_name(name: str) -> Awaitable[ThreadSetNameResponse]` - `compact() -> Awaitable[ThreadCompactStartResponse]` @@ -182,6 +183,27 @@ phase-less assistant message item. Use `turn(...)` when you need low-level turn control (`stream()`, `steer()`, `interrupt()`) before collecting the turn result. +## Sandbox + +Use `sandbox=` consistently on thread lifecycle methods and turns: + +```python +from openai_codex import Codex, Sandbox + +with Codex() as codex: + thread = codex.thread_start(sandbox=Sandbox.workspace_write) + result = thread.run("Review the diff only.", sandbox=Sandbox.read_only) +``` + +Presets: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +When `sandbox=` is omitted, app-server uses its configured default. A sandbox +passed to `run(...)` or `turn(...)` applies to that turn and subsequent turns. + ## TurnHandle / AsyncTurnHandle ### TurnHandle diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index 5f2ed87ffd..42f2cb1f6a 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -48,7 +48,26 @@ If you are migrating older code, update these names: - `sortKey` -> `sort_key` - `sourceKinds` -> `source_kinds` - `outputSchema` -> `output_schema` -- `sandboxPolicy` -> `sandbox_policy` + +## How do I choose sandbox access? + +Use the same `sandbox=` keyword for threads and turns: + +```python +from openai_codex import Sandbox + +thread = codex.thread_start(sandbox=Sandbox.workspace_write) +result = thread.run("Review only.", sandbox=Sandbox.read_only) +``` + +The presets are: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +When `sandbox=` is omitted, app-server uses its configured default. A turn +sandbox override applies to that turn and subsequent turns. ## Why only `thread_start(...)` and `thread_resume(...)`? diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index b21db2a207..6113b9050a 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -26,7 +26,7 @@ Existing Codex auth state is reused automatically. To authenticate from the SDK, use the flow that fits your app: ```python -from openai_codex import Codex +from openai_codex import Codex, Sandbox with Codex() as codex: codex.login_api_key("sk-...") @@ -58,7 +58,11 @@ with Codex() as codex: server = codex.metadata.serverInfo print("Server:", None if server is None else server.name, None if server is None else server.version) - thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) + thread = codex.thread_start( + model="gpt-5.4", + config={"model_reasoning_effort": "high"}, + sandbox=Sandbox.workspace_write, + ) result = thread.run("Say hello in one sentence.") print("Thread:", thread.id) @@ -76,7 +80,29 @@ What happened: - use `thread.turn(...)` when you need a `TurnHandle` for streaming, steering, or interrupting before collecting `TurnResult` - one client can consume multiple active turns concurrently; turn streams are routed by turn ID -## 4) Continue the same thread (multi-turn) +## 4) Change sandbox access + +Use one enum for the initial sandbox and for later turn overrides: + +```python +from openai_codex import Codex, Sandbox + +with Codex() as codex: + thread = codex.thread_start(sandbox=Sandbox.workspace_write) + thread.run("Make the requested changes.") + review = thread.run("Review the diff only.", sandbox=Sandbox.read_only) +``` + +Available presets: + +- `Sandbox.read_only`: read files without allowing writes. +- `Sandbox.workspace_write`: the normal default for projects with a recorded trust decision; read files and write inside the workspace and configured writable roots. +- `Sandbox.full_access`: run without filesystem access restrictions. + +When `sandbox=` is omitted, app-server uses its configured default. A turn +override also becomes the sandbox for subsequent turns on that thread. + +## 5) Continue the same thread (multi-turn) ```python from openai_codex import Codex @@ -91,7 +117,7 @@ with Codex() as codex: print("second:", second.final_response) ``` -## 5) Async parity +## 6) Async parity Use `async with AsyncCodex()` as the normal async entrypoint. `AsyncCodex` initializes lazily, and context entry makes startup/shutdown explicit. @@ -111,7 +137,7 @@ async def main() -> None: asyncio.run(main()) ``` -## 6) Resume an existing thread +## 7) Resume an existing thread ```python from openai_codex import Codex @@ -124,7 +150,7 @@ with Codex() as codex: print(result.final_response) ``` -## 7) Public app-server types +## 8) Public app-server types The convenience wrappers live at the package root. Public app-server value and event types live under: @@ -133,7 +159,7 @@ event types live under: from openai_codex.types import ThreadReadResponse, Turn, TurnStatus ``` -## 8) Next stops +## 9) Next stops - API surface and signatures: `docs/api-reference.md` - Common decisions/pitfalls: `docs/faq.md` diff --git a/sdk/python/examples/13_model_select_and_turn_params/async.py b/sdk/python/examples/13_model_select_and_turn_params/async.py index 144169c46c..d9e706f240 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/async.py +++ b/sdk/python/examples/13_model_select_and_turn_params/async.py @@ -13,12 +13,12 @@ import asyncio from openai_codex import ( AsyncCodex, + Sandbox, ) from openai_codex.types import ( Personality, ReasoningEffort, ReasoningSummary, - SandboxPolicy, ) REASONING_RANK = { @@ -67,13 +67,6 @@ OUTPUT_SCHEMA = { "additionalProperties": False, } -SANDBOX_POLICY = SandboxPolicy.model_validate( - { - "type": "readOnly", - "access": {"type": "fullAccess"}, - } -) - async def main() -> None: async with AsyncCodex(config=runtime_config()) as codex: @@ -106,7 +99,7 @@ async def main() -> None: model=selected_model.model, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, - sandbox_policy=SANDBOX_POLICY, + sandbox=Sandbox.read_only, summary=ReasoningSummary.model_validate("concise"), ) second = await second_turn.run() diff --git a/sdk/python/examples/13_model_select_and_turn_params/sync.py b/sdk/python/examples/13_model_select_and_turn_params/sync.py index 962665363f..4a9afa0980 100644 --- a/sdk/python/examples/13_model_select_and_turn_params/sync.py +++ b/sdk/python/examples/13_model_select_and_turn_params/sync.py @@ -11,12 +11,12 @@ ensure_local_sdk_src() from openai_codex import ( Codex, + Sandbox, ) from openai_codex.types import ( Personality, ReasoningEffort, ReasoningSummary, - SandboxPolicy, ) REASONING_RANK = { @@ -65,14 +65,6 @@ OUTPUT_SCHEMA = { "additionalProperties": False, } -SANDBOX_POLICY = SandboxPolicy.model_validate( - { - "type": "readOnly", - "access": {"type": "fullAccess"}, - } -) - - with Codex(config=runtime_config()) as codex: models = codex.models(include_hidden=True) selected_model = _pick_highest_model(models.data) @@ -102,7 +94,7 @@ with Codex(config=runtime_config()) as codex: model=selected_model.model, output_schema=OUTPUT_SCHEMA, personality=Personality.pragmatic, - sandbox_policy=SANDBOX_POLICY, + sandbox=Sandbox.read_only, summary=ReasoningSummary.model_validate("concise"), ).run() diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index bd313a02b1..1bb9bf731f 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -220,11 +220,11 @@ "source": [ "# Cell 5b: one turn with most optional turn params\n", "from pathlib import Path\n", - "from openai_codex import (\n", + "from openai_codex import Sandbox\n", + "from openai_codex.types import (\n", " Personality,\n", " ReasoningEffort,\n", " ReasoningSummary,\n", - " SandboxPolicy,\n", ")\n", "\n", "output_schema = {\n", @@ -237,7 +237,6 @@ " 'additionalProperties': False,\n", "}\n", "\n", - "sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n", "summary = ReasoningSummary.model_validate('concise')\n", "\n", "with Codex() as codex:\n", @@ -249,7 +248,7 @@ " model='gpt-5.4',\n", " output_schema=output_schema,\n", " personality=Personality.pragmatic,\n", - " sandbox_policy=sandbox_policy,\n", + " sandbox=Sandbox.read_only,\n", " summary=summary,\n", " )\n", " result = turn.run()\n", @@ -266,11 +265,11 @@ "source": [ "# Cell 5c: choose highest model + highest supported reasoning, then run turns\n", "from pathlib import Path\n", - "from openai_codex import (\n", + "from openai_codex import Sandbox\n", + "from openai_codex.types import (\n", " Personality,\n", " ReasoningEffort,\n", " ReasoningSummary,\n", - " SandboxPolicy,\n", ")\n", "\n", "reasoning_rank = {\n", @@ -310,7 +309,6 @@ " 'required': ['summary', 'actions'],\n", " 'additionalProperties': False,\n", "}\n", - "sandbox_policy = SandboxPolicy.model_validate({'type': 'readOnly', 'access': {'type': 'fullAccess'}})\n", "\n", "with Codex() as codex:\n", " models = codex.models(include_hidden=True)\n", @@ -337,7 +335,7 @@ " model=selected_model.model,\n", " output_schema=output_schema,\n", " personality=Personality.pragmatic,\n", - " sandbox_policy=sandbox_policy,\n", + " sandbox=Sandbox.read_only,\n", " summary=ReasoningSummary.model_validate('concise'),\n", " ).run()\n", " print('agent.message.params:', second.final_response)\n", diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index e88c45521f..b20f814f66 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -875,7 +875,41 @@ def _approval_mode_model_arg_lines(*, indent: str = " ") -> list[str] def _model_arg_lines(fields: list[PublicFieldSpec], *, indent: str = " ") -> list[str]: - return [f"{indent}{field.wire_name}={field.py_name}," for field in fields] + lines: list[str] = [] + for field in fields: + arg = field.py_name + if field.wire_name == "sandbox": + arg = "_sandbox_mode(sandbox)" + elif field.wire_name == "sandbox_policy": + arg = "_sandbox_policy(sandbox)" + lines.append(f"{indent}{field.wire_name}={arg},") + return lines + + +def _replace_public_sandbox_field( + fields: list[PublicFieldSpec], *, wire_name: str +) -> list[PublicFieldSpec]: + """Expose stable wire sandbox settings through one public enum parameter.""" + public_fields: list[PublicFieldSpec] = [] + replaced = False + for field in fields: + if field.wire_name != wire_name: + public_fields.append(field) + continue + if replaced: + raise RuntimeError(f"Found more than one generated sandbox field named {wire_name}") + public_fields.append( + PublicFieldSpec( + wire_name=wire_name, + py_name="sandbox", + annotation="Sandbox | None", + required=False, + ) + ) + replaced = True + if not replaced: + raise RuntimeError(f"Could not find generated sandbox field named {wire_name}") + return public_fields def _replace_generated_block(source: str, block_name: str, body: str) -> str: @@ -1113,6 +1147,7 @@ def generate_public_api_flat_methods() -> None: "ThreadStartParams", exclude=approval_fields, ) + thread_start_fields = _replace_public_sandbox_field(thread_start_fields, wire_name="sandbox") thread_list_fields = _load_public_fields( "openai_codex.generated.v2_all", "ThreadListParams", @@ -1122,16 +1157,19 @@ def generate_public_api_flat_methods() -> None: "ThreadResumeParams", exclude={"thread_id", *approval_fields}, ) + thread_resume_fields = _replace_public_sandbox_field(thread_resume_fields, wire_name="sandbox") thread_fork_fields = _load_public_fields( "openai_codex.generated.v2_all", "ThreadForkParams", exclude={"thread_id", *approval_fields}, ) + thread_fork_fields = _replace_public_sandbox_field(thread_fork_fields, wire_name="sandbox") turn_start_fields = _load_public_fields( "openai_codex.generated.v2_all", "TurnStartParams", exclude={"thread_id", "input", *approval_fields}, ) + turn_start_fields = _replace_public_sandbox_field(turn_start_fields, wire_name="sandbox_policy") source = public_api_path.read_text() source = _replace_generated_block( diff --git a/sdk/python/src/openai_codex/__init__.py b/sdk/python/src/openai_codex/__init__.py index 3bc018a769..9f1ca770c5 100644 --- a/sdk/python/src/openai_codex/__init__.py +++ b/sdk/python/src/openai_codex/__init__.py @@ -15,6 +15,7 @@ from .api import ( LocalImageInput, MentionInput, RunInput, + Sandbox, SkillInput, TextInput, Thread, @@ -44,6 +45,7 @@ __all__ = [ "Codex", "AsyncCodex", "ApprovalMode", + "Sandbox", "ChatgptLoginHandle", "DeviceCodeLoginHandle", "AsyncChatgptLoginHandle", diff --git a/sdk/python/src/openai_codex/_sandbox.py b/sdk/python/src/openai_codex/_sandbox.py new file mode 100644 index 0000000000..24aeb57c85 --- /dev/null +++ b/sdk/python/src/openai_codex/_sandbox.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from enum import Enum +from typing import NoReturn + +from .generated.v2_all import ( + DangerFullAccessSandboxPolicy, + ReadOnlySandboxPolicy, + SandboxMode, + SandboxPolicy, + WorkspaceWriteSandboxPolicy, +) + + +class Sandbox(str, Enum): + """Preset filesystem access levels for threads and turns. + + `read_only` allows file reads without writes. `workspace_write` is the + normal default for projects with a recorded trust decision and allows + writes inside the workspace and configured writable roots. `full_access` + removes filesystem access restrictions. + """ + + read_only = "read-only" + workspace_write = "workspace-write" + full_access = "full-access" + + +def _require_sandbox(sandbox: Sandbox) -> None: + if isinstance(sandbox, Sandbox): + return + options = ", ".join(f"Sandbox.{value.name}" for value in Sandbox) + raise ValueError(f"sandbox must be one of: {options}") + + +def _sandbox_mode(sandbox: Sandbox | None) -> SandboxMode | None: + """Translate a public preset to the thread lifecycle wire mode.""" + if sandbox is None: + return None + _require_sandbox(sandbox) + + match sandbox: + case Sandbox.read_only: + return SandboxMode.read_only + case Sandbox.workspace_write: + return SandboxMode.workspace_write + case Sandbox.full_access: + return SandboxMode.danger_full_access + case _: + return _assert_never_sandbox(sandbox) + + +def _sandbox_policy(sandbox: Sandbox | None) -> SandboxPolicy | None: + """Translate a public preset to the turn override wire policy.""" + if sandbox is None: + return None + _require_sandbox(sandbox) + + match sandbox: + case Sandbox.read_only: + return SandboxPolicy( + root=ReadOnlySandboxPolicy(type="readOnly"), + ) + case Sandbox.workspace_write: + return SandboxPolicy( + root=WorkspaceWriteSandboxPolicy(type="workspaceWrite"), + ) + case Sandbox.full_access: + return SandboxPolicy( + root=DangerFullAccessSandboxPolicy(type="dangerFullAccess"), + ) + case _: + return _assert_never_sandbox(sandbox) + + +def _assert_never_sandbox(sandbox: NoReturn) -> NoReturn: + """Make sandbox mapping exhaustive for static type checkers.""" + raise AssertionError(f"Unhandled sandbox: {sandbox!r}") diff --git a/sdk/python/src/openai_codex/api.py b/sdk/python/src/openai_codex/api.py index f14a311789..6b6ddf617e 100644 --- a/sdk/python/src/openai_codex/api.py +++ b/sdk/python/src/openai_codex/api.py @@ -37,6 +37,7 @@ from ._run import ( _collect_async_turn_result, _collect_turn_result, ) +from ._sandbox import Sandbox as Sandbox, _sandbox_mode, _sandbox_policy from .async_client import AsyncAppServerClient from .client import AppServerClient, AppServerConfig from .generated.v2_all import ( @@ -48,8 +49,6 @@ from .generated.v2_all import ( Personality, ReasoningEffort, ReasoningSummary, - SandboxMode, - SandboxPolicy, SortDirection, ThreadArchiveResponse, ThreadCompactStartResponse, @@ -138,7 +137,7 @@ class Codex: model: str | None = None, model_provider: str | None = None, personality: Personality | None = None, - sandbox: SandboxMode | None = None, + sandbox: Sandbox | None = None, service_name: str | None = None, service_tier: str | None = None, session_start_source: ThreadStartSource | None = None, @@ -156,7 +155,7 @@ class Codex: model=model, model_provider=model_provider, personality=personality, - sandbox=sandbox, + sandbox=_sandbox_mode(sandbox), service_name=service_name, service_tier=service_tier, session_start_source=session_start_source, @@ -205,7 +204,7 @@ class Codex: model: str | None = None, model_provider: str | None = None, personality: Personality | None = None, - sandbox: SandboxMode | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, ) -> Thread: approval_policy, approvals_reviewer = _approval_mode_override_settings(approval_mode) @@ -220,7 +219,7 @@ class Codex: model=model, model_provider=model_provider, personality=personality, - sandbox=sandbox, + sandbox=_sandbox_mode(sandbox), service_tier=service_tier, ) resumed = self._client.thread_resume(thread_id, params) @@ -238,7 +237,7 @@ class Codex: ephemeral: bool | None = None, model: str | None = None, model_provider: str | None = None, - sandbox: SandboxMode | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, thread_source: ThreadSource | None = None, ) -> Thread: @@ -254,7 +253,7 @@ class Codex: ephemeral=ephemeral, model=model, model_provider=model_provider, - sandbox=sandbox, + sandbox=_sandbox_mode(sandbox), service_tier=service_tier, thread_source=thread_source, ) @@ -371,7 +370,7 @@ class AsyncCodex: model: str | None = None, model_provider: str | None = None, personality: Personality | None = None, - sandbox: SandboxMode | None = None, + sandbox: Sandbox | None = None, service_name: str | None = None, service_tier: str | None = None, session_start_source: ThreadStartSource | None = None, @@ -390,7 +389,7 @@ class AsyncCodex: model=model, model_provider=model_provider, personality=personality, - sandbox=sandbox, + sandbox=_sandbox_mode(sandbox), service_name=service_name, service_tier=service_tier, session_start_source=session_start_source, @@ -440,7 +439,7 @@ class AsyncCodex: model: str | None = None, model_provider: str | None = None, personality: Personality | None = None, - sandbox: SandboxMode | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, ) -> AsyncThread: await self._ensure_initialized() @@ -456,7 +455,7 @@ class AsyncCodex: model=model, model_provider=model_provider, personality=personality, - sandbox=sandbox, + sandbox=_sandbox_mode(sandbox), service_tier=service_tier, ) resumed = await self._client.thread_resume(thread_id, params) @@ -474,7 +473,7 @@ class AsyncCodex: ephemeral: bool | None = None, model: str | None = None, model_provider: str | None = None, - sandbox: SandboxMode | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, thread_source: ThreadSource | None = None, ) -> AsyncThread: @@ -491,7 +490,7 @@ class AsyncCodex: ephemeral=ephemeral, model=model, model_provider=model_provider, - sandbox=sandbox, + sandbox=_sandbox_mode(sandbox), service_tier=service_tier, thread_source=thread_source, ) @@ -529,7 +528,7 @@ class Thread: model: str | None = None, output_schema: JsonObject | None = None, personality: Personality | None = None, - sandbox_policy: SandboxPolicy | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> TurnResult: @@ -541,7 +540,7 @@ class Thread: model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox=sandbox, service_tier=service_tier, summary=summary, ) @@ -562,7 +561,7 @@ class Thread: model: str | None = None, output_schema: JsonObject | None = None, personality: Personality | None = None, - sandbox_policy: SandboxPolicy | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> TurnHandle: @@ -578,7 +577,7 @@ class Thread: model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox_policy=_sandbox_policy(sandbox), service_tier=service_tier, summary=summary, ) @@ -612,7 +611,7 @@ class AsyncThread: model: str | None = None, output_schema: JsonObject | None = None, personality: Personality | None = None, - sandbox_policy: SandboxPolicy | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> TurnResult: @@ -624,7 +623,7 @@ class AsyncThread: model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox=sandbox, service_tier=service_tier, summary=summary, ) @@ -645,7 +644,7 @@ class AsyncThread: model: str | None = None, output_schema: JsonObject | None = None, personality: Personality | None = None, - sandbox_policy: SandboxPolicy | None = None, + sandbox: Sandbox | None = None, service_tier: str | None = None, summary: ReasoningSummary | None = None, ) -> AsyncTurnHandle: @@ -662,7 +661,7 @@ class AsyncThread: model=model, output_schema=output_schema, personality=personality, - sandbox_policy=sandbox_policy, + sandbox_policy=_sandbox_policy(sandbox), service_tier=service_tier, summary=summary, ) diff --git a/sdk/python/tests/test_public_api_runtime_behavior.py b/sdk/python/tests/test_public_api_runtime_behavior.py index 4e79cb6104..f6d83a965e 100644 --- a/sdk/python/tests/test_public_api_runtime_behavior.py +++ b/sdk/python/tests/test_public_api_runtime_behavior.py @@ -11,6 +11,7 @@ from openai_codex.api import ( ApprovalMode, AsyncCodex, Codex, + Sandbox, ) from openai_codex.generated.v2_all import TurnStartParams from openai_codex.models import InitializeResponse @@ -158,6 +159,40 @@ def test_unknown_approval_mode_is_rejected() -> None: public_api_module._approval_mode_settings("allow_all") # type: ignore[arg-type] +def test_sandbox_presets_serialize_for_threads_and_turns() -> None: + """One public sandbox enum should map to both stable wire representations.""" + assert { + sandbox.name: public_api_module._sandbox_mode(sandbox).value for sandbox in Sandbox + } == { + "read_only": "read-only", + "workspace_write": "workspace-write", + "full_access": "danger-full-access", + } + assert { + sandbox.name: public_api_module._sandbox_policy(sandbox).model_dump( + by_alias=True, + mode="json", + ) + for sandbox in Sandbox + } == { + "read_only": {"networkAccess": False, "type": "readOnly"}, + "workspace_write": { + "excludeSlashTmp": False, + "excludeTmpdirEnvVar": False, + "networkAccess": False, + "type": "workspaceWrite", + "writableRoots": [], + }, + "full_access": {"type": "dangerFullAccess"}, + } + + +def test_raw_sandbox_strings_are_rejected() -> None: + """Callers should use the discoverable enum rather than memorizing values.""" + with pytest.raises(ValueError, match="Sandbox\\.workspace_write"): + public_api_module._sandbox_mode("workspace") # type: ignore[arg-type] + + def test_retry_examples_compare_status_with_enum() -> None: for path in ( ROOT / "examples" / "10_error_handling_and_retry" / "sync.py", diff --git a/sdk/python/tests/test_public_api_signatures.py b/sdk/python/tests/test_public_api_signatures.py index 9a1212dfdf..b4dc49acf1 100644 --- a/sdk/python/tests/test_public_api_signatures.py +++ b/sdk/python/tests/test_public_api_signatures.py @@ -16,6 +16,7 @@ from openai_codex import ( AsyncThread, AsyncTurnHandle, Codex, + Sandbox, Thread, TurnHandle, TurnResult, @@ -29,6 +30,7 @@ EXPECTED_ROOT_EXPORTS = [ "Codex", "AsyncCodex", "ApprovalMode", + "Sandbox", "ChatgptLoginHandle", "DeviceCodeLoginHandle", "AsyncChatgptLoginHandle", @@ -191,6 +193,15 @@ def test_root_exports_approval_mode() -> None: ] +def test_root_exports_sandbox_presets() -> None: + """The friendly sandbox API should expose only obvious named presets.""" + assert [(sandbox.name, sandbox.value) for sandbox in Sandbox] == [ + ("read_only", "read-only"), + ("workspace_write", "workspace-write"), + ("full_access", "full-access"), + ] + + def test_package_and_default_client_versions_follow_project_version() -> None: """The importable package version should stay aligned with pyproject metadata.""" pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" @@ -341,7 +352,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "model", "output_schema", "personality", - "sandbox_policy", + "sandbox", "service_tier", "summary", ], @@ -352,7 +363,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "model", "output_schema", "personality", - "sandbox_policy", + "sandbox", "service_tier", "summary", ], @@ -416,7 +427,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "model", "output_schema", "personality", - "sandbox_policy", + "sandbox", "service_tier", "summary", ], @@ -427,7 +438,7 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None: "model", "output_schema", "personality", - "sandbox_policy", + "sandbox", "service_tier", "summary", ], From 8d398d3c5230fa69557b258264f3c6eb21fff9aa Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 27 May 2026 15:15:03 -0300 Subject: [PATCH 038/204] feat(tui): add vim text object bindings (#24382) ## Why Vim mode currently supports some normal-mode operators and motions, but common text-object combinations like `ciw`, `daw`, `di(`, and quote/bracket variants are still missing. That makes the composer feel incomplete for users who expect operator + text object editing to work inside prompts. Closes #21383. ## What Changed - Add Vim pending-state support for operator/text-object sequences. - Add `c` as a normal-mode operator for text objects, so combinations like `ciw` delete the object and enter insert mode. - Support word, WORD, delimiter, and quote text objects: - `iw`, `aw`, `iW`, `aW` - `i(`, `a(`, `i)`, `a)`, `ib`, `ab` - `i[`, `a[`, `i]`, `a]` - `i{`, `a{`, `i}`, `a}`, `iB`, `aB` - `i"`, `a"`, `i'`, `a'`, `i\``, `a\`` - Add configurable keymap entries and keymap picker coverage for the new Vim text-object context. - Regenerate the config schema and update keymap picker snapshots. ## How to Test Manual smoke test: 1. Start Codex with Vim composer mode enabled. 2. Type a draft such as: ```text alpha beta gamma call(foo[bar], {"x": "hello world"}) say "one \"two\" three" now ``` 3. Put the cursor on `beta`, press `ciw`, and confirm `beta` is removed and the composer enters insert mode. 4. Escape back to normal mode, put the cursor on `gamma`, press `daw`, and confirm `gamma` plus surrounding whitespace is removed. 5. Put the cursor inside `foo[bar]`, press `di[`, and confirm only `bar` is removed. 6. Put the cursor inside `call(...)`, press `da(`, and confirm the whole parenthesized section is removed. 7. Put the cursor inside the quoted text, press `ci"`, and confirm the quote contents are removed and insert mode starts. 8. Verify cancellation does not edit text: press `d` then `Esc`, and press `d` then `i` then `Esc`. Targeted tests: - `cargo test -p codex-tui --lib vim_` - `cargo nextest run -p codex-tui keymap_setup::tests` Additional local checks: - `just write-config-schema` - `just fmt` - `just fix -p codex-tui` - `git diff --check` - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` Local full-suite note: `just test -p codex-tui` ran to completion. The keymap snapshot failures were expected and accepted. Two unrelated guardian feature-flag tests still fail locally: - `app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` `just argument-comment-lint` is currently blocked locally by Bazel analysis before the lint runs because `compiler-rt` has an empty `include/sanitizer/*.h` glob in the local Bazel cache. The touched Rust diff was manually inspected for opaque positional literals. --- codex-rs/config/src/tui_keymap.rs | 47 +++ codex-rs/core/config.schema.json | 138 +++++++ codex-rs/tui/src/bottom_pane/textarea.rs | 308 ++++++++++++--- codex-rs/tui/src/bottom_pane/textarea/vim.rs | 320 +++++++++++++++ codex-rs/tui/src/keymap.rs | 366 +++++++++++++++++- codex-rs/tui/src/keymap_setup/actions.rs | 36 ++ codex-rs/tui/src/keymap_setup/picker.rs | 2 +- ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 12 files changed, 1148 insertions(+), 81 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/textarea/vim.rs diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 5848d1f444..e41ec860ee 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -233,6 +233,8 @@ pub struct TuiVimNormalKeymap { pub start_delete_operator: Option, /// Begin yank operator; next key selects motion (`y`). pub start_yank_operator: Option, + /// Begin change operator; next keys select a text object. + pub start_change_operator: Option, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option, } @@ -268,10 +270,39 @@ pub struct TuiVimOperatorKeymap { pub motion_line_start: Option, /// Motion: to end of line (`$`). pub motion_line_end: Option, + /// Select an inner text object after an operator. + pub select_inner_text_object: Option, + /// Select an around text object after an operator. + pub select_around_text_object: Option, /// Cancel the pending operator and return to normal mode. pub cancel: Option, } +/// Vim text-object keybindings for modal editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimTextObjectKeymap { + /// Text object: word. + pub word: Option, + /// Text object: whitespace-delimited WORD. + pub big_word: Option, + /// Text object: parentheses. + pub parentheses: Option, + /// Text object: brackets. + pub brackets: Option, + /// Text object: braces. + pub braces: Option, + /// Text object: double quotes. + pub double_quote: Option, + /// Text object: single quotes. + pub single_quote: Option, + /// Text object: backticks. + pub backtick: Option, + /// Cancel the pending text-object command. + pub cancel: Option, +} + /// Pager context keybindings for transcript and static overlays. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] @@ -376,6 +407,8 @@ pub struct TuiKeymap { #[serde(default)] pub vim_operator: TuiVimOperatorKeymap, #[serde(default)] + pub vim_text_object: TuiVimTextObjectKeymap, + #[serde(default)] pub pager: TuiPagerKeymap, #[serde(default)] pub list: TuiListKeymap, @@ -562,6 +595,20 @@ mod tests { ); } + #[test] + fn misspelled_vim_text_object_action_is_rejected() { + let toml_input = r#" + [vim_text_object] + double_quotes = "shift-quote" + "#; + let err = toml::from_str::(toml_input) + .expect_err("expected unknown vim text object action"); + assert!( + err.to_string().contains("double_quotes"), + "expected error to mention misspelled field, got: {err}" + ); + } + #[test] fn removed_backtrack_actions_are_rejected() { for (context, action) in [ diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 06b7c56d5b..f8ffe9c832 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2851,6 +2851,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "yank_line": null @@ -2867,7 +2868,20 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null + }, + "vim_text_object": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null } }, "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." @@ -3518,6 +3532,7 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, "yank_line": null @@ -3541,8 +3556,28 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null } + }, + "vim_text_object": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimTextObjectKeymap" + } + ], + "default": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null + } } }, "type": "object" @@ -3903,6 +3938,14 @@ ], "description": "Paste after cursor (`p`)." }, + "start_change_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin change operator; next keys select a text object." + }, "start_delete_operator": { "allOf": [ { @@ -4022,6 +4065,22 @@ ], "description": "Motion: to start of next word (`w`)." }, + "select_around_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an around text object after an operator." + }, + "select_inner_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an inner text object after an operator." + }, "yank_line": { "allOf": [ { @@ -4033,6 +4092,85 @@ }, "type": "object" }, + "TuiVimTextObjectKeymap": { + "additionalProperties": false, + "description": "Vim text-object keybindings for modal editing inside text areas.", + "properties": { + "backtick": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: backticks." + }, + "big_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: whitespace-delimited WORD." + }, + "braces": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: braces." + }, + "brackets": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: brackets." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel the pending text-object command." + }, + "double_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: double quotes." + }, + "parentheses": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: parentheses." + }, + "single_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: single quotes." + }, + "word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: word." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index d931b14b2c..39da5752c2 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -16,6 +16,7 @@ use crate::keymap::EditorKeymap; use crate::keymap::RuntimeKeymap; use crate::keymap::VimNormalKeymap; use crate::keymap::VimOperatorKeymap; +use crate::keymap::VimTextObjectKeymap; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement as UserTextElement; use crossterm::event::KeyCode; @@ -35,6 +36,13 @@ use textwrap::Options; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +mod vim; +use self::vim::VimMode; +use self::vim::VimMotion; +use self::vim::VimOperator; +use self::vim::VimPending; +use self::vim::VimTextObjectScope; + const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; fn is_word_separator(ch: char) -> bool { @@ -101,10 +109,11 @@ pub(crate) struct TextArea { kill_buffer_kind: KillBufferKind, vim_enabled: bool, vim_mode: VimMode, - vim_operator: Option, + vim_pending: VimPending, editor_keymap: EditorKeymap, vim_normal_keymap: VimNormalKeymap, vim_operator_keymap: VimOperatorKeymap, + vim_text_object_keymap: VimTextObjectKeymap, } #[derive(Debug, Clone)] @@ -119,14 +128,6 @@ pub(crate) struct TextAreaState { scroll: u16, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimMode { - /// Normal mode routes printable keys to movement, operators, and mode transitions. - Normal, - /// Insert mode routes input through the regular editor keymap until Escape is pressed. - Insert, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum KillBufferKind { /// Characterwise kills and yanks paste at the cursor. @@ -135,36 +136,6 @@ enum KillBufferKind { Linewise, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimOperator { - /// Delete the range selected by the next motion or repeated operator key. - Delete, - /// Copy the range selected by the next motion or repeated operator key. - Yank, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum VimMotion { - /// Move one atomic boundary to the left. - Left, - /// Move one atomic boundary to the right. - Right, - /// Move one visual row up, preserving preferred display column. - Up, - /// Move one visual row down, preserving preferred display column. - Down, - /// Move to the start of the next word-like run. - WordForward, - /// Move to the start of the previous word-like run. - WordBackward, - /// Move to the end of the current or next word-like run. - WordEnd, - /// Move to the start of the current line. - LineStart, - /// Move to the end of the current line. - LineEnd, -} - impl TextArea { pub fn new() -> Self { let defaults = RuntimeKeymap::defaults(); @@ -179,10 +150,11 @@ impl TextArea { kill_buffer_kind: KillBufferKind::Characterwise, vim_enabled: false, vim_mode: VimMode::Insert, - vim_operator: None, + vim_pending: VimPending::None, editor_keymap: defaults.editor, vim_normal_keymap: defaults.vim_normal, vim_operator_keymap: defaults.vim_operator, + vim_text_object_keymap: defaults.vim_text_object, } } @@ -196,6 +168,7 @@ impl TextArea { self.editor_keymap = keymap.editor.clone(); self.vim_normal_keymap = keymap.vim_normal.clone(); self.vim_operator_keymap = keymap.vim_operator.clone(); + self.vim_text_object_keymap = keymap.vim_text_object.clone(); } /// Replace the visible textarea text and clear any existing text elements. @@ -257,7 +230,7 @@ impl TextArea { /// an old `d` or `y` command. pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { self.vim_enabled = enabled; - self.vim_operator = None; + self.vim_pending = VimPending::None; self.vim_mode = if enabled { VimMode::Normal } else { @@ -293,7 +266,7 @@ impl TextArea { /// This is observable so the composer can avoid stealing the second key of /// `d{motion}` or `y{motion}` for higher-level shortcuts. pub(crate) fn is_vim_operator_pending(&self) -> bool { - self.vim_operator.is_some() + !matches!(self.vim_pending, VimPending::None) } /// Enter Vim insert mode if modal editing is enabled. @@ -304,7 +277,7 @@ impl TextArea { pub(crate) fn enter_vim_insert_mode(&mut self) { if self.vim_enabled { self.vim_mode = VimMode::Insert; - self.vim_operator = None; + self.vim_pending = VimPending::None; } } @@ -317,7 +290,7 @@ impl TextArea { pub(crate) fn enter_vim_normal_mode(&mut self) { if self.vim_enabled { self.vim_mode = VimMode::Normal; - self.vim_operator = None; + self.vim_pending = VimPending::None; self.preferred_col = None; } } @@ -669,9 +642,17 @@ impl TextArea { } fn handle_vim_normal(&mut self, event: KeyEvent) { - if let Some(op) = self.vim_operator.take() { - self.handle_vim_operator(op, event); - return; + let pending = std::mem::replace(&mut self.vim_pending, VimPending::None); + match pending { + VimPending::None => {} + VimPending::Operator(op) => { + self.handle_vim_operator(op, event); + return; + } + VimPending::TextObject { operator, scope } => { + self.handle_vim_text_object(operator, scope, event); + return; + } } if self.vim_normal_keymap.enter_insert.is_pressed(event) { @@ -776,15 +757,23 @@ impl TextArea { .start_delete_operator .is_pressed(event) { - self.vim_operator = Some(VimOperator::Delete); + self.vim_pending = VimPending::Operator(VimOperator::Delete); return; } if self.vim_normal_keymap.start_yank_operator.is_pressed(event) { - self.vim_operator = Some(VimOperator::Yank); + self.vim_pending = VimPending::Operator(VimOperator::Yank); + return; + } + if self + .vim_normal_keymap + .start_change_operator + .is_pressed(event) + { + self.vim_pending = VimPending::Operator(VimOperator::Change); return; } if self.vim_normal_keymap.cancel_operator.is_pressed(event) { - self.vim_operator = None; + self.vim_pending = VimPending::None; } } @@ -800,14 +789,41 @@ impl TextArea { if self.vim_operator_keymap.cancel.is_pressed(event) { return true; } + if let Some(scope) = self.vim_text_object_scope_for_event(event) { + self.vim_pending = VimPending::TextObject { + operator: op, + scope, + }; + return true; + } - if let Some(motion) = self.vim_motion_for_event(event) { + if op != VimOperator::Change + && let Some(motion) = self.vim_motion_for_event(event) + { self.apply_vim_operator(op, motion); return true; } false } + fn handle_vim_text_object( + &mut self, + op: VimOperator, + scope: VimTextObjectScope, + event: KeyEvent, + ) -> bool { + if self.vim_text_object_keymap.cancel.is_pressed(event) { + return true; + } + let Some(object) = self.vim_text_object_for_event(event) else { + return false; + }; + if let Some(range) = self.text_object_range(object, scope) { + self.apply_vim_operator_to_range(op, range); + } + true + } + fn vim_motion_for_event(&self, event: KeyEvent) -> Option { if self.vim_operator_keymap.motion_left.is_pressed(event) { return Some(VimMotion::Left); @@ -854,6 +870,18 @@ impl TextArea { match op { VimOperator::Delete => self.kill_range(range), VimOperator::Yank => self.yank_range(range), + VimOperator::Change => {} + } + } + + fn apply_vim_operator_to_range(&mut self, op: VimOperator, range: Range) { + match op { + VimOperator::Delete => self.kill_range(range), + VimOperator::Yank => self.yank_range(range), + VimOperator::Change => { + self.kill_range(range); + self.vim_mode = VimMode::Insert; + } } } @@ -2232,7 +2260,7 @@ mod tests { let mut t = TextArea::new(); t.set_vim_enabled(/*enabled*/ true); - t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(t.vim_mode_label(), Some("Normal")); @@ -2399,6 +2427,180 @@ mod tests { assert_eq!(t.kill_buffer, "hello "); } + #[test] + fn vim_change_inner_word_deletes_word_and_enters_insert() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ "hello ".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello "); + assert_eq!(t.kill_buffer, "world"); + assert_eq!(t.cursor(), "hello ".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_word_text_objects_cover_delete_yank_and_big_word() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, "hello "); + assert_eq!(t.vim_mode_label(), Some("Normal")); + + let mut t = ta_with("foo.bar/baz qux"); + t.set_cursor(/*pos*/ "foo.".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE)); + + assert_eq!(t.text(), " qux"); + assert_eq!(t.kill_buffer, "foo.bar/baz"); + } + + #[test] + fn vim_word_text_objects_accept_cursor_at_word_end() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ "hello".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "world"); + assert_eq!(t.kill_buffer, "hello "); + + let mut t = ta_with("foo bar"); + t.set_cursor(t.text().len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "foo "); + assert_eq!(t.kill_buffer, "bar"); + assert_eq!(t.cursor(), "foo ".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_delimiter_text_objects_select_innermost_pair_and_aliases() { + let mut t = ta_with("a(b(c)d)e"); + t.set_cursor(/*pos*/ "a(b(".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a(b()d)e"); + assert_eq!(t.kill_buffer, "c"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("a [b] c"); + t.set_cursor(/*pos*/ "a [".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a c"); + assert_eq!(t.kill_buffer, "[b]"); + } + + #[test] + fn vim_empty_inner_text_objects_are_valid_targets() { + let mut t = ta_with("call()"); + t.set_cursor(/*pos*/ "call(".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('('), KeyModifiers::NONE)); + + assert_eq!(t.text(), "call()"); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.cursor(), "call(".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with(r#"say "" now"#); + t.set_cursor(/*pos*/ r#"say ""#.len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE)); + + assert_eq!(t.text(), r#"say "" now"#); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.cursor(), r#"say ""#.len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_quote_text_objects_are_line_local_and_handle_escapes() { + let mut t = ta_with(r#"say "a \"b\" c" now"#); + t.set_cursor(/*pos*/ r#"say "a \"#.len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::SHIFT)); + + assert_eq!(t.text(), r#"say "" now"#); + assert_eq!(t.kill_buffer, r#"a \"b\" c"#); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + let mut t = ta_with("one \"two\nthree\" four"); + t.set_cursor(/*pos*/ "one \"two\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one \"two\nthree\" four"); + assert_eq!(t.kill_buffer, ""); + } + + #[test] + fn vim_text_object_cancellation_and_unsupported_change_motions_do_not_edit() { + let mut t = ta_with("hello world"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('$'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, ""); + assert_eq!(t.vim_mode_label(), Some("Normal")); + assert!(!t.is_vim_operator_pending()); + + t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert!(t.is_vim_operator_pending()); + t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(t.text(), "hello world"); + assert_eq!(t.kill_buffer, ""); + assert!(!t.is_vim_operator_pending()); + } + #[test] fn vim_operator_invalid_motion_is_consumed() { let mut t = ta_with("hello"); @@ -2408,7 +2610,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert!(t.is_vim_operator_pending()); - t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + t.input(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); assert_eq!(t.text(), "hello"); assert_eq!(t.vim_mode_label(), Some("Normal")); diff --git a/codex-rs/tui/src/bottom_pane/textarea/vim.rs b/codex-rs/tui/src/bottom_pane/textarea/vim.rs new file mode 100644 index 0000000000..2c1edc3fe7 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/textarea/vim.rs @@ -0,0 +1,320 @@ +use super::TextArea; +use super::split_word_pieces; +use crate::key_hint::KeyBindingListExt; +use crossterm::event::KeyEvent; +use std::ops::Range; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMode { + /// Normal mode routes printable keys to movement, operators, and mode transitions. + Normal, + /// Insert mode routes input through the regular editor keymap until Escape is pressed. + Insert, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimOperator { + Delete, + Yank, + Change, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimPending { + None, + Operator(VimOperator), + TextObject { + operator: VimOperator, + scope: VimTextObjectScope, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimMotion { + Left, + Right, + Up, + Down, + WordForward, + WordBackward, + WordEnd, + LineStart, + LineEnd, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObjectScope { + Inner, + Around, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum VimTextObject { + Word, + BigWord, + Parentheses, + Brackets, + Braces, + DoubleQuote, + SingleQuote, + Backtick, +} + +impl TextArea { + pub(super) fn vim_text_object_scope_for_event( + &self, + event: KeyEvent, + ) -> Option { + if self + .vim_operator_keymap + .select_inner_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Inner); + } + if self + .vim_operator_keymap + .select_around_text_object + .is_pressed(event) + { + return Some(VimTextObjectScope::Around); + } + None + } + + pub(super) fn vim_text_object_for_event(&self, event: KeyEvent) -> Option { + if self.vim_text_object_keymap.word.is_pressed(event) { + return Some(VimTextObject::Word); + } + if self.vim_text_object_keymap.big_word.is_pressed(event) { + return Some(VimTextObject::BigWord); + } + if self.vim_text_object_keymap.parentheses.is_pressed(event) { + return Some(VimTextObject::Parentheses); + } + if self.vim_text_object_keymap.brackets.is_pressed(event) { + return Some(VimTextObject::Brackets); + } + if self.vim_text_object_keymap.braces.is_pressed(event) { + return Some(VimTextObject::Braces); + } + if self.vim_text_object_keymap.double_quote.is_pressed(event) { + return Some(VimTextObject::DoubleQuote); + } + if self.vim_text_object_keymap.single_quote.is_pressed(event) { + return Some(VimTextObject::SingleQuote); + } + if self.vim_text_object_keymap.backtick.is_pressed(event) { + return Some(VimTextObject::Backtick); + } + None + } + + pub(super) fn text_object_range( + &self, + object: VimTextObject, + scope: VimTextObjectScope, + ) -> Option> { + match object { + VimTextObject::Word => self.word_text_object_range(scope, /*big_word*/ false), + VimTextObject::BigWord => self.word_text_object_range(scope, /*big_word*/ true), + VimTextObject::Parentheses => self.paired_text_object_range(scope, '(', ')'), + VimTextObject::Brackets => self.paired_text_object_range(scope, '[', ']'), + VimTextObject::Braces => self.paired_text_object_range(scope, '{', '}'), + VimTextObject::DoubleQuote => self.quoted_text_object_range(scope, '"'), + VimTextObject::SingleQuote => self.quoted_text_object_range(scope, '\''), + VimTextObject::Backtick => self.quoted_text_object_range(scope, '`'), + } + } + + fn word_text_object_range( + &self, + scope: VimTextObjectScope, + big_word: bool, + ) -> Option> { + let inner = if big_word { + self.big_word_range_at_cursor()? + } else { + self.small_word_range_at_cursor()? + }; + Some(match scope { + VimTextObjectScope::Inner => inner, + VimTextObjectScope::Around => self.expand_word_around(inner), + }) + } + + fn big_word_range_at_cursor(&self) -> Option> { + self.non_ws_runs() + .into_iter() + .find(|range| self.cursor_overlaps_range(range) || self.cursor_is_at_range_end(range)) + } + + fn small_word_range_at_cursor(&self) -> Option> { + for run in self.non_ws_runs() { + if !self.cursor_overlaps_range(&run) && !self.cursor_is_at_range_end(&run) { + continue; + } + let mut last_piece = None; + for (piece_start, piece) in split_word_pieces(&self.text[run.clone()]) { + let piece = run.start + piece_start..run.start + piece_start + piece.len(); + if self.cursor_overlaps_range(&piece) { + return Some(piece); + } + last_piece = Some(piece); + } + if self.cursor_is_at_range_end(&run) { + return last_piece.or(Some(run)); + } + return Some(run); + } + None + } + + fn non_ws_runs(&self) -> Vec> { + let mut runs = Vec::new(); + let mut start = None; + for (idx, ch) in self.text.char_indices() { + if ch.is_whitespace() { + if let Some(run_start) = start.take() { + runs.push(run_start..idx); + } + } else if start.is_none() { + start = Some(idx); + } + } + if let Some(run_start) = start { + runs.push(run_start..self.text.len()); + } + runs + } + + fn cursor_overlaps_range(&self, range: &Range) -> bool { + range.start <= self.cursor_pos && self.cursor_pos < range.end + } + + fn cursor_is_at_range_end(&self, range: &Range) -> bool { + range.start < range.end && self.cursor_pos == range.end + } + + fn expand_word_around(&self, inner: Range) -> Range { + let following = self.following_whitespace_end(inner.end); + if following > inner.end { + return inner.start..following; + } + self.preceding_whitespace_start(inner.start)..inner.end + } + + fn following_whitespace_end(&self, start: usize) -> usize { + let mut end = start; + for (offset, ch) in self.text[start..].char_indices() { + if !ch.is_whitespace() { + break; + } + end = start + offset + ch.len_utf8(); + } + end + } + + fn preceding_whitespace_start(&self, end: usize) -> usize { + let mut start = end; + for (idx, ch) in self.text[..end].char_indices().rev() { + if !ch.is_whitespace() { + break; + } + start = idx; + } + start + } + + fn paired_text_object_range( + &self, + scope: VimTextObjectScope, + open: char, + close: char, + ) -> Option> { + let mut stack: Vec = Vec::new(); + let mut best: Option> = None; + for (idx, ch) in self.text.char_indices() { + if self.is_inside_element(idx) { + continue; + } + if ch == open { + stack.push(idx); + } else if ch == close { + let Some(open_idx) = stack.pop() else { + continue; + }; + let close_end = idx + ch.len_utf8(); + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + open.len_utf8()..idx, + VimTextObjectScope::Around => open_idx..close_end, + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } + } + best + } + + fn quoted_text_object_range( + &self, + scope: VimTextObjectScope, + quote: char, + ) -> Option> { + let line = self.beginning_of_current_line()..self.end_of_current_line(); + let mut open = None; + let mut best: Option> = None; + for (offset, ch) in self.text[line.clone()].char_indices() { + let idx = line.start + offset; + if self.is_inside_element(idx) || ch != quote || self.is_escaped(idx) { + continue; + } + if let Some(open_idx) = open.take() { + if open_idx <= self.cursor_pos && self.cursor_pos <= idx { + let candidate = match scope { + VimTextObjectScope::Inner => open_idx + quote.len_utf8()..idx, + VimTextObjectScope::Around => idx_range(open_idx, idx, quote), + }; + if candidate.start <= candidate.end + && best + .as_ref() + .is_none_or(|current| candidate.len() < current.len()) + { + best = Some(candidate); + } + } + } else { + open = Some(idx); + } + } + best + } + + fn is_inside_element(&self, pos: usize) -> bool { + self.elements + .iter() + .any(|element| pos >= element.range.start && pos < element.range.end) + } + + fn is_escaped(&self, pos: usize) -> bool { + let mut backslashes = 0; + for ch in self.text[..pos].chars().rev() { + if ch != '\\' { + break; + } + backslashes += 1; + } + backslashes % 2 == 1 + } +} + +fn idx_range(open_idx: usize, close_idx: usize, quote: char) -> Range { + open_idx..close_idx + quote.len_utf8() +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index bc54224c40..8e6d40c764 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -46,6 +46,7 @@ pub(crate) struct RuntimeKeymap { pub(crate) editor: EditorKeymap, pub(crate) vim_normal: VimNormalKeymap, pub(crate) vim_operator: VimOperatorKeymap, + pub(crate) vim_text_object: VimTextObjectKeymap, pub(crate) pager: PagerKeymap, pub(crate) list: ListKeymap, pub(crate) approval: ApprovalKeymap, @@ -161,6 +162,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, pub(crate) start_yank_operator: Vec, + pub(crate) start_change_operator: Vec, pub(crate) cancel_operator: Vec, } @@ -183,6 +185,22 @@ pub(crate) struct VimOperatorKeymap { pub(crate) motion_word_end: Vec, pub(crate) motion_line_start: Vec, pub(crate) motion_line_end: Vec, + pub(crate) select_inner_text_object: Vec, + pub(crate) select_around_text_object: Vec, + pub(crate) cancel: Vec, +} + +/// Vim text-object keybindings active after an operator plus inner/around prefix. +#[derive(Clone, Debug, Default)] +pub(crate) struct VimTextObjectKeymap { + pub(crate) word: Vec, + pub(crate) big_word: Vec, + pub(crate) parentheses: Vec, + pub(crate) brackets: Vec, + pub(crate) braces: Vec, + pub(crate) double_quote: Vec, + pub(crate) single_quote: Vec, + pub(crate) backtick: Vec, pub(crate) cancel: Vec, } @@ -452,7 +470,7 @@ impl RuntimeKeymap { yank: resolve_local!(keymap, defaults, editor, yank), }; - let vim_normal = VimNormalKeymap { + let mut vim_normal = VimNormalKeymap { enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert), append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor), append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end), @@ -480,10 +498,113 @@ impl RuntimeKeymap { start_delete_operator ), start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator), + start_change_operator: resolve_local!( + keymap, + defaults, + vim_normal, + start_change_operator + ), cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator), }; - let vim_operator = VimOperatorKeymap { + let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_normal.enter_insert.as_ref(), + vim_normal.enter_insert.as_slice(), + ), + ( + keymap.vim_normal.append_after_cursor.as_ref(), + vim_normal.append_after_cursor.as_slice(), + ), + ( + keymap.vim_normal.append_line_end.as_ref(), + vim_normal.append_line_end.as_slice(), + ), + ( + keymap.vim_normal.insert_line_start.as_ref(), + vim_normal.insert_line_start.as_slice(), + ), + ( + keymap.vim_normal.open_line_below.as_ref(), + vim_normal.open_line_below.as_slice(), + ), + ( + keymap.vim_normal.open_line_above.as_ref(), + vim_normal.open_line_above.as_slice(), + ), + ( + keymap.vim_normal.move_left.as_ref(), + vim_normal.move_left.as_slice(), + ), + ( + keymap.vim_normal.move_right.as_ref(), + vim_normal.move_right.as_slice(), + ), + ( + keymap.vim_normal.move_up.as_ref(), + vim_normal.move_up.as_slice(), + ), + ( + keymap.vim_normal.move_down.as_ref(), + vim_normal.move_down.as_slice(), + ), + ( + keymap.vim_normal.move_word_forward.as_ref(), + vim_normal.move_word_forward.as_slice(), + ), + ( + keymap.vim_normal.move_word_backward.as_ref(), + vim_normal.move_word_backward.as_slice(), + ), + ( + keymap.vim_normal.move_word_end.as_ref(), + vim_normal.move_word_end.as_slice(), + ), + ( + keymap.vim_normal.move_line_start.as_ref(), + vim_normal.move_line_start.as_slice(), + ), + ( + keymap.vim_normal.move_line_end.as_ref(), + vim_normal.move_line_end.as_slice(), + ), + ( + keymap.vim_normal.delete_char.as_ref(), + vim_normal.delete_char.as_slice(), + ), + ( + keymap.vim_normal.delete_to_line_end.as_ref(), + vim_normal.delete_to_line_end.as_slice(), + ), + ( + keymap.vim_normal.yank_line.as_ref(), + vim_normal.yank_line.as_slice(), + ), + ( + keymap.vim_normal.paste_after.as_ref(), + vim_normal.paste_after.as_slice(), + ), + ( + keymap.vim_normal.start_delete_operator.as_ref(), + vim_normal.start_delete_operator.as_slice(), + ), + ( + keymap.vim_normal.start_yank_operator.as_ref(), + vim_normal.start_yank_operator.as_slice(), + ), + ( + keymap.vim_normal.cancel_operator.as_ref(), + vim_normal.cancel_operator.as_slice(), + ), + ]); + + if keymap.vim_normal.start_change_operator.is_none() { + vim_normal + .start_change_operator + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } + + let mut vim_operator = VimOperatorKeymap { delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line), motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left), @@ -505,9 +626,95 @@ impl RuntimeKeymap { motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end), motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start), motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end), + select_inner_text_object: resolve_local!( + keymap, + defaults, + vim_operator, + select_inner_text_object + ), + select_around_text_object: resolve_local!( + keymap, + defaults, + vim_operator, + select_around_text_object + ), cancel: resolve_local!(keymap, defaults, vim_operator, cancel), }; + let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([ + ( + keymap.vim_operator.delete_line.as_ref(), + vim_operator.delete_line.as_slice(), + ), + ( + keymap.vim_operator.yank_line.as_ref(), + vim_operator.yank_line.as_slice(), + ), + ( + keymap.vim_operator.motion_left.as_ref(), + vim_operator.motion_left.as_slice(), + ), + ( + keymap.vim_operator.motion_right.as_ref(), + vim_operator.motion_right.as_slice(), + ), + ( + keymap.vim_operator.motion_up.as_ref(), + vim_operator.motion_up.as_slice(), + ), + ( + keymap.vim_operator.motion_down.as_ref(), + vim_operator.motion_down.as_slice(), + ), + ( + keymap.vim_operator.motion_word_forward.as_ref(), + vim_operator.motion_word_forward.as_slice(), + ), + ( + keymap.vim_operator.motion_word_backward.as_ref(), + vim_operator.motion_word_backward.as_slice(), + ), + ( + keymap.vim_operator.motion_word_end.as_ref(), + vim_operator.motion_word_end.as_slice(), + ), + ( + keymap.vim_operator.motion_line_start.as_ref(), + vim_operator.motion_line_start.as_slice(), + ), + ( + keymap.vim_operator.motion_line_end.as_ref(), + vim_operator.motion_line_end.as_slice(), + ), + ( + keymap.vim_operator.cancel.as_ref(), + vim_operator.cancel.as_slice(), + ), + ]); + + if keymap.vim_operator.select_inner_text_object.is_none() { + vim_operator + .select_inner_text_object + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + if keymap.vim_operator.select_around_text_object.is_none() { + vim_operator + .select_around_text_object + .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); + } + + let vim_text_object = VimTextObjectKeymap { + word: resolve_local!(keymap, defaults, vim_text_object, word), + big_word: resolve_local!(keymap, defaults, vim_text_object, big_word), + parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses), + brackets: resolve_local!(keymap, defaults, vim_text_object, brackets), + braces: resolve_local!(keymap, defaults, vim_text_object, braces), + double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote), + single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote), + backtick: resolve_local!(keymap, defaults, vim_text_object, backtick), + cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), + }; + let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), @@ -536,8 +743,7 @@ impl RuntimeKeymap { let list_move_down = resolve_local!(keymap, defaults, list, move_down); let list_accept = resolve_local!(keymap, defaults, list, accept); let list_cancel = resolve_local!(keymap, defaults, list, cancel); - let mut configured_bindings_to_preserve = Vec::new(); - for (configured, resolved) in [ + let configured_bindings_to_preserve = configured_bindings_to_preserve([ ( keymap.global.open_transcript.as_ref(), app.open_transcript.as_slice(), @@ -593,51 +799,42 @@ impl RuntimeKeymap { approval.decline.as_slice(), ), (keymap.approval.cancel.as_ref(), approval.cancel.as_slice()), - ] { - if configured.is_none() { - continue; - } - for binding in resolved { - if !configured_bindings_to_preserve.contains(binding) { - configured_bindings_to_preserve.push(*binding); - } - } - } + ]); let list = ListKeymap { move_up: list_move_up, move_down: list_move_down, - move_left: resolve_new_list_bindings( + move_left: resolve_new_default_bindings( keymap.list.move_left.as_ref(), &defaults.list.move_left, &configured_bindings_to_preserve, "tui.keymap.list.move_left", )?, - move_right: resolve_new_list_bindings( + move_right: resolve_new_default_bindings( keymap.list.move_right.as_ref(), &defaults.list.move_right, &configured_bindings_to_preserve, "tui.keymap.list.move_right", )?, - page_up: resolve_new_list_bindings( + page_up: resolve_new_default_bindings( keymap.list.page_up.as_ref(), &defaults.list.page_up, &configured_bindings_to_preserve, "tui.keymap.list.page_up", )?, - page_down: resolve_new_list_bindings( + page_down: resolve_new_default_bindings( keymap.list.page_down.as_ref(), &defaults.list.page_down, &configured_bindings_to_preserve, "tui.keymap.list.page_down", )?, - jump_top: resolve_new_list_bindings( + jump_top: resolve_new_default_bindings( keymap.list.jump_top.as_ref(), &defaults.list.jump_top, &configured_bindings_to_preserve, "tui.keymap.list.jump_top", )?, - jump_bottom: resolve_new_list_bindings( + jump_bottom: resolve_new_default_bindings( keymap.list.jump_bottom.as_ref(), &defaults.list.jump_bottom, &configured_bindings_to_preserve, @@ -654,6 +851,7 @@ impl RuntimeKeymap { editor, vim_normal, vim_operator, + vim_text_object, pager, list, approval, @@ -796,6 +994,7 @@ impl RuntimeKeymap { paste_after: default_bindings![plain(KeyCode::Char('p'))], start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], start_yank_operator: default_bindings![plain(KeyCode::Char('y'))], + start_change_operator: default_bindings![plain(KeyCode::Char('c'))], cancel_operator: default_bindings![plain(KeyCode::Esc)], }, vim_operator: VimOperatorKeymap { @@ -813,6 +1012,35 @@ impl RuntimeKeymap { plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], + select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))], + select_around_text_object: default_bindings![plain(KeyCode::Char('a'))], + cancel: default_bindings![plain(KeyCode::Esc)], + }, + vim_text_object: VimTextObjectKeymap { + word: default_bindings![plain(KeyCode::Char('w'))], + big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))], + parentheses: default_bindings![ + plain(KeyCode::Char('(')), + shift(KeyCode::Char('(')), + plain(KeyCode::Char(')')), + shift(KeyCode::Char(')')), + plain(KeyCode::Char('b')) + ], + brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))], + braces: default_bindings![ + plain(KeyCode::Char('{')), + shift(KeyCode::Char('{')), + plain(KeyCode::Char('}')), + shift(KeyCode::Char('}')), + shift(KeyCode::Char('b')), + plain(KeyCode::Char('B')) + ], + double_quote: default_bindings![ + plain(KeyCode::Char('"')), + shift(KeyCode::Char('"')) + ], + single_quote: default_bindings![plain(KeyCode::Char('\''))], + backtick: default_bindings![plain(KeyCode::Char('`'))], cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { @@ -1196,6 +1424,10 @@ impl RuntimeKeymap { "start_yank_operator", self.vim_normal.start_yank_operator.as_slice(), ), + ( + "start_change_operator", + self.vim_normal.start_change_operator.as_slice(), + ), ( "cancel_operator", self.vim_normal.cancel_operator.as_slice(), @@ -1232,10 +1464,33 @@ impl RuntimeKeymap { "motion_line_end", self.vim_operator.motion_line_end.as_slice(), ), + ( + "select_inner_text_object", + self.vim_operator.select_inner_text_object.as_slice(), + ), + ( + "select_around_text_object", + self.vim_operator.select_around_text_object.as_slice(), + ), ("cancel", self.vim_operator.cancel.as_slice()), ], )?; + validate_unique( + "vim_text_object", + [ + ("word", self.vim_text_object.word.as_slice()), + ("big_word", self.vim_text_object.big_word.as_slice()), + ("parentheses", self.vim_text_object.parentheses.as_slice()), + ("brackets", self.vim_text_object.brackets.as_slice()), + ("braces", self.vim_text_object.braces.as_slice()), + ("double_quote", self.vim_text_object.double_quote.as_slice()), + ("single_quote", self.vim_text_object.single_quote.as_slice()), + ("backtick", self.vim_text_object.backtick.as_slice()), + ("cancel", self.vim_text_object.cancel.as_slice()), + ], + )?; + validate_unique( "pager", [ @@ -1524,7 +1779,24 @@ fn resolve_bindings( parse_bindings(spec, path) } -fn resolve_new_list_bindings( +fn configured_bindings_to_preserve( + pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N], +) -> Vec { + let mut configured_bindings = Vec::new(); + for (configured, resolved) in pairs { + if configured.is_none() { + continue; + } + for binding in resolved { + if !configured_bindings.contains(binding) { + configured_bindings.push(*binding); + } + } + } + configured_bindings +} + +fn resolve_new_default_bindings( configured: Option<&KeybindingsSpec>, fallback: &[KeyBinding], configured_bindings_to_preserve: &[KeyBinding], @@ -1973,6 +2245,58 @@ mod tests { expect_conflict(&keymap, "list.jump_top", "approval.approve"); } + #[test] + fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("c")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_normal.move_left, + vec![key_hint::plain(KeyCode::Char('c'))] + ); + assert_eq!(runtime.vim_normal.start_change_operator, Vec::new()); + } + + #[test] + fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("c")); + keymap.vim_normal.start_change_operator = Some(one("c")); + + expect_conflict(&keymap, "move_left", "start_change_operator"); + } + + #[test] + fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { + let mut keymap = TuiKeymap::default(); + keymap.vim_operator.motion_left = Some(one("i")); + keymap.vim_operator.motion_right = Some(one("a")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_operator.motion_left, + vec![key_hint::plain(KeyCode::Char('i'))] + ); + assert_eq!( + runtime.vim_operator.motion_right, + vec![key_hint::plain(KeyCode::Char('a'))] + ); + assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new()); + assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new()); + } + + #[test] + fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_operator.motion_left = Some(one("i")); + keymap.vim_operator.select_inner_text_object = Some(one("i")); + + expect_conflict(&keymap, "motion_left", "select_inner_text_object"); + } + #[test] fn vim_normal_defaults_include_insert_and_arrow_aliases() { let runtime = RuntimeKeymap::defaults(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index fb3ecd9f2c..c8815c8c98 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -140,6 +140,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "paste_after", "Paste after the cursor."), action("vim_normal", "Vim normal", "start_delete_operator", "Begin a delete operator and wait for a motion."), action("vim_normal", "Vim normal", "start_yank_operator", "Begin a yank operator and wait for a motion."), + action("vim_normal", "Vim normal", "start_change_operator", "Begin a change operator and wait for a text object."), action("vim_normal", "Vim normal", "cancel_operator", "Cancel a pending Vim operator."), action("vim_operator", "Vim operator", "delete_line", "Repeat delete operator to delete the whole line."), action("vim_operator", "Vim operator", "yank_line", "Repeat yank operator to yank the whole line."), @@ -152,7 +153,18 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_operator", "Vim operator", "motion_word_end", "Operator motion to end of word."), action("vim_operator", "Vim operator", "motion_line_start", "Operator motion to line start."), action("vim_operator", "Vim operator", "motion_line_end", "Operator motion to line end."), + action("vim_operator", "Vim operator", "select_inner_text_object", "Select an inner text object."), + action("vim_operator", "Vim operator", "select_around_text_object", "Select an around text object."), action("vim_operator", "Vim operator", "cancel", "Cancel the pending operator."), + action("vim_text_object", "Vim text object", "word", "Target the current word."), + action("vim_text_object", "Vim text object", "big_word", "Target the current WORD."), + action("vim_text_object", "Vim text object", "parentheses", "Target enclosing parentheses."), + action("vim_text_object", "Vim text object", "brackets", "Target enclosing brackets."), + action("vim_text_object", "Vim text object", "braces", "Target enclosing braces."), + action("vim_text_object", "Vim text object", "double_quote", "Target enclosing double quotes."), + action("vim_text_object", "Vim text object", "single_quote", "Target enclosing single quotes."), + action("vim_text_object", "Vim text object", "backtick", "Target enclosing backticks."), + action("vim_text_object", "Vim text object", "cancel", "Cancel the pending text object."), action("pager", "Pager", "scroll_up", "Scroll up by one row."), action("pager", "Pager", "scroll_down", "Scroll down by one row."), action("pager", "Pager", "page_up", "Scroll up by one page."), @@ -269,6 +281,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "paste_after") => Some(&mut keymap.vim_normal.paste_after), ("vim_normal", "start_delete_operator") => Some(&mut keymap.vim_normal.start_delete_operator), ("vim_normal", "start_yank_operator") => Some(&mut keymap.vim_normal.start_yank_operator), + ("vim_normal", "start_change_operator") => Some(&mut keymap.vim_normal.start_change_operator), ("vim_normal", "cancel_operator") => Some(&mut keymap.vim_normal.cancel_operator), ("vim_operator", "delete_line") => Some(&mut keymap.vim_operator.delete_line), ("vim_operator", "yank_line") => Some(&mut keymap.vim_operator.yank_line), @@ -281,7 +294,18 @@ pub(super) fn binding_slot<'a>( ("vim_operator", "motion_word_end") => Some(&mut keymap.vim_operator.motion_word_end), ("vim_operator", "motion_line_start") => Some(&mut keymap.vim_operator.motion_line_start), ("vim_operator", "motion_line_end") => Some(&mut keymap.vim_operator.motion_line_end), + ("vim_operator", "select_inner_text_object") => Some(&mut keymap.vim_operator.select_inner_text_object), + ("vim_operator", "select_around_text_object") => Some(&mut keymap.vim_operator.select_around_text_object), ("vim_operator", "cancel") => Some(&mut keymap.vim_operator.cancel), + ("vim_text_object", "word") => Some(&mut keymap.vim_text_object.word), + ("vim_text_object", "big_word") => Some(&mut keymap.vim_text_object.big_word), + ("vim_text_object", "parentheses") => Some(&mut keymap.vim_text_object.parentheses), + ("vim_text_object", "brackets") => Some(&mut keymap.vim_text_object.brackets), + ("vim_text_object", "braces") => Some(&mut keymap.vim_text_object.braces), + ("vim_text_object", "double_quote") => Some(&mut keymap.vim_text_object.double_quote), + ("vim_text_object", "single_quote") => Some(&mut keymap.vim_text_object.single_quote), + ("vim_text_object", "backtick") => Some(&mut keymap.vim_text_object.backtick), + ("vim_text_object", "cancel") => Some(&mut keymap.vim_text_object.cancel), ("pager", "scroll_up") => Some(&mut keymap.pager.scroll_up), ("pager", "scroll_down") => Some(&mut keymap.pager.scroll_down), ("pager", "page_up") => Some(&mut keymap.pager.page_up), @@ -380,6 +404,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()), ("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()), ("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()), + ("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()), ("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()), ("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()), ("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()), @@ -392,7 +417,18 @@ pub(super) fn bindings_for_action<'a>( ("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()), ("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()), ("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()), + ("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()), + ("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()), ("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()), + ("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()), + ("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()), + ("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()), + ("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()), + ("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()), + ("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()), + ("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()), + ("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()), + ("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()), ("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()), ("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()), ("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index b6c89d9585..52ec4a4b8b 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -104,7 +104,7 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ id: "vim-shortcuts", label: "Vim", description: "Vim normal-mode and operator shortcuts.", - contexts: &["vim_normal", "vim_operator"], + contexts: &["vim_normal", "vim_operator", "vim_text_object"], }, KeymapContextTab { id: "navigation-shortcuts", diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index bc41e50b24..e212226ef5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 94 actions, 1 customized, 2 unbound. + 106 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index e0c1aa7ffe..22c0537540 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 95 actions, 0 customized, 3 unbound. + 107 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 856ac05d1e..4a88465ba8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,14 +2,14 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (94 selectable) +tab: All (106 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (9 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (35 selectable) +tab: Vim (47 selectable) tab: Navigation (20 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 57b0131002..26af4bd57a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 94 actions, 0 customized, 2 unbound. + 106 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 4d10013649..36f6a1d04c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 94 actions, 0 customized, 2 unbound. + 106 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug From 2d1ad374a7d2469e6bc889d4cdf7bfb58d82b436 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 27 May 2026 15:59:17 -0300 Subject: [PATCH 039/204] feat(tui): make turn interruption keybind configurable (#24766) ## Why Interrupting an active turn is currently fixed to `Esc`, which is easy to hit accidentally and cannot be customized through `/keymap`. This gives users a less accidental binding while preserving the existing default. ## What Changed - Adds `tui.keymap.chat.interrupt_turn` to `/keymap`, defaulting to `esc` and supporting remapping or unbinding. - Uses the configured interrupt binding for running-turn status, queued steer interruption, and `request_user_input`, including the visible hints. - Preserves local `Esc` behavior for popups, Vim insert mode, and `/agent` editing while validating conflicts with fixed/backtrack and request-input navigation bindings. - Adds behavior and snapshot coverage for remapped interruption paths. ## How to Test 1. Run Codex and open `/keymap`, then set **Interrupt Turn** to `f12`. 2. Start a turn and confirm `Esc` no longer interrupts it while `f12` does; the running hint should display `f12 to interrupt`. 3. Queue a steer while a turn is running and confirm the preview displays `f12`; pressing it should interrupt and submit the steer immediately. 4. Trigger a `request_user_input` prompt and confirm its footer uses `f12`; with notes open, `Esc` should still clear notes while `f12` interrupts the turn. 5. Clear the Interrupt Turn binding and confirm the key-specific interrupt hint is removed while `Ctrl+C` remains available. Targeted validation: - `just write-config-schema` - `just fix -p codex-config` - `just fix -p codex-tui` - `just fmt` - `just argument-comment-lint-from-source -p codex-config -p codex-tui` - `just test -p codex-config` - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` - `just test -p codex-tui keymap_setup::tests` - `just test -p codex-tui` (fails in two pre-existing guardian feature-flag tests unrelated to this diff; the intentional picker snapshot updates were reviewed and accepted) --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 14 +++- codex-rs/tui/src/bottom_pane/mod.rs | 45 ++++++++-- .../src/bottom_pane/pending_input_preview.rs | 39 +++++++-- .../src/bottom_pane/request_user_input/mod.rs | 78 +++++++++++++++-- ...ser_input_freeform_remapped_interrupt.snap | 13 +++ ...steer_with_remapped_interrupt_binding.snap | 20 +++++ codex-rs/tui/src/chatwidget/interaction.rs | 3 +- .../chatwidget/tests/composer_submission.rs | 26 ++++++ codex-rs/tui/src/keymap.rs | 83 ++++++++++++++++++- codex-rs/tui/src/keymap_setup.rs | 1 + codex-rs/tui/src/keymap_setup/actions.rs | 3 + codex-rs/tui/src/keymap_setup/picker.rs | 1 + ...__tests__keymap_picker_all_tab_search.snap | 2 +- ...ap_setup__tests__keymap_picker_custom.snap | 4 +- ...ests__keymap_picker_fast_mode_enabled.snap | 4 +- ...p__tests__keymap_picker_first_actions.snap | 8 +- ...ap_setup__tests__keymap_picker_narrow.snap | 4 +- ...ymap_setup__tests__keymap_picker_wide.snap | 4 +- ...ests__renders_remapped_interrupt_hint.snap | 5 ++ codex-rs/tui/src/status_indicator_widget.rs | 35 +++++++- 21 files changed, 352 insertions(+), 42 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index e41ec860ee..e063cac0e8 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -115,6 +115,8 @@ pub struct TuiGlobalKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { + /// Interrupt the active turn. + pub interrupt_turn: Option, /// Decrease the active reasoning effort. pub decrease_reasoning_effort: Option, /// Increase the active reasoning effort. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f8ffe9c832..ab855d733b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2766,7 +2766,8 @@ "chat": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null }, "composer": { "history_search_next": null, @@ -3094,6 +3095,14 @@ } ], "description": "Increase the active reasoning effort." + }, + "interrupt_turn": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Interrupt the active turn." } }, "type": "object" @@ -3405,7 +3414,8 @@ "default": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null } }, "composer": { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 65770b61ae..1fcc7635c0 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -25,7 +25,9 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::key_hint::KeyBindingListExt; use crate::keymap::RuntimeKeymap; +use crate::keymap::primary_binding; use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; @@ -352,6 +354,12 @@ impl BottomPane { pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) { self.keymap = keymap.clone(); self.composer.set_keymap_bindings(keymap); + let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn); + self.pending_input_preview + .set_interrupt_binding(interrupt_binding); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(interrupt_binding); + } self.request_redraw(); } @@ -617,13 +625,12 @@ impl BottomPane { .and_then(parse_slash_name) .is_some_and(|(name, _, _)| name == "agent"); - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. + // If a task is running and a status line is visible, allow the + // configured action to interrupt even while the composer has focus. // When a popup is active, prefer dismissing it over interrupting the task. - if key_event.code == KeyCode::Esc - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + if self.keymap.chat.interrupt_turn.is_pressed(key_event) && self.is_task_running - && !is_agent_command + && !(is_agent_command && key_event.code == KeyCode::Esc) && !self.composer.popup_active() && !self.composer_should_handle_vim_insert_escape(key_event) && let Some(status) = &self.status @@ -989,6 +996,7 @@ impl BottomPane { } if let Some(status) = self.status.as_mut() { status.set_interrupt_hint_visible(/*visible*/ true); + status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); } self.sync_status_inline_message(); self.request_redraw(); @@ -1017,6 +1025,9 @@ impl BottomPane { self.frame_requester.clone(), self.animations_enabled, )); + if let Some(status) = self.status.as_mut() { + status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn)); + } self.sync_status_inline_message(); self.request_redraw(); } @@ -2731,6 +2742,30 @@ mod tests { ); } + #[test] + fn remapped_interrupt_turn_uses_configured_key_including_agent_drafts() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = test_pane(tx); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + pane.set_keymap_bindings(&keymap); + pane.set_task_running(/*running*/ true); + pane.insert_str("/agent "); + + pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!( + rx.try_recv().is_err(), + "expected Esc to remain local after remapping interruption" + ); + + pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + assert!( + matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))), + "expected configured key to interrupt while `/agent` is being edited" + ); + } + #[test] fn selection_view_esc_respects_remapped_list_cancel() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs index 34e7d7d390..96cc1bdfd8 100644 --- a/codex-rs/tui/src/bottom_pane/pending_input_preview.rs +++ b/codex-rs/tui/src/bottom_pane/pending_input_preview.rs @@ -15,7 +15,7 @@ use crate::wrapping::adaptive_wrap_lines; /// The widget renders pending steers first, then rejected steers that will be /// resubmitted at end of turn, then ordinary queued user messages. Pending /// steers explain that they will be submitted after the next tool/result -/// boundary unless the user presses Esc to interrupt and send them +/// boundary unless the user invokes the interrupt binding to send them /// immediately. The edit hint at the bottom only appears when there are actual /// queued user inputs to pop back into the composer. Because some terminals /// intercept certain modifier-key combinations, the displayed binding is @@ -27,6 +27,8 @@ pub(crate) struct PendingInputPreview { /// Key combination rendered in the hint line. Defaults to Alt+Up but may /// be overridden for terminals where that chord is unavailable. edit_binding: Option, + /// Key combination rendered for immediately interrupting and sending steers. + interrupt_binding: Option, } const PREVIEW_LINE_LIMIT: usize = 3; @@ -38,6 +40,7 @@ impl PendingInputPreview { rejected_steers: Vec::new(), queued_messages: Vec::new(), edit_binding: Some(key_hint::alt(KeyCode::Up)), + interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), } } @@ -48,6 +51,10 @@ impl PendingInputPreview { self.edit_binding = binding; } + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + self.interrupt_binding = binding; + } + fn push_truncated_preview_lines( lines: &mut Vec>, wrapped: Vec>, @@ -81,16 +88,15 @@ impl PendingInputPreview { let mut lines = vec![]; if !self.pending_steers.is_empty() { - Self::push_section_header( - &mut lines, - width, - Line::from(vec![ - "Messages to be submitted after next tool call".into(), + let mut header = vec!["Messages to be submitted after next tool call".into()]; + if let Some(interrupt_binding) = self.interrupt_binding { + header.extend(vec![ " (press ".dim(), - key_hint::plain(KeyCode::Esc).into(), + interrupt_binding.into(), " to interrupt and send immediately)".dim(), - ]), - ); + ]); + } + Self::push_section_header(&mut lines, width, Line::from(header)); for steer in &self.pending_steers { let wrapped = adaptive_wrap_lines( @@ -327,6 +333,21 @@ mod tests { assert_snapshot!("render_one_pending_steer", format!("{buf:?}")); } + #[test] + fn render_one_pending_steer_with_remapped_interrupt_binding() { + let mut queue = PendingInputPreview::new(); + queue.pending_steers.push("Please continue.".to_string()); + queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + let width = 48; + let height = queue.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!( + "render_one_pending_steer_with_remapped_interrupt_binding", + format!("{buf:?}") + ); + } + #[test] fn render_pending_steers_above_queued_messages() { let mut queue = PendingInputPreview::new(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 16baedc097..d328d9f1fd 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -143,6 +143,7 @@ pub(crate) struct RequestUserInputOverlay { pending_submission_draft: Option, confirm_unanswered: Option, composer_submit_keys: Vec, + interrupt_turn_keys: Vec, list_keymap: ListKeymap, } @@ -198,6 +199,7 @@ impl RequestUserInputOverlay { pending_submission_draft: None, confirm_unanswered: None, composer_submit_keys: keymap.composer.submit.clone(), + interrupt_turn_keys: keymap.chat.interrupt_turn.clone(), list_keymap: keymap.list, }; overlay.reset_for_request(); @@ -506,8 +508,15 @@ impl RequestUserInputOverlay { tips.push(FooterTip::new("ctrl + p / ctrl + n change question")); } } - if !(self.has_options() && notes_visible) { - tips.push(FooterTip::new("esc to interrupt")); + if let Some(interrupt_key) = self.interrupt_turn_keys.first() + && !(self.has_options() + && notes_visible + && *interrupt_key == crate::key_hint::plain(KeyCode::Esc)) + { + tips.push(FooterTip::new(format!( + "{} to interrupt", + interrupt_key.display_label() + ))); } tips } @@ -1056,11 +1065,12 @@ impl BottomPaneView for RequestUserInputOverlay { return; } - if matches!(key_event.code, KeyCode::Esc) { - if self.has_options() && self.notes_ui_visible() { - self.clear_notes_and_focus_options(); - return; - } + if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() { + self.clear_notes_and_focus_options(); + return; + } + + if self.interrupt_turn_keys.is_pressed(key_event) { // TODO: Emit interrupted request_user_input results (including committed answers) // once core supports persisting them reliably without follow-up turn issues. self.app_event_tx.interrupt(); @@ -2043,6 +2053,40 @@ mod tests { ); } + #[test] + fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() { + let (tx, mut rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let mut overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_with_options("q1", "Pick one")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let answer = overlay.current_answer_mut().expect("answer missing"); + answer.options_state.selected_idx = Some(0); + overlay.handle_key_event(KeyEvent::from(KeyCode::Tab)); + + let tips = overlay.footer_tips(); + let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::>(); + assert_eq!( + tip_texts, + vec![ + "tab or esc to clear notes", + "enter to submit answer", + "f12 to interrupt", + ] + ); + + overlay.handle_key_event(KeyEvent::from(KeyCode::F(12))); + + assert_eq!(overlay.done, true); + expect_interrupt_only(&mut rx); + } + #[test] fn tab_opens_notes_when_option_selected() { let (tx, _rx) = test_sender(); @@ -3171,6 +3215,26 @@ mod tests { ); } + #[test] + fn request_user_input_freeform_remapped_interrupt_snapshot() { + let (tx, _rx) = test_sender(); + let mut keymap = RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + let overlay = RequestUserInputOverlay::new_with_keymap( + request_event("turn-1", vec![question_without_options("q1", "Goal")]), + tx, + /*has_input_focus*/ true, + /*enhanced_keys_supported*/ false, + /*disable_paste_burst*/ false, + keymap, + ); + let area = Rect::new(0, 0, 120, 10); + insta::assert_snapshot!( + "request_user_input_freeform_remapped_interrupt", + render_snapshot(&overlay, area) + ); + } + #[test] fn request_user_input_multi_question_first_snapshot() { let (tx, _rx) = test_sender(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap new file mode 100644 index 0000000000..0b26b354e3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform_remapped_interrupt.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/request_user_input/mod.rs +expression: "render_snapshot(&overlay, area)" +--- + + Question 1/1 (1 unanswered) + Share details. + + › Type your answer (optional) + + + + enter to submit answer | f12 to interrupt diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap new file mode 100644 index 0000000000..f8a3caa1bf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__pending_input_preview__tests__render_one_pending_steer_with_remapped_interrupt_binding.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/bottom_pane/pending_input_preview.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 48, height: 3 }, + content: [ + "• Messages to be submitted after next tool call ", + " (press f12 to interrupt and send immediately) ", + " ↳ Please continue. ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 0819bd6c03..d9451fc9d5 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -112,8 +112,7 @@ impl ChatWidget { return; } - if matches!(key_event.code, KeyCode::Esc) - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + if self.chat_keymap.interrupt_turn.is_pressed(key_event) && !self.input_queue.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index bfe563b94c..23487af437 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -953,6 +953,32 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { assert!(chat.input_queue.submit_pending_steers_after_interrupt); } +#[tokio::test] +async fn pending_steer_interrupt_uses_remapped_binding() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; + chat.chat_keymap = keymap.chat.clone(); + chat.bottom_pane.set_keymap_bindings(&keymap); + chat.bottom_pane.set_task_running(/*running*/ true); + chat.input_queue + .pending_steers + .push_back(pending_steer("queued steer")); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!chat.input_queue.submit_pending_steers_after_interrupt); + assert!(op_rx.try_recv().is_err()); + + chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } + assert!(chat.input_queue.submit_pending_steers_after_interrupt); +} + #[tokio::test] async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 8e6d40c764..7bb554a257 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -78,6 +78,8 @@ pub(crate) struct AppKeymap { /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { + /// Interrupt the active turn. + pub(crate) interrupt_turn: Vec, /// Decrease the active reasoning effort. pub(crate) decrease_reasoning_effort: Vec, /// Increase the active reasoning effort. @@ -420,6 +422,11 @@ impl RuntimeKeymap { }; let chat = ChatKeymap { + interrupt_turn: resolve_bindings( + keymap.chat.interrupt_turn.as_ref(), + &defaults.chat.interrupt_turn, + "tui.keymap.chat.interrupt_turn", + )?, decrease_reasoning_effort: resolve_bindings( keymap.chat.decrease_reasoning_effort.as_ref(), &defaults.chat.decrease_reasoning_effort, @@ -878,6 +885,7 @@ impl RuntimeKeymap { toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], }, chat: ChatKeymap { + interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))], edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], @@ -1127,6 +1135,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1169,6 +1178,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1197,6 +1207,11 @@ impl RuntimeKeymap { ), ], MAIN_RESERVED_BINDINGS, + [( + "chat.interrupt_turn", + "fixed.backtrack", + key_hint::plain(KeyCode::Esc), + )], )?; validate_no_shadow_with_allowed_overlaps( @@ -1249,6 +1264,18 @@ impl RuntimeKeymap { )], )?; + // The request-user-input overlay consumes turn interruption before + // configurable question navigation reaches its list handler. + validate_no_shadow_with_allowed_overlaps( + "request_user_input", + [("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())], + [ + ("list.move_left", self.list.move_left.as_slice()), + ("list.move_right", self.list.move_right.as_slice()), + ], + [], + )?; + // While the composer is focused, these main-surface handlers always // consume matching keys before the event reaches the textarea editor. validate_no_shadow_with_allowed_overlaps( @@ -1261,6 +1288,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -1522,6 +1550,7 @@ impl RuntimeKeymap { ("close_transcript", self.pager.close_transcript.as_slice()), ], TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, + [], )?; validate_unique( @@ -1675,10 +1704,11 @@ See the Codex keymap documentation for supported actions and examples." Ok(()) } -fn validate_no_reserved( +fn validate_no_reserved( context: &str, pairs: [(&'static str, &[KeyBinding]); N], reserved: &[(&'static str, KeyBinding)], + allowed_overlaps: [(&'static str, &'static str, KeyBinding); A], ) -> Result<(), String> { for (action, bindings) in pairs { for binding in bindings { @@ -1687,6 +1717,15 @@ fn validate_no_reserved( .iter() .find(|(_, reserved_binding)| reserved_binding.parts() == key) { + if allowed_overlaps.iter().any( + |(allowed_action, allowed_reserved_action, allowed_binding)| { + *allowed_action == action + && *allowed_reserved_action == *reserved_action + && allowed_binding.parts() == key + }, + ) { + continue; + } return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \ Set a different key in `~/.codex/config.toml` and retry. \ @@ -2076,6 +2115,10 @@ mod tests { vec![key_hint::ctrl(KeyCode::Char('l'))] ); assert_eq!(runtime.app.toggle_fast_mode, Vec::new()); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::Esc)] + ); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![key_hint::alt(KeyCode::Char(','))] @@ -2498,6 +2541,44 @@ mod tests { expect_conflict(&keymap, "composer.submit", "fixed.paste_image"); } + #[test] + fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() { + let mut keymap = TuiKeymap::default(); + let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse"); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::Esc)] + ); + + keymap.chat.interrupt_turn = Some(one("f12")); + let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse"); + assert_eq!( + runtime.chat.interrupt_turn, + vec![key_hint::plain(KeyCode::F(12))] + ); + + keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse"); + assert!(runtime.chat.interrupt_turn.is_empty()); + } + + #[test] + fn interrupt_turn_rejects_other_fixed_shortcuts() { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one("ctrl-v")); + + expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image"); + } + + #[test] + fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.chat.interrupt_turn = Some(one("f12")); + keymap.list.move_right = Some(one("f12")); + + expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right"); + } + #[test] fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index f67ea20cb0..8c1292aeb1 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1009,6 +1009,7 @@ mod tests { actions, vec![ "Composer.submit", + "Chat.interrupt_turn", "Editor.insert_newline", "Composer.queue", "Global.open_external_editor", diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index c8815c8c98..87d65b225f 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -93,6 +93,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), + action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -234,6 +235,7 @@ pub(super) fn binding_slot<'a>( ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), + ("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -357,6 +359,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), + ("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 52ec4a4b8b..883f9f898f 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -60,6 +60,7 @@ struct KeymapContextTab { const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "submit"), + ("chat", "interrupt_turn"), ("editor", "insert_newline"), ("composer", "queue"), ("global", "toggle_fast_mode"), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index 4e388179a3..06efc646a8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -8,9 +8,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default -Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index e212226ef5..6c0d008aad 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 106 actions, 1 customized, 2 unbound. + 107 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 22c0537540..b7384d1c4a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 107 actions, 0 customized, 3 unbound. + 108 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug @@ -17,6 +17,6 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global - Toggle Fast Mode unbound Global Toggle Raw Output alt-r - Chat Decrease Reasoning Effort alt-, + Chat Interrupt Turn esc left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 4a88465ba8..53ccba7e37 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (106 selectable) -tab: Common (19 selectable) +tab: All (107 selectable) +tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) -tab: App (9 selectable) +tab: App (10 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (47 selectable) @@ -19,9 +19,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default -Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 26af4bd57a..3b9ebb7a6a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 106 actions, 0 customized, 2 unbound. + 107 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -17,7 +17,7 @@ expression: "render_picker(params, 78)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 36f6a1d04c..c5efeb30e5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 106 actions, 0 customized, 2 unbound. + 107 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Chat Interrupt Turn esc Chat Decrease Reasoning Effort alt-, - Chat Increase Reasoning Effort alt-. left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap new file mode 100644 index 0000000000..ac32ebe046 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_remapped_interrupt_hint.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/status_indicator_widget.rs +expression: terminal.backend() +--- +"Working (0s • f12 to interrupt) " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index dabe005358..0438c206d5 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -20,6 +20,7 @@ use unicode_width::UnicodeWidthStr; use crate::app_event_sender::AppEventSender; use crate::key_hint; +use crate::key_hint::KeyBinding; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; @@ -49,6 +50,7 @@ pub(crate) struct StatusIndicatorWidget { /// Optional suffix rendered after the elapsed/interrupt segment. inline_message: Option, show_interrupt_hint: bool, + interrupt_binding: Option, elapsed_running: Duration, last_resume_at: Instant, @@ -87,6 +89,7 @@ impl StatusIndicatorWidget { details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, inline_message: None, show_interrupt_hint: true, + interrupt_binding: Some(key_hint::plain(KeyCode::Esc)), elapsed_running: Duration::ZERO, last_resume_at: Instant::now(), is_paused: false, @@ -125,7 +128,7 @@ impl StatusIndicatorWidget { }); } - /// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`. + /// Update the inline suffix text shown after the elapsed/interrupt hint. /// /// Callers should provide plain, already-contextualized text. Passing /// verbose status prose here can cause frequent width truncation and hide @@ -150,6 +153,10 @@ impl StatusIndicatorWidget { self.show_interrupt_hint = visible; } + pub(crate) fn set_interrupt_binding(&mut self, binding: Option) { + self.interrupt_binding = binding; + } + pub(crate) fn pause_timer(&mut self) { self.pause_timer_at(Instant::now()); } @@ -257,10 +264,12 @@ impl Renderable for StatusIndicatorWidget { if !spans.is_empty() { spans.push(" ".into()); } - if self.show_interrupt_hint { + if self.show_interrupt_hint + && let Some(interrupt_binding) = self.interrupt_binding + { spans.extend(vec![ format!("({pretty_elapsed} • ").dim(), - key_hint::plain(KeyCode::Esc).into(), + interrupt_binding.into(), " to interrupt)".dim(), ]); } else { @@ -405,6 +414,26 @@ mod tests { assert!(line.starts_with("Working (0s • esc to interrupt)")); } + #[test] + fn renders_remapped_interrupt_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new( + tx, + crate::tui::FrameRequester::test_dummy(), + /*animations_enabled*/ false, + ); + w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)))); + w.is_paused = true; + w.elapsed_running = Duration::ZERO; + + let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal"); + terminal + .draw(|f| w.render(f.area(), f.buffer_mut())) + .expect("draw"); + insta::assert_snapshot!(terminal.backend()); + } + #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::(); From 6b4b15a1ed2271c2b35d7e73bdf532cf11e33388 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 27 May 2026 16:18:24 -0300 Subject: [PATCH 040/204] feat(tui): render markdown tables in app style [1 of 2] (#24489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Stack - **Current: #24489 [1 of 2]** - render markdown tables in app style. - **Stacked follow-up: #24636 [2 of 2]** - render cramped markdown tables as key/value records. ## Why Markdown tables currently render as boxed terminal grids, which gives ordinary assistant output a heavier visual treatment than surrounding rich text. This row-separated layout is the best match for how the App renders tables, while accented headers remain distinguishable even when a terminal font renders bold subtly.

Codex CLI - Before

CleanShot 2026-05-25 at 18 46 17

Codex CLI - After

image

Codex App

CleanShot 2026-05-25 at 18 45 04
## What Changed - Render markdown tables as padded, aligned rows without an enclosing box. - Style table headers with the active syntax-theme accent plus bold text, while keeping separators low contrast and theme-aware. - Use a segmented heavy header rule and thin body-row rules, preserving wrapping, narrow-width fallback, streaming parity, and rich-history rendering. - Update focused assertions and snapshots for the final table layout. ## How to Test 1. Render a markdown table in the TUI with several rows and columns. 2. Confirm the header uses the active theme accent, rows use one-character interior padding, and the table has no enclosing box. 3. Confirm the header is followed by segmented `━` rules and multiple body rows are separated by muted segmented `─` rules. 4. Render the same table while streaming and in history/raw-mode toggles; the final rich layout should remain stable. 5. Render a narrow table with long content and verify wrapping or pipe fallback does not overflow. ## Validation - `just test -p codex-tui table` - `just test -p codex-tui streaming::controller::tests` - `just argument-comment-lint-from-source -p codex-tui -- --all-targets` - `just fix -p codex-tui` - `just fmt` `just test -p codex-tui` was also run after accepting the snapshots; it fails only in the unrelated existing guardian app tests `update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` and `update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history`. --- .../src/chatwidget/tests/status_and_layout.rs | 4 +- ...ll__tests__raw_mode_toggle_transcript.snap | 18 +-- codex-rs/tui/src/history_cell/tests.rs | 12 +- codex-rs/tui/src/markdown.rs | 14 +- codex-rs/tui/src/markdown_render.rs | 122 +++++++++++------- codex-rs/tui/src/markdown_render_tests.rs | 70 ++++++++-- codex-rs/tui/src/markdown_stream.rs | 4 +- ...sts__markdown_render_complex_snapshot.snap | 8 +- ...collapsing_narrative_columns_snapshot.snap | 43 +++--- codex-rs/tui/src/streaming/controller.rs | 50 +++---- codex-rs/tui/src/style.rs | 71 ++++++++++ 11 files changed, 277 insertions(+), 139 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 5690db4ab0..acb1df6c5a 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -393,8 +393,8 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() { assert!(saw_source_backed_plan, "expected source-backed plan insert"); assert!( - rendered_plan.contains('│') || rendered_plan.contains('┌'), - "expected completed plan table to render as a boxed table, got: {rendered_plan:?}" + rendered_plan.contains('━'), + "expected completed plan table to render with separators, got: {rendered_plan:?}" ); assert!( !saw_stream_plan, diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap index 1d89a4bd6a..f1c04cec85 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__raw_mode_toggle_transcript.snap @@ -1,5 +1,5 @@ --- -source: tui/src/history_cell.rs +source: tui/src/history_cell/tests.rs expression: rendered --- rich before: @@ -10,11 +10,9 @@ rich before: • - first item - second item - ┌──────┬───────┐ - │ Col │ Value │ - ├──────┼───────┤ - │ code │ x = 1 │ - └──────┴───────┘ + Col Value + ━━━━━━ ━━━━━━━ + code x = 1 copy me • Called @@ -48,11 +46,9 @@ rich after: • - first item - second item - ┌──────┬───────┐ - │ Col │ Value │ - ├──────┼───────┤ - │ code │ x = 1 │ - └──────┴───────┘ + Col Value + ━━━━━━ ━━━━━━━ + code x = 1 copy me • Called diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 8c48ced86a..6c83982a41 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -278,10 +278,8 @@ fn proposed_plan_cell_renders_markdown_table() { let rendered = render_lines(&plan.display_lines(/*width*/ 80)); assert!( - rendered - .iter() - .any(|line| line.contains('│') || line.contains('┌')), - "expected boxed table in proposed plan output: {rendered:?}" + rendered.iter().any(|line| line.contains('━')), + "expected separated table in proposed plan output: {rendered:?}" ); assert!( !rendered @@ -308,10 +306,8 @@ fn proposed_plan_cell_unwraps_markdown_fenced_table() { let rendered = render_lines(&plan.display_lines(/*width*/ 80)); assert!( - rendered - .iter() - .any(|line| line.contains('│') || line.contains('┌')), - "expected boxed table for markdown-fenced proposed plan output: {rendered:?}" + rendered.iter().any(|line| line.contains('━')), + "expected separated table for markdown-fenced proposed plan output: {rendered:?}" ); assert!( !rendered.iter().any(|line| line.trim() == "```markdown"), diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 6d0daf89b2..0cabee0805 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -397,8 +397,8 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); - assert!(rendered.iter().any(|line| line.contains("│ 1 │ 2 │"))); + assert!(rendered.iter().any(|line| line.contains('━'))); + assert!(rendered.iter().any(|line| line.contains(" 1 2"))); } #[test] @@ -407,11 +407,11 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); + assert!(rendered.iter().any(|line| line.contains('━'))); assert!( rendered .iter() - .any(|line| line.contains("│ Col A │ Col B │ Col C │")) + .any(|line| line.contains(" Col A Col B Col C")) ); assert!( !rendered @@ -426,8 +426,8 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); - assert!(rendered.iter().any(|line| line.contains("│ A"))); + assert!(rendered.iter().any(|line| line.contains('━'))); + assert!(rendered.iter().any(|line| line.contains(" left right"))); assert!(!rendered.iter().any(|line| line.trim() == "A | B")); } @@ -437,7 +437,7 @@ mod tests { let mut out = Vec::new(); append_markdown_agent(src, /*width*/ None, &mut out); let rendered = lines_to_strings(&out); - assert!(rendered.iter().any(|line| line.contains("┌"))); + assert!(rendered.iter().any(|line| line.contains('━'))); assert!(!rendered.iter().any(|line| line.trim() == "| Only |")); } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 75125a1ac1..87861989c0 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -22,8 +22,9 @@ //! alignment count. //! 3. **Compute column widths** -- allocate widths with content-aware //! priority and iterative shrinking. -//! 4. **Render box grid** -- Unicode borders (`┌───┬───┐`) or fallback to pipe -//! format when the minimum cannot fit. +//! 4. **Render row-separated layout** -- theme-accented bold headers, a +//! heavier segmented header rule, and low-contrast segmented body +//! separators, or fallback to pipe format when the minimum cannot fit. //! 5. **Append spillover** -- extracted spillover rows rendered as plain text //! after the table. //! @@ -36,9 +37,11 @@ //! preserved last. When even 3-char-wide columns cannot fit, the table falls //! back to pipe-delimited format. +use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; use crate::render::line_utils::push_owned_lines; +use crate::style::table_separator_style; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; @@ -66,6 +69,11 @@ use std::sync::LazyLock; use unicode_width::UnicodeWidthStr; use url::Url; +const TABLE_COLUMN_GAP: usize = 2; +const TABLE_CELL_PADDING: usize = 1; +const TABLE_HEADER_SEPARATOR_CHAR: char = '━'; +const TABLE_BODY_SEPARATOR_CHAR: char = '─'; + struct MarkdownStyles { h1: Style, h2: Style, @@ -207,7 +215,7 @@ impl TableState { /// Rendered table output split by wrapping behavior. /// -/// `table_lines` are either prewrapped grid rows (box rendering) or pipe +/// `table_lines` are either prewrapped aligned rows or pipe /// fallback rows that should still pass through normal wrapping. /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. @@ -260,7 +268,7 @@ pub fn render_markdown_text(input: &str) -> Text<'static> { /// Render markdown constrained to a known terminal width. /// /// The renderer preserves table structure when possible and falls back to -/// pipe-table output when a box table cannot fit the available width. Passing +/// pipe-table output when an aligned table cannot fit the available width. Passing /// `None` keeps intrinsic line widths and disables width-driven wrapping in the /// markdown writer. Local file links render relative to the current process /// working directory. @@ -982,13 +990,12 @@ where } } - /// Convert a completed `TableState` into styled `Line`s with Unicode - /// box-drawing borders. + /// Convert a completed `TableState` into styled, row-separated `Line`s. /// /// Pipeline: filter spillover rows -> normalize column counts -> compute - /// column widths -> render box grid (or fall back to pipe format if the + /// column widths -> render aligned rows (or fall back to pipe format if the /// minimum column widths exceed available terminal width). Spillover rows - /// are appended as plain text after the table grid. + /// are appended as plain text after the table. /// /// Falls back to `render_table_pipe_fallback` (raw `| A | B |` format) /// when `compute_column_widths` returns `None` (terminal too narrow for @@ -1048,25 +1055,38 @@ where }; }; - let border_style = Style::new().dim(); - let mut out = Vec::with_capacity(3 + rows.len() * 2); - out.push(self.render_border_line('┌', '┬', '┐', &column_widths, border_style)); + let header_style = + foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"]) + .unwrap_or(self.styles.strong) + .bold(); + let separator_style = table_separator_style(); + let mut out = Vec::with_capacity(2 + rows.len() * 2); out.extend(self.render_table_row( &header, &column_widths, &table_state.alignments, - border_style, + header_style, )); - out.push(self.render_border_line('├', '┼', '┤', &column_widths, border_style)); - for row in &rows { + out.push(Self::render_table_separator( + &column_widths, + TABLE_HEADER_SEPARATOR_CHAR, + separator_style, + )); + for (row_idx, row) in rows.iter().enumerate() { out.extend(self.render_table_row( row, &column_widths, &table_state.alignments, - border_style, + Style::default(), )); + if row_idx + 1 < rows.len() { + out.push(Self::render_table_separator( + &column_widths, + TABLE_BODY_SEPARATOR_CHAR, + separator_style, + )); + } } - out.push(self.render_border_line('└', '┴', '┘', &column_widths, border_style)); RenderedTableLines { table_lines: out, table_lines_prewrapped: true, @@ -1079,17 +1099,19 @@ where row.resize(column_count, TableCell::default()); } - /// subtracts the space eaten by border characters + /// Subtract horizontal gutters and per-cell padding from the content budget. fn available_table_width(&self, column_count: usize) -> Option { self.wrap_width.map(|wrap_width| { let prefix_width = Self::spans_display_width(&self.prefix_spans(self.pending_marker_line)); - let reserved = prefix_width + 1 + (column_count * 3); + let reserved = prefix_width + + (column_count.saturating_sub(1) * TABLE_COLUMN_GAP) + + (column_count * TABLE_CELL_PADDING * 2); wrap_width.saturating_sub(reserved) }) } - /// Allocate column widths for box-drawing table rendering. + /// Allocate column widths for aligned, row-separated table rendering. /// /// Each column starts at its natural (max cell content) width, then columns /// are iteratively shrunk one character at a time until the total fits within @@ -1277,25 +1299,19 @@ where } } - fn render_border_line( - &self, - left: char, - sep: char, - right: char, + fn render_table_separator( column_widths: &[usize], + separator_char: char, style: Style, ) -> Line<'static> { - let mut spans = Vec::with_capacity(column_widths.len() * 2 + 1); - spans.push(Span::styled(String::from(left), style)); - for (idx, width) in column_widths.iter().enumerate() { - spans.push(Span::styled("─".repeat(*width + 2), style)); - if idx + 1 == column_widths.len() { - spans.push(Span::styled(String::from(right), style)); - } else { - spans.push(Span::styled(String::from(sep), style)); - } - } - Line::from(spans) + let segment_char = separator_char.to_string(); + let gap = " ".repeat(TABLE_COLUMN_GAP); + let text = column_widths + .iter() + .map(|width| segment_char.repeat(*width + (TABLE_CELL_PADDING * 2))) + .collect::>() + .join(&gap); + Line::from(Span::styled(text, style)) } fn render_table_row( @@ -1303,7 +1319,7 @@ where row: &[TableCell], column_widths: &[usize], alignments: &[Alignment], - border_style: Style, + row_style: Style, ) -> Vec> { let wrapped_cells: Vec>> = row .iter() @@ -1314,10 +1330,21 @@ where let mut out = Vec::with_capacity(row_height); for row_line in 0..row_height { + let Some(last_visible_column) = wrapped_cells.iter().rposition(|lines| { + lines + .get(row_line) + .is_some_and(|line| Self::line_display_width(line) > 0) + }) else { + out.push(Line::default().style(row_style)); + continue; + }; let mut spans = Vec::new(); - spans.push(Span::styled("│", border_style)); - for (column, width) in column_widths.iter().enumerate() { - spans.push(Span::raw(" ")); + for (column, width) in column_widths + .iter() + .enumerate() + .take(last_visible_column + 1) + { + spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING))); let line = wrapped_cells[column] .get(row_line) .cloned() @@ -1333,13 +1360,18 @@ where spans.push(Span::raw(" ".repeat(left_padding))); } spans.extend(line.spans); - if right_padding > 0 { + let is_last_column = column == last_visible_column; + if right_padding > 0 && !is_last_column { spans.push(Span::raw(" ".repeat(right_padding))); } - spans.push(Span::raw(" ")); - spans.push(Span::styled("│", border_style)); + if !is_last_column { + spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING))); + } + if !is_last_column { + spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP))); + } } - out.push(Line::from(spans)); + out.push(Line::from(spans).style(row_style)); } out } @@ -1635,8 +1667,8 @@ where /// Push a line that has already been laid out at the correct width, skipping /// word wrapping. /// - /// Table lines are pre-formatted with exact column widths and box-drawing - /// borders. Passing them through `word_wrap_line` would break the grid at + /// Table lines are pre-formatted with exact column widths and separators. + /// Passing them through `word_wrap_line` would break the layout at /// arbitrary positions. This method prepends the indent/blockquote prefix /// and pushes directly to `self.text.lines`. fn is_blockquote_active(&self) -> bool { diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index d02fef76dd..68e1961699 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -1,4 +1,5 @@ use pretty_assertions::assert_eq; +use ratatui::style::Modifier; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -1471,7 +1472,7 @@ fn code_block_preserves_trailing_blank_lines() { } #[test] -fn table_renders_unicode_box() { +fn table_renders_app_style_rows_with_themed_bold_header() { let md = "| A | B |\n|---|---|\n| 1 | 2 |\n"; let text = render_markdown_text(md); let lines: Vec = text @@ -1483,13 +1484,33 @@ fn table_renders_unicode_box() { assert_eq!( lines, vec![ - "┌─────┬─────┐".to_string(), - "│ A │ B │".to_string(), - "├─────┼─────┤".to_string(), - "│ 1 │ 2 │".to_string(), - "└─────┴─────┘".to_string(), + " A B".to_string(), + "━━━━━ ━━━━━".to_string(), + " 1 2".to_string(), ] ); + assert!( + text.lines[0] + .style + .add_modifier + .contains(Modifier::BOLD) + ); + assert!( + text.lines[0].style.fg.is_some(), + "expected the syntax theme to provide a table header accent" + ); + assert!( + text.lines[1].spans[0] + .style + .add_modifier + .contains(Modifier::DIM) + ); + assert!( + !text.lines[2] + .style + .add_modifier + .contains(Modifier::BOLD) + ); } #[test] @@ -1502,13 +1523,13 @@ fn table_alignment_respects_markers() { .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); - assert_eq!(lines[1], "│ Left │ Center │ Right │"); - assert_eq!(lines[3], "│ a │ b │ c │"); + assert_eq!(lines[0], " Left Center Right"); + assert_eq!(lines[2], " a b c"); } #[test] -fn table_wraps_cell_content_when_width_is_narrow() { - let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n"; +fn table_separates_logical_rows_after_wrapped_content() { + let md = "| Key | Description |\n| --- | --- |\n| -v | Enable very verbose logging output for debugging |\n| -q | Quiet output |\n"; let text = crate::markdown_render::render_markdown_text_with_width(md, Some(30)); let lines: Vec = text .lines @@ -1516,7 +1537,6 @@ fn table_wraps_cell_content_when_width_is_narrow() { .map(|line| line.spans.iter().map(|span| span.content.clone()).collect()) .collect(); - assert!(lines[0].starts_with('┌') && lines[0].ends_with('┐')); assert!( lines .iter() @@ -1524,6 +1544,26 @@ fn table_wraps_cell_content_when_width_is_narrow() { && lines.iter().any(|line| line.contains("logging output")), "expected wrapped row content: {lines:?}" ); + let separator_indices: Vec = lines + .iter() + .enumerate() + .filter_map(|(idx, line)| { + ((line.contains('━') || line.contains('─')) + && line.chars().all(|ch| matches!(ch, '━' | '─' | ' '))) + .then_some(idx) + }) + .collect(); + let wrapped_row_end = lines + .iter() + .position(|line| line.contains("logging output")) + .expect("expected final wrapped line"); + assert_eq!(separator_indices.len(), 2); + assert!(separator_indices[1] > wrapped_row_end); + assert!( + !lines + .last() + .is_some_and(|line| line.contains('━') || line.contains('─')) + ); } #[test] @@ -1553,7 +1593,7 @@ fn table_inside_blockquote_has_quote_prefix() { .collect(); assert!(lines.iter().all(|line| line.starts_with("> "))); - assert!(lines.iter().any(|line| line.contains("┌─────┬─────┐"))); + assert!(lines.iter().any(|line| line.contains("━━━━━ ━━━━━"))); } #[test] @@ -1580,5 +1620,9 @@ fn table_falls_back_to_pipe_rendering_if_it_cannot_fit() { .collect(); assert!(lines.first().is_some_and(|line| line.starts_with('|'))); - assert!(!lines.iter().any(|line| line.contains('┌'))); + assert!( + !lines + .iter() + .any(|line| line.contains('━') || line.contains('─')) + ); } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index ad8cc68e7d..c3f2474b5b 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -878,8 +878,8 @@ mod tests { let rendered_strs = lines_to_plain_strings(&rendered); assert!( - rendered_strs.iter().any(|line| line.contains('┌')), - "expected markdown-fenced table to render as boxed table: {rendered_strs:?}" + rendered_strs.iter().any(|line| line.contains('━')), + "expected markdown-fenced table to render with a separator: {rendered_strs:?}" ); assert!( !rendered_strs.iter().any(|line| line.trim() == "| A | B |"), diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap index 1e32d7c61d..30c768ab6b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap @@ -30,11 +30,9 @@ Image: alt text Table below (alignment test): -┌──────┬────────┬───────┐ -│ Left │ Center │ Right │ -├──────┼────────┼───────┤ -│ a │ b │ c │ -└──────┴────────┴───────┘ + Left Center Right +━━━━━━ ━━━━━━━━ ━━━━━━━ + a b c Inline HTML: sup and sub. HTML block:
inline block
diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap index 400b402023..95ad94f647 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__table_wraps_file_paths_before_collapsing_narrative_columns_snapshot.snap @@ -2,25 +2,24 @@ source: tui/src/markdown_render_tests.rs expression: "plain_lines(&text).join(\"\\n\")" --- -┌────────────────────────────────────────┬──────────────────┬──────┬─────────┬─────────────────────────────────────────┐ -│ Unit │ Files │ Adds │ Removes │ What It Adds │ -├────────────────────────────────────────┼──────────────────┼──────┼─────────┼─────────────────────────────────────────┤ -│ Suggestion engine and unit coverage │ codex-rs/core/ │ 704 │ 0 │ Sampling workflow, stable-history │ -│ │ src/ │ │ │ checks, tool-flow suppression, fast │ -│ │ next_prompt_sugg │ │ │ reasoning profile, filtering rules, │ -│ │ estion.rs:1, │ │ │ cancellation and timeout. │ -│ │ codex-rs/core/ │ │ │ │ -│ │ src/ │ │ │ │ -│ │ next_prompt_sugg │ │ │ │ -│ │ estion_tests.rs: │ │ │ │ -│ │ 1 │ │ │ │ -│ Model instruction fragment and │ codex-rs/core/ │ 54 │ 0 │ Synthetic suggestion prompt and an │ -│ contextual isolation │ src/context/ │ │ │ isolation test for ordinary user text. │ -│ │ next_prompt_sugg │ │ │ │ -│ │ estion.rs:1, │ │ │ │ -│ │ codex-rs/core/ │ │ │ │ -│ │ src/context/ │ │ │ │ -│ │ contextual_user_ │ │ │ │ -│ │ message_tests.rs │ │ │ │ -│ │ :1 │ │ │ │ -└────────────────────────────────────────┴──────────────────┴──────┴─────────┴─────────────────────────────────────────┘ + Unit Files Adds Removes What It Adds +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━ ━━━━━━ ━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Suggestion engine and unit coverage codex-rs/core/ 704 0 Sampling workflow, stable-history + src/ checks, tool-flow suppression, fast + next_prompt_sugg reasoning profile, filtering rules, + estion.rs:1, cancellation and timeout. + codex-rs/core/ + src/ + next_prompt_sugg + estion_tests.rs: + 1 +─────────────────────────────────────── ────────────────── ────── ───────── ──────────────────────────────────────── + Model instruction fragment and codex-rs/core/ 54 0 Synthetic suggestion prompt and an + contextual isolation src/context/ isolation test for ordinary user text. + next_prompt_sugg + estion.rs:1, + codex-rs/core/ + src/context/ + contextual_user_ + message_tests.rs + :1 diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index a653f1b210..0cdf956087 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -1362,7 +1362,7 @@ mod tests { } #[test] - fn controller_renders_unicode_for_multi_table_response_shape() { + fn controller_renders_separators_for_multi_table_response_shape() { let source = "Absolutely. Here are several different Markdown table patterns you can use for rendering tests.\n\n| Name | Role | Location |\n|-------|-----------|----------|\n| Ava | Engineer | NYC |\n| Malik | Designer | Berlin |\n| Priya | PM | Remote |\n\n| Item | Qty | Price | In Stock |\n|:------------|----:|------:|:--------:|\n| Keyboard | 2 | 49.99 | Yes |\n| Mouse | 10 @@ -1378,13 +1378,13 @@ mod tests { let deltas = chunked.iter().map(String::as_str).collect::>(); let streamed = collect_streamed_lines(&deltas, Some(120)); assert!( - streamed.iter().any(|line| line.contains('┌')), - "expected unicode table border in streamed output: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separator in streamed output: {streamed:?}" ); } #[test] - fn controller_renders_unicode_for_no_outer_pipes_table_shape() { + fn controller_renders_separators_for_no_outer_pipes_table_shape() { let source = "### 1) Basic\n\n| Name | Role | Active |\n|---|---|---|\n| Alice | Engineer | Yes |\n| Bob | Designer | No |\n\n### 2) No outer pipes\n\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n\n### 3) Another table\n\n| Key | Value |\n|---|---|\n| a | b |\n"; @@ -1407,6 +1407,10 @@ mod tests { !has_raw_no_outer_header, "no-outer-pipes header should not remain raw in final streamed output: {streamed:?}" ); + assert!( + streamed.iter().any(|line| line.contains('━')), + "expected table separator in final streamed output: {streamed:?}" + ); } #[test] @@ -1429,8 +1433,8 @@ mod tests { assert_eq!(streamed, expected); assert!( - streamed.iter().any(|line| line.contains('┌')), - "expected unicode table border for no-outer-pipes streaming: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separator for no-outer-pipes streaming: {streamed:?}" ); assert!( !streamed @@ -1458,8 +1462,8 @@ mod tests { assert_eq!(streamed, expected); assert!( - streamed.iter().any(|line| line.contains('┌')), - "expected unicode table border for two-column no-outer table: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separator for two-column no-outer table: {streamed:?}" ); assert!( !streamed.iter().any(|line| line.trim() == "A | B"), @@ -1490,8 +1494,8 @@ mod tests { assert!( streamed .iter() - .any(|line| line.contains("┌───────┬───────┬───────┐")), - "expected converted no-outer table border in streamed output: {streamed:?}" + .any(|line| line.contains(" Col A Col B Col C")), + "expected converted no-outer table header in streamed output: {streamed:?}" ); } @@ -1513,8 +1517,8 @@ mod tests { assert_eq!(streamed, expected); assert!( - streamed.iter().any(|line| line.contains('┌')), - "expected unicode table border in streamed output: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separator in streamed output: {streamed:?}" ); assert!( !streamed.iter().any(|line| line.trim() == "| A | B |"), @@ -1542,8 +1546,8 @@ mod tests { assert_eq!(streamed, expected); assert!( - streamed.iter().any(|line| line.contains('┌')), - "expected unicode table border in streamed output: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separator in streamed output: {streamed:?}" ); assert!( !streamed @@ -1621,8 +1625,10 @@ mod tests { "expected code-fenced pipe line to remain raw: {streamed:?}" ); assert!( - !streamed.iter().any(|line| line.contains('┌')), - "did not expect unicode table border for non-markdown fence: {streamed:?}" + !streamed + .iter() + .any(|line| line.contains('━') || line.contains('─')), + "did not expect a table separator for non-markdown fence: {streamed:?}" ); } @@ -1643,10 +1649,8 @@ mod tests { assert_eq!(streamed, baseline); assert!( - streamed - .iter() - .any(|line| line.contains('│') || line.contains('└') || line.contains('┌')), - "expected unicode table box drawing chars in plan streamed output: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separators in plan streamed output: {streamed:?}" ); assert!( !streamed @@ -1675,10 +1679,8 @@ mod tests { assert_eq!(streamed, baseline); assert!( - streamed - .iter() - .any(|line| line.contains('│') || line.contains('└') || line.contains('┌')), - "expected unicode table box drawing chars in fenced plan output: {streamed:?}" + streamed.iter().any(|line| line.contains('━')), + "expected table separators in fenced plan output: {streamed:?}" ); assert!( !streamed diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 10c269174f..0284df9142 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -1,12 +1,18 @@ use crate::color::blend; use crate::color::is_light; +use crate::terminal_palette::StdoutColorLevel; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; +use crate::terminal_palette::default_fg; +use crate::terminal_palette::rgb_color; +use crate::terminal_palette::stdout_color_level; use ratatui::style::Color; use ratatui::style::Style; use ratatui::style::Stylize; const LIGHT_BG_ACCENT_RGB: (u8, u8, u8) = (0, 95, 135); +// Decorative table rules should remain visible without competing with cell content. +const TABLE_SEPARATOR_FG_ALPHA: f32 = 0.20; pub fn user_message_style() -> Style { user_message_style_for(default_bg()) @@ -16,6 +22,11 @@ pub fn proposed_plan_style() -> Style { proposed_plan_style_for(default_bg()) } +/// Returns a low-contrast rule style for separators within markdown tables. +pub(crate) fn table_separator_style() -> Style { + table_separator_style_for(default_fg(), default_bg(), stdout_color_level()) +} + /// Returns the shared accent style for active or selected TUI controls. pub(crate) fn accent_style() -> Style { accent_style_for(default_bg()) @@ -45,6 +56,22 @@ pub(crate) fn accent_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { } } +fn table_separator_style_for( + terminal_fg: Option<(u8, u8, u8)>, + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, +) -> Style { + let (Some(fg), Some(bg)) = (terminal_fg, terminal_bg) else { + return Style::default().dim(); + }; + let separator_rgb = blend(fg, bg, TABLE_SEPARATOR_FG_ALPHA); + match color_level { + StdoutColorLevel::TrueColor => Style::default().fg(rgb_color(separator_rgb)), + StdoutColorLevel::Ansi256 => Style::default().fg(best_color(separator_rgb)), + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => Style::default().dim(), + } +} + #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { let (top, alpha) = if is_light(terminal_bg) { @@ -81,4 +108,48 @@ mod tests { assert_eq!(accent_style_for(Some((0, 0, 0))), expected); assert_eq!(accent_style_for(/*terminal_bg*/ None), expected); } + + #[test] + fn table_separator_blends_toward_dark_background() { + let style = table_separator_style_for( + Some((255, 255, 255)), + Some((0, 0, 0)), + StdoutColorLevel::TrueColor, + ); + + assert_eq!(style.fg, Some(rgb_color((51, 51, 51)))); + } + + #[test] + fn table_separator_blends_toward_light_background() { + let style = table_separator_style_for( + Some((0, 0, 0)), + Some((255, 255, 255)), + StdoutColorLevel::TrueColor, + ); + + assert_eq!(style.fg, Some(rgb_color((204, 204, 204)))); + } + + #[test] + fn table_separator_dims_when_palette_aware_color_is_unavailable() { + let expected = Style::default().dim(); + + assert_eq!( + table_separator_style_for( + Some((255, 255, 255)), + Some((0, 0, 0)), + StdoutColorLevel::Ansi16, + ), + expected + ); + assert_eq!( + table_separator_style_for( + /*terminal_fg*/ None, + Some((0, 0, 0)), + StdoutColorLevel::TrueColor, + ), + expected + ); + } } From 07a930138f675a1ccdd15339f74bb337f9f02398 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 27 May 2026 12:39:01 -0700 Subject: [PATCH 041/204] chore: enable namespace tools for Bedrock (#24713) Client-side namespace tools are now supported by bedrock. Enable `namespace_tools` for the Amazon Bedrock provider while continuing to disable unsupported hosted tools such as image generation and web search. --- .../v2/model_provider_capabilities_read.rs | 2 +- codex-rs/core/src/tools/spec_plan_tests.rs | 18 +++++++++--------- .../model-provider/src/amazon_bedrock/mod.rs | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs index 6dcb9ac4ec..529f238798 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -60,7 +60,7 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { let received: ModelProviderCapabilitiesReadResponse = to_response(response)?; let expected = ModelProviderCapabilitiesReadResponse { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, }; diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 2eb6eea5d2..a2f12d1c01 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -526,7 +526,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { "read_mcp_resource", ]); - let missing_namespace_capability = probe_with( + let bedrock_namespace_capability = probe_with( |turn| { turn.model_info.supports_search_tool = true; use_bedrock_provider(turn); @@ -537,7 +537,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() { }, ) .await; - missing_namespace_capability.assert_visible_lacks(&["tool_search"]); + bedrock_namespace_capability.assert_visible_contains(&["tool_search"]); let enabled = probe_with( |turn| { @@ -890,7 +890,7 @@ async fn multi_agent_v2_can_use_configured_tool_namespace() { } #[tokio::test] -async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support() { +async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { let plan = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); update_config(turn, |config| { @@ -900,15 +900,15 @@ async fn multi_agent_v2_namespace_is_ignored_without_provider_namespace_support( }) .await; - plan.assert_visible_contains(&["spawn_agent", "send_message", "list_agents"]); - plan.assert_visible_lacks(&["agents"]); - assert!( - plan.registered_names - .contains(&ToolName::plain("spawn_agent").to_string()) - ); + plan.assert_visible_contains(&["agents"]); + plan.assert_visible_lacks(&["spawn_agent", "send_message", "list_agents"]); assert!( !plan .registered_names + .contains(&ToolName::plain("spawn_agent").to_string()) + ); + assert!( + plan.registered_names .contains(&ToolName::namespaced("agents", "spawn_agent").to_string()) ); } diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 3940f73fcd..4d6a9afb3c 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -57,7 +57,7 @@ impl ModelProvider for AmazonBedrockModelProvider { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, } @@ -131,7 +131,7 @@ mod tests { } #[test] - fn capabilities_disable_unsupported_launch_features() { + fn capabilities_disable_unsupported_hosted_tools() { let provider = AmazonBedrockModelProvider::new( ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), ); @@ -139,7 +139,7 @@ mod tests { assert_eq!( provider.capabilities(), ProviderCapabilities { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, } From 9152ebd289f2d9103ffc41c2fec69f6c623a6eab Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 27 May 2026 12:59:11 -0700 Subject: [PATCH 042/204] fix(linux-sandbox): preserve shell cleanup on interruption (#22729) ## Why Interrupted `shell_command` calls can race with the outer tool-dispatch cancellation path. When that happens, the runtime future may be dropped before the spawned process gets a chance to run `SIGTERM` cleanup. For bwrapd-backed Linux sandbox commands, that can leave synthetic protected-path mount bookkeeping such as `.git/.codex` registrations under `/tmp` behind after a TUI interruption. The relevant cancellation points are the outer dispatch race in [`core/src/tools/parallel.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/tools/parallel.rs#L91-L132) and the process shutdown logic in [`core/src/exec.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/exec.rs#L1367-L1393). ## What changed - Keep `shell_command` dispatch alive long enough for the runtime to finish cancellation cleanup instead of immediately returning the synthetic aborted response. - Fold shell-turn cancellation into the existing `ExecExpiration` path in [`core/src/tools/runtimes/shell.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/tools/runtimes/shell.rs#L267-L274), so cancellation and timeout behavior stay centralized. - On cancellation, send `SIGTERM` first, wait briefly for cleanup to run, then hard-kill any remaining descendants in the original process group. - Treat `ESRCH` as an already-gone process-group cleanup case in `codex-utils-pty`, which keeps best-effort teardown from surfacing a stale-process race as an error. ## Verification - `cargo test -p codex-core cancellation` - Added regression coverage for: - `shell_tool_cancellation_waits_for_runtime_cleanup` - `process_exec_tool_call_cancellation_allows_sigterm_cleanup` --- codex-rs/core/src/exec.rs | 53 ++++- codex-rs/core/src/exec_tests.rs | 109 ++++++++++ codex-rs/core/src/session/tests.rs | 70 +++++++ codex-rs/core/src/tools/handlers/shell.rs | 4 + .../src/tools/handlers/shell/shell_command.rs | 6 + codex-rs/core/src/tools/parallel.rs | 193 ++++++++++++++++-- codex-rs/core/src/tools/registry.rs | 69 +++++-- codex-rs/core/src/tools/router.rs | 6 + codex-rs/core/src/tools/runtimes/shell.rs | 3 + codex-rs/utils/pty/src/process_group.rs | 8 +- 10 files changed, 474 insertions(+), 47 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 5cec3b49bb..a485047a32 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -56,6 +56,7 @@ const SIGKILL_CODE: i32 = 9; const TIMEOUT_CODE: i32 = 64; const EXIT_CODE_SIGNAL_BASE: i32 = 128; // conventional shell: 128 + signal const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code +const CANCELLATION_TERMINATION_GRACE_PERIOD: Duration = Duration::from_millis(50); // I/O buffer sizing const READ_CHUNK_SIZE: usize = 8192; // bytes per read @@ -1358,15 +1359,49 @@ async fn consume_output( (exit_status, false) } outcome = &mut expiration_wait => { - kill_child_process_group(&mut child)?; - child.start_kill()?; - let timed_out = matches!(outcome, Some(ExecExpirationOutcome::TimedOut)); - let exit_status = if timed_out { - synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE) - } else { - synthetic_exit_status_for_code(/*code*/ 1) - }; - (exit_status, timed_out) + match outcome { + Some(ExecExpirationOutcome::TimedOut) => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + ( + synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), + true, + ) + } + Some(ExecExpirationOutcome::Cancelled) => { + // Let TERM-aware processes run cleanup briefly, then kill any + // remaining members of the original process group. + let process_group_id = child.id(); + let should_escalate = if let Some(process_group_id) = process_group_id { + codex_utils_pty::process_group::terminate_process_group(process_group_id)? + } else { + false + }; + match tokio::time::timeout( + CANCELLATION_TERMINATION_GRACE_PERIOD, + child.wait(), + ) + .await + { + Ok(status) => { + status?; + if should_escalate + && let Some(process_group_id) = process_group_id + { + codex_utils_pty::process_group::kill_process_group( + process_group_id, + )?; + } + } + Err(_) => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + } + } + (synthetic_exit_status_for_code(/*code*/ 1), false) + } + None => unreachable!("expiration wait only resolves while expiration is active"), + } } _ = tokio::signal::ctrl_c() => { kill_child_process_group(&mut child)?; diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 23db019bb9..504bac622e 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -1128,6 +1128,115 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn process_exec_tool_call_cancellation_allows_sigterm_cleanup() -> Result<()> { + let temp_dir = tempfile::TempDir::new()?; + let ready_marker = temp_dir.path().join("ready"); + let cleanup_marker = temp_dir.path().join("cleanup"); + let descendant_pid_marker = temp_dir.path().join("descendant-pid"); + // The parent handles TERM and records cleanup, while a TERM-ignoring child + // proves cancellation still escalates any survivors in the process group. + let command = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + r#"(trap '' TERM; sleep 60) & +printf '%s' "$!" > "$DESCENDANT_PID_MARKER" +trap 'printf cleaned > "$CLEANUP_MARKER"; exit 0' TERM +printf ready > "$READY_MARKER" +while :; do sleep 1; done"# + .to_string(), + ]; + let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?; + let mut env: HashMap = std::env::vars().collect(); + env.insert( + "READY_MARKER".to_string(), + ready_marker.to_string_lossy().into_owned(), + ); + env.insert( + "CLEANUP_MARKER".to_string(), + cleanup_marker.to_string_lossy().into_owned(), + ); + env.insert( + "DESCENDANT_PID_MARKER".to_string(), + descendant_pid_marker.to_string_lossy().into_owned(), + ); + let cancel_token = CancellationToken::new(); + let cancel_tx = cancel_token.clone(); + tokio::spawn(async move { + for _ in 0..50 { + if ready_marker.exists() { + cancel_tx.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + cancel_tx.cancel(); + }); + let params = ExecParams { + command, + cwd: cwd.clone(), + expiration: ExecExpiration::DefaultTimeout.with_cancellation(cancel_token), + capture_policy: ExecCapturePolicy::ShellTool, + env, + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + justification: None, + arg0: None, + }; + + let result = timeout( + Duration::from_secs(5), + process_exec_tool_call( + params, + &PermissionProfile::Disabled, + &cwd, + &None, + /*use_legacy_landlock*/ false, + /*stdout_stream*/ None, + ), + ) + .await + .expect("cancellation should stop the process promptly"); + let output = result.expect("cancellation should return a non-timeout exec result"); + assert!(!output.timed_out); + assert_eq!( + std::fs::read_to_string(cleanup_marker)?, + "cleaned", + "SIGTERM cleanup trap should run before cancellation falls back to a hard kill" + ); + let descendant_pid = std::fs::read_to_string(descendant_pid_marker)? + .parse::() + .map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to parse descendant pid: {error}"), + ) + })?; + let mut killed = false; + for _ in 0..20 { + if unsafe { libc::kill(descendant_pid, 0) } == -1 + && let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error() + { + killed = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + if !killed { + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + assert!( + killed, + "TERM-ignoring descendant process with pid {descendant_pid} is still alive" + ); + Ok(()) +} + #[cfg(unix)] fn long_running_command() -> Vec { vec![ diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5cb8f438ab..f8bc8ae4e4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -10131,6 +10131,76 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { ExecApprovalRequirement::Skip { .. } )); } + +#[cfg(unix)] +#[tokio::test] +async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> { + let session = make_session_with_config(|config| { + let cwd = config.cwd.clone(); + config + .permissions + .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) + .expect("test setup should allow sandbox policy"); + }) + .await?; + let turn_context = session.new_default_turn().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let temp_dir = tempfile::TempDir::new()?; + let ready_marker = temp_dir.path().join("ready"); + let cleanup_marker = temp_dir.path().join("cleanup"); + // Interrupt after the shell starts, then verify dispatch waits for its TERM cleanup trap. + let command = format!( + r#"trap 'printf cleaned > "{}"; exit 0' TERM +printf ready > "{}" +while :; do sleep 1; done"#, + cleanup_marker.display(), + ready_marker.display(), + ); + let item = ResponseItem::FunctionCall { + id: None, + name: "shell_command".to_string(), + namespace: None, + arguments: serde_json::json!({ + "command": command, + "timeout_ms": 60_000, + }) + .to_string(), + call_id: "shell-cleanup-call".to_string(), + }; + let call = ToolRouter::build_tool_call(item)? + .expect("shell command response item should build a tool call"); + let cancellation_token = CancellationToken::new(); + let cancellation_tx = cancellation_token.clone(); + let handle = tokio::spawn( + test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)) + .handle_tool_call(call, cancellation_token), + ); + + let mut ready = false; + for _ in 0..50 { + if ready_marker.exists() { + ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + if !ready { + cancellation_tx.cancel(); + let _ = timeout(Duration::from_secs(5), handle).await; + anyhow::bail!("shell command should reach the ready marker"); + } + + cancellation_tx.cancel(); + timeout(Duration::from_secs(5), handle) + .await + .expect("cancelled shell tool should finish promptly") + .expect("shell tool task should join") + .expect("cancelled shell tool should return a response item"); + assert_eq!(std::fs::read_to_string(cleanup_marker)?, "cleaned"); + Ok(()) +} + #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::sandboxing::SandboxPermissions; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 09eb29b1e4..c890e0d8da 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -2,6 +2,7 @@ use codex_features::Feature; use codex_protocol::models::ShellCommandToolCallParams; use serde_json::Value as JsonValue; use std::sync::Arc; +use tokio_util::sync::CancellationToken; use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; @@ -44,6 +45,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { struct RunExecLikeArgs { tool_name: ToolName, exec_params: ExecParams, + cancellation_token: CancellationToken, hook_command: String, shell_type: Option, additional_permissions: Option, @@ -59,6 +61,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { let ToolInvocation { session, turn, + cancellation_token, tracker, call_id, payload, @@ -185,6 +186,7 @@ impl ToolExecutor for ShellCommandHandler { run_exec_like(RunExecLikeArgs { tool_name, exec_params, + cancellation_token, hook_command: params.command, shell_type, additional_permissions: params.additional_permissions.clone(), @@ -205,6 +207,10 @@ impl CoreToolRuntime for ShellCommandHandler { matches!(payload, ToolPayload::Function { .. }) } + fn waits_for_runtime_cancellation(&self) -> bool { + true + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { tool_name: HookToolName::bash(), diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index bac6361329..7db1ec9643 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -92,6 +92,7 @@ impl ToolCallRuntime { let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); + let wait_for_runtime_cancellation = self.router.tool_waits_for_runtime_cancellation(&call); let started = Instant::now(); let abort_session = Arc::clone(&session); let abort_source = source.clone(); @@ -140,23 +141,35 @@ impl ToolCallRuntime { } else { let secs = started.elapsed().as_secs_f32().max(0.1); abort_dispatch_span.record("aborted", true); - handle.abort(); - match handle.await { - Ok(result) => result, - Err(err) if err.is_cancelled() => { - let response = Self::aborted_response(&call, secs); - notify_tool_aborted( - abort_session.as_ref(), - abort_turn.as_ref(), - call.call_id.as_str(), - &call.tool_name, - abort_source, - ) - .await; - Ok(response) + if wait_for_runtime_cancellation { + if terminal_outcome_reached.swap(true, Ordering::AcqRel) { + return handle.await.map_err(Self::tool_task_join_error)?; + } + // The abort owns the terminal outcome; await only so + // the runtime can finish process teardown. + match handle.await { + Ok(_) => {} + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(Self::tool_task_join_error(err)), + } + } else { + handle.abort(); + match handle.await { + Ok(result) => return result, + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(Self::tool_task_join_error(err)), } - Err(err) => Err(Self::tool_task_join_error(err)), } + let response = Self::aborted_response(&call, secs); + notify_tool_aborted( + abort_session.as_ref(), + abort_turn.as_ref(), + call.call_id.as_str(), + &call.tool_name, + abort_source, + ) + .await; + Ok(response) } }, } @@ -274,6 +287,85 @@ mod tests { impl CoreToolRuntime for ImmediateHandler {} + struct CancellationCleanupHandler { + tool_name: codex_tools::ToolName, + started: std::sync::Mutex>>, + cleanup_started: std::sync::Mutex>>, + allow_cleanup: Arc, + } + + #[async_trait::async_trait] + impl ToolExecutor for CancellationCleanupHandler { + fn tool_name(&self) -> codex_tools::ToolName { + self.tool_name.clone() + } + + fn spec(&self) -> codex_tools::ToolSpec { + codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool { + name: self.tool_name.name.clone(), + description: "Cancellation cleanup test tool.".to_string(), + strict: false, + defer_loading: None, + parameters: codex_tools::JsonSchema::default(), + output_schema: None, + }) + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let started = self + .started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(started) = started { + let _ = started.send(()); + } + invocation.cancellation_token.cancelled().await; + let cleanup_started = self + .cleanup_started + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + if let Some(cleanup_started) = cleanup_started { + let _ = cleanup_started.send(()); + } + self.allow_cleanup.notified().await; + Ok(Box::new(FunctionToolOutput::from_text( + "cleanup complete".to_string(), + Some(false), + ))) + } + } + + impl CoreToolRuntime for CancellationCleanupHandler { + fn waits_for_runtime_cancellation(&self) -> bool { + true + } + } + + struct FinishRecorder { + records: Arc>>, + } + + impl codex_extension_api::ToolLifecycleContributor for FinishRecorder { + fn on_tool_finish<'a>( + &'a self, + input: codex_extension_api::ToolFinishInput<'a>, + ) -> codex_extension_api::ToolLifecycleFuture<'a> { + let records = Arc::clone(&self.records); + let outcome = input.outcome; + Box::pin(async move { + records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(outcome); + }) + } + } + struct BlockingFinishContributor { records: Arc>>, finish_started: std::sync::Mutex>>, @@ -375,4 +467,75 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn cancellation_waiting_for_runtime_cleanup_emits_only_aborted_lifecycle() + -> anyhow::Result<()> { + let (mut session, turn_context) = crate::session::tests::make_session_and_context().await; + let records = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut builder = + codex_extension_api::ExtensionRegistryBuilder::::new(); + builder.tool_lifecycle_contributor(Arc::new(FinishRecorder { + records: Arc::clone(&records), + })); + session.services.extensions = Arc::new(builder.build()); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let tool_name = codex_tools::ToolName::plain("cleanup_tool"); + let (started_tx, started_rx) = oneshot::channel(); + let (cleanup_started_tx, cleanup_started_rx) = oneshot::channel(); + let allow_cleanup = Arc::new(Notify::new()); + let handler = Arc::new(CancellationCleanupHandler { + tool_name: tool_name.clone(), + started: std::sync::Mutex::new(Some(started_tx)), + cleanup_started: std::sync::Mutex::new(Some(cleanup_started_tx)), + allow_cleanup: Arc::clone(&allow_cleanup), + }) as Arc; + let router = Arc::new(ToolRouter::from_parts( + ToolRegistry::from_tools([handler]), + Vec::new(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let runtime = ToolCallRuntime::new(router, session, turn_context, tracker); + let cancellation_token = CancellationToken::new(); + let call = ToolCall { + tool_name, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + + let response_task = + tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); + started_rx.await.expect("handler should start"); + cancellation_token.cancel(); + cleanup_started_rx + .await + .expect("handler should start cleanup"); + tokio::time::sleep(Duration::from_millis(10)).await; + allow_cleanup.notify_one(); + + let response = tokio::time::timeout(Duration::from_secs(1), response_task) + .await + .expect("timed out waiting for tool response") + .expect("tool response task should join")?; + let ResponseInputItem::FunctionCallOutput { output, .. } = response else { + anyhow::bail!("cancelled tool should return function output"); + }; + let FunctionCallOutputBody::Text(text) = output.body else { + anyhow::bail!("cancelled tool output should be text"); + }; + assert!(text.contains("aborted by user")); + + let actual = records + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .drain(..) + .collect::>(); + assert_eq!(vec![ToolCallOutcome::Aborted], actual); + + Ok(()) + } } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index a08d5c7834..f0fdf424a9 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -57,6 +57,12 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { ) } + /// Whether cancellation should let the handler finish teardown before the + /// host returns an aborted tool response. + fn waits_for_runtime_cancellation(&self) -> bool { + false + } + fn telemetry_tags<'a>( &'a self, _invocation: &'a ToolInvocation, @@ -284,6 +290,10 @@ impl CoreToolRuntime for ExposureOverride { self.handler.matches_kind(payload) } + fn waits_for_runtime_cancellation(&self) -> bool { + self.handler.waits_for_runtime_cancellation() + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { self.handler.pre_tool_use_payload(invocation) } @@ -381,6 +391,11 @@ impl ToolRegistry { Some(tool.supports_parallel_tool_calls()) } + pub(crate) fn waits_for_runtime_cancellation(&self, name: &ToolName) -> Option { + let tool = self.tool(name)?; + Some(tool.waits_for_runtime_cancellation()) + } + #[allow(dead_code)] pub(crate) async fn dispatch_any( &self, @@ -497,10 +512,12 @@ impl ToolRegistry { PreToolUseHookResult::Blocked(message) => { let err = FunctionCallError::RespondToModel(message); dispatch_trace.record_failed(&err); - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish(&invocation, ToolCallOutcome::Blocked).await; + notify_tool_finish_if_unclaimed( + &invocation, + terminal_outcome_reached.as_deref(), + ToolCallOutcome::Blocked, + ) + .await; return Err(err); } PreToolUseHookResult::Continue { @@ -511,11 +528,9 @@ impl ToolRegistry { } Err(err) => { dispatch_trace.record_failed(&err); - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish( + notify_tool_finish_if_unclaimed( &invocation, + terminal_outcome_reached.as_deref(), ToolCallOutcome::Failed { handler_executed: false, }, @@ -638,18 +653,21 @@ impl ToolRegistry { handler_executed: true, }, }; - if let Some(terminal_outcome_reached) = &terminal_outcome_reached { - terminal_outcome_reached.store(true, Ordering::Release); - } - notify_tool_finish(&invocation, lifecycle_outcome).await; + let finished = notify_tool_finish_if_unclaimed( + &invocation, + terminal_outcome_reached.as_deref(), + lifecycle_outcome, + ) + .await; - if let Err(err) = invocation - .session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: invocation.turn.as_ref(), - tool_name: tool_name.name.as_str(), - }) - .await + if finished + && let Err(err) = invocation + .session + .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { + turn_context: invocation.turn.as_ref(), + tool_name: tool_name.name.as_str(), + }) + .await { warn!("failed to account thread goal progress after tool call: {err}"); } @@ -676,6 +694,19 @@ impl ToolRegistry { } } +async fn notify_tool_finish_if_unclaimed( + invocation: &ToolInvocation, + terminal_outcome_reached: Option<&AtomicBool>, + outcome: ToolCallOutcome, +) -> bool { + if terminal_outcome_reached.is_some_and(|reached| reached.swap(true, Ordering::AcqRel)) { + return false; + } + + notify_tool_finish(invocation, outcome).await; + true +} + async fn handle_any_tool( tool: &dyn CoreToolRuntime, invocation: ToolInvocation, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index cfd4776890..a095499ea0 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -86,6 +86,12 @@ impl ToolRouter { .unwrap_or(false) } + pub fn tool_waits_for_runtime_cancellation(&self, call: &ToolCall) -> bool { + self.registry + .waits_for_runtime_cancellation(&call.tool_name) + .unwrap_or(false) + } + #[instrument(level = "trace", skip_all, err)] pub fn build_tool_call(item: ResponseItem) -> Result, FunctionCallError> { match item { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6251267773..df63f6cd47 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -44,6 +44,7 @@ use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use std::collections::HashMap; +use tokio_util::sync::CancellationToken; #[derive(Clone, Debug)] pub struct ShellRequest { @@ -52,6 +53,7 @@ pub struct ShellRequest { pub hook_command: String, pub cwd: AbsolutePathBuf, pub timeout_ms: Option, + pub cancellation_token: CancellationToken, pub env: HashMap, pub explicit_env_overrides: HashMap, pub network: Option, @@ -265,6 +267,7 @@ impl ToolRuntime for ShellRuntime { let command = build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())?; let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into(); + expiration = expiration.with_cancellation(req.cancellation_token.clone()); if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() { expiration = expiration.with_cancellation(cancellation); } diff --git a/codex-rs/utils/pty/src/process_group.rs b/codex-rs/utils/pty/src/process_group.rs index f8056acc66..22a934fab7 100644 --- a/codex-rs/utils/pty/src/process_group.rs +++ b/codex-rs/utils/pty/src/process_group.rs @@ -94,7 +94,7 @@ pub fn kill_process_group_by_pid(pid: u32) -> io::Result<()> { let pgid = unsafe { libc::getpgid(pid) }; if pgid == -1 { let err = io::Error::last_os_error(); - if err.kind() != ErrorKind::NotFound { + if err.kind() != ErrorKind::NotFound && err.raw_os_error() != Some(libc::ESRCH) { return Err(err); } return Ok(()); @@ -103,7 +103,7 @@ pub fn kill_process_group_by_pid(pid: u32) -> io::Result<()> { let result = unsafe { libc::killpg(pgid, libc::SIGKILL) }; if result == -1 { let err = io::Error::last_os_error(); - if err.kind() != ErrorKind::NotFound { + if err.kind() != ErrorKind::NotFound && err.raw_os_error() != Some(libc::ESRCH) { return Err(err); } } @@ -129,7 +129,7 @@ pub fn terminate_process_group(process_group_id: u32) -> io::Result { let result = unsafe { libc::killpg(pgid, libc::SIGTERM) }; if result == -1 { let err = io::Error::last_os_error(); - if err.kind() == ErrorKind::NotFound { + if err.kind() == ErrorKind::NotFound || err.raw_os_error() == Some(libc::ESRCH) { return Ok(false); } return Err(err); @@ -153,7 +153,7 @@ pub fn kill_process_group(process_group_id: u32) -> io::Result<()> { let result = unsafe { libc::killpg(pgid, libc::SIGKILL) }; if result == -1 { let err = io::Error::last_os_error(); - if err.kind() != ErrorKind::NotFound { + if err.kind() != ErrorKind::NotFound && err.raw_os_error() != Some(libc::ESRCH) { return Err(err); } } From 7a26497836be29ec77fb8007347b33cad952b497 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 27 May 2026 17:14:55 -0300 Subject: [PATCH 043/204] feat(tui): add OSC 8 web links to rich content (#24472) ## Why Wrapped URLs in rich TUI output, especially URLs rendered inside Markdown tables, are split across terminal rows. In terminals that support OSC 8 hyperlinks, treating each visible fragment as part of the complete destination enables reliable open-link and copy-link actions even after table layout wraps the URL. This addresses the semantic-link portion of #12200 and the behavior described in https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It does not change ordinary drag-selection across bordered table rows. ## What Changed - Added shared TUI OSC 8 support that validates `http://` and `https://` destinations, sanitizes terminal payloads, and applies metadata separately from visible line width/layout. - Added semantic web-link annotations to assistant and proposed-plan Markdown, including explicit web links and bare web URLs in prose and table cells while excluding code and non-web Markdown destinations. - Preserved complete URL targets through table wrapping, narrow pipe fallback, streaming, transcript overlay rendering, history insertion, and resize replay. - Routed intentional Codex-owned links in notices, status/setup/app-link, feedback, onboarding, MCP/plugin help, memories, and update surfaces through the shared hyperlink handling. ## How to Test 1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and request an assistant response containing a Markdown table whose last column contains a long `https://` URL. 2. Make the terminal narrow enough for the URL to wrap across multiple bordered table rows. 3. Use the terminal's open-link or copy-link action on more than one wrapped URL fragment and confirm each fragment resolves to the complete original URL. 4. Resize the terminal after the table is rendered and repeat the link action to confirm the destination survives scrollback replay. 5. Open the transcript overlay while rich output is present and confirm web links remain interactive there. 6. As a regression check, render inline/fenced code containing URL text and a Markdown link such as `[https://example.com](mailto:support@example.com)`; confirm these do not acquire a web OSC 8 destination. Targeted automated coverage exercised Markdown links and exclusions, wrapped and pipe-fallback tables, streaming/transcript overlay propagation, status-link truncation, and rendered word-wrapping cell alignment. `just test -p codex-tui` was also run; it passed the hyperlink coverage and reproduced two unrelated existing guardian feature-flag test failures. --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/resize_reflow.rs | 45 +- codex-rs/tui/src/app/tests.rs | 11 +- codex-rs/tui/src/app_backtrack.rs | 14 +- codex-rs/tui/src/bottom_pane/app_link_view.rs | 11 +- codex-rs/tui/src/bottom_pane/feedback_view.rs | 4 +- .../src/bottom_pane/memories_settings_view.rs | 1 + codex-rs/tui/src/chatwidget.rs | 23 +- .../chatwidget/tests/popups_and_settings.rs | 2 +- codex-rs/tui/src/history_cell/base.rs | 61 ++ codex-rs/tui/src/history_cell/mcp.rs | 9 +- codex-rs/tui/src/history_cell/messages.rs | 67 +- codex-rs/tui/src/history_cell/mod.rs | 39 +- codex-rs/tui/src/history_cell/notices.rs | 16 + codex-rs/tui/src/history_cell/plans.rs | 57 +- codex-rs/tui/src/history_cell/tests.rs | 41 ++ codex-rs/tui/src/insert_history.rs | 71 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/markdown.rs | 19 +- codex-rs/tui/src/markdown_render.rs | 421 +++++++++--- codex-rs/tui/src/onboarding/auth.rs | 60 +- codex-rs/tui/src/pager_overlay.rs | 94 ++- codex-rs/tui/src/status/card.rs | 39 +- codex-rs/tui/src/status/tests.rs | 19 + codex-rs/tui/src/streaming/controller.rs | 150 +++-- codex-rs/tui/src/streaming/mod.rs | 16 +- codex-rs/tui/src/terminal_hyperlinks.rs | 627 ++++++++++++++++++ codex-rs/tui/src/tui.rs | 17 +- codex-rs/tui/src/update_prompt.rs | 7 +- 29 files changed, 1659 insertions(+), 287 deletions(-) create mode 100644 codex-rs/tui/src/terminal_hyperlinks.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8c89c9153b..bd0cc45548 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -473,7 +473,7 @@ struct SessionSummary { #[derive(Debug, Default)] struct InitialHistoryReplayBuffer { - retained_lines: VecDeque>, + retained_lines: VecDeque, render_from_transcript_tail: bool, } @@ -498,7 +498,7 @@ pub(crate) struct App { // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, - pub(crate) deferred_history_lines: Vec>, + pub(crate) deferred_history_lines: Vec, has_emitted_history_lines: bool, transcript_reflow: TranscriptReflowState, initial_history_replay_buffer: Option, diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 6ef1721266..34e25697e2 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -27,11 +27,12 @@ use super::InitialHistoryReplayBuffer; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::insert_history::HistoryLineWrapPolicy; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; use crate::tui; struct ReflowCellDisplay { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } @@ -41,7 +42,7 @@ struct ReflowCellDisplay { /// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the /// rows here are a transient render product for a single terminal width. pub(super) struct ReflowRenderResult { - pub(super) lines: Vec>, + pub(super) lines: Vec, } pub(super) fn trailing_run_start(transcript_cells: &[Arc]) -> usize { @@ -75,12 +76,12 @@ impl App { &mut self, cell: &dyn HistoryCell, width: u16, - ) -> Vec> { + ) -> Vec { let mut display = - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); if !display.is_empty() && !cell.is_stream_continuation() { if self.has_emitted_history_lines { - display.insert(0, Line::from("")); + display.insert(/*index*/ 0, HyperlinkLine::new(Line::from(""))); } else { self.has_emitted_history_lines = true; } @@ -101,7 +102,10 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } @@ -153,14 +157,20 @@ impl App { let width = tui.terminal.last_known_screen_size.width; let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines; if !reflowed_lines.is_empty() { - tui.insert_history_lines(reflowed_lines); + tui.insert_history_hyperlink_lines_with_wrap_policy( + reflowed_lines, + self.history_line_wrap_policy(), + ); } } return; } let retained_lines = buffer.retained_lines.into_iter().collect::>(); - tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + retained_lines, + self.history_line_wrap_policy(), + ); } pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( @@ -190,7 +200,10 @@ impl App { } else if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + display, + self.history_line_wrap_policy(), + ); } } } @@ -210,7 +223,7 @@ impl App { /// here would make copy, transcript overlay, and future replay paths disagree about history. pub(super) fn buffer_initial_history_replay_display_lines( buffer: &mut InitialHistoryReplayBuffer, - display: Vec>, + display: Vec, max_rows: usize, ) { buffer.retained_lines.extend(display); @@ -437,7 +450,7 @@ impl App { self.deferred_history_lines.clear(); if !reflowed_lines.is_empty() { - tui.insert_history_lines_with_wrap_policy( + tui.insert_history_hyperlink_lines_with_wrap_policy( reflowed_lines, self.history_line_wrap_policy(), ); @@ -462,7 +475,8 @@ impl App { while start > 0 { start -= 1; let cell = self.transcript_cells[start].clone(); - let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()); + let lines = cell + .display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode()); rendered_rows += lines.len(); cell_displays.push_front(ReflowCellDisplay { lines, @@ -482,7 +496,10 @@ impl App { start -= 1; let cell = self.transcript_cells[start].clone(); cell_displays.push_front(ReflowCellDisplay { - lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + lines: cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), is_stream_continuation: cell.is_stream_continuation(), }); } @@ -492,7 +509,7 @@ impl App { for display in cell_displays { if !display.lines.is_empty() && !display.is_stream_continuation { if has_emitted_history_lines { - reflowed_lines.push(Line::from("")); + reflowed_lines.push(HyperlinkLine::new(Line::from(""))); } else { has_emitted_history_lines = true; } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index c2138ba31a..f4c8a4df79 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3906,8 +3906,9 @@ fn plain_line_cell(text: impl Into) -> Arc { Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc } -fn rendered_line_text(line: &Line<'static>) -> String { - line.spans +fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String { + line.line + .spans .iter() .map(|span| span.content.as_ref()) .collect() @@ -4019,7 +4020,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { app.initial_history_replay_buffer .as_mut() .expect("initial replay buffer active"), - vec![Line::from(format!("line {index}"))], + vec![Line::from(format!("line {index}")).into()], /*max_rows*/ 3, ); } @@ -4925,7 +4926,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() { app.transcript_cells.clone(), app.keymap.pager.clone(), )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.backtrack.overlay_preview_active = true; app.backtrack.nth_user_message = 1; @@ -5459,7 +5460,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { app.transcript_cells.clone(), crate::keymap::RuntimeKeymap::defaults().pager, )); - app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.deferred_history_lines = vec![Line::from("stale buffered line").into()]; app.has_emitted_history_lines = true; app.backtrack.primed = true; app.backtrack.overlay_preview_active = true; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 625f8d0841..075a4a5ae2 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -245,7 +245,10 @@ impl App { let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); - tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy()); + tui.insert_history_hyperlink_lines_with_wrap_policy( + lines, + self.history_line_wrap_policy(), + ); } self.overlay = None; self.backtrack.overlay_preview_active = false; @@ -263,8 +266,11 @@ impl App { .chat_widget .history_wrap_width(tui.terminal.last_known_screen_size.width); for cell in &self.transcript_cells { - tui.insert_history_lines_with_wrap_policy( - cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), + tui.insert_history_hyperlink_lines_with_wrap_policy( + cell.display_hyperlink_lines_for_mode( + width, + self.chat_widget.history_render_mode(), + ), self.history_line_wrap_policy(), ); } @@ -399,7 +405,7 @@ impl App { tui.draw(u16::MAX, |frame| { let width = frame.area().width.max(1); t.sync_live_tail(width, active_key, |w| { - chat_widget.active_cell_transcript_lines(w) + chat_widget.active_cell_transcript_hyperlink_lines(w) }); t.render(frame.area(), frame.buffer); })?; diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 7fe180a16f..bbd7f355c3 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -814,6 +814,7 @@ impl crate::render::renderable::Renderable for AppLinkView { Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(inner, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url); if actions_area.height > 0 { let actions_area = Rect { @@ -1005,7 +1006,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() @@ -1325,7 +1329,10 @@ mod tests { if symbol.is_empty() { ' ' } else { - symbol.chars().next().unwrap_or(' ') + crate::terminal_hyperlinks::strip_osc8(symbol) + .chars() + .next() + .unwrap_or(' ') } }) .collect::() diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 2770598fc4..ce380a867d 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -313,7 +313,7 @@ pub(crate) fn feedback_success_cell( include_logs: bool, thread_id: &str, feedback_audience: FeedbackAudience, -) -> history_cell::PlainHistoryCell { +) -> history_cell::WebHyperlinkHistoryCell { let prefix = if include_logs { "• Feedback uploaded." } else { @@ -359,7 +359,7 @@ pub(crate) fn feedback_success_cell( ]); } } - history_cell::PlainHistoryCell::new(lines) + history_cell::WebHyperlinkHistoryCell::new(lines) } fn issue_url_for_category( diff --git a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs index 06e9b31eb8..793596b468 100644 --- a/codex-rs/tui/src/bottom_pane/memories_settings_view.rs +++ b/codex-rs/tui/src/bottom_pane/memories_settings_view.rs @@ -421,6 +421,7 @@ impl Renderable for MemoriesSettingsView { } if self.reset_confirmation.is_none() { self.docs_link.clone().render(docs_area, buf); + crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL); } let hint_area = Rect { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 16ac980f35..d3e5cf68ef 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -10,7 +10,8 @@ //! visible immediately. //! //! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail -//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The +//! during draws using `active_cell_transcript_key()` and +//! `active_cell_transcript_hyperlink_lines()`. The //! cache key is designed to change when the active cell mutates in place or when its transcript //! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on //! every draw. @@ -77,6 +78,7 @@ use crate::status::StatusHistoryHandle; use crate::status::format_directory_display; use crate::status::format_tokens_compact; use crate::status::rate_limit_snapshot_display_for_limit; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_title::SetTerminalTitleResult; use crate::terminal_title::clear_terminal_title; use crate::terminal_title::set_terminal_title; @@ -1824,28 +1826,37 @@ impl ChatWidget { }) } - /// Returns the active cell's transcript lines for a given terminal width. + /// Returns the active cell's annotated transcript lines for a given terminal width. /// /// This is a convenience for the transcript overlay live-tail path, and it intentionally /// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers /// should pass the same width the overlay uses; using a different width will cause wrapping /// mismatches between the main viewport and the transcript overlay. - pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + pub(crate) fn active_cell_transcript_hyperlink_lines( + &self, + width: u16, + ) -> Option> { let mut lines = Vec::new(); if let Some(cell) = self.transcript.active_cell.as_ref() { - lines.extend(cell.transcript_lines(width)); + lines.extend(cell.transcript_hyperlink_lines(width)); } if let Some(hook_cell) = self.active_hook_cell.as_ref() { // Compute hook lines first so hidden hooks do not add a separator. - let hook_lines = hook_cell.transcript_lines(width); + let hook_lines = hook_cell.transcript_hyperlink_lines(width); if !hook_lines.is_empty() && !lines.is_empty() { - lines.push("".into()); + lines.push(HyperlinkLine::from("")); } lines.extend(hook_lines); } (!lines.is_empty()).then_some(lines) } + #[cfg(test)] + pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { + self.active_cell_transcript_hyperlink_lines(width) + .map(crate::terminal_hyperlinks::visible_lines) + } + /// Return a reference to the widget's current config (includes any /// runtime overrides applied via TUI, e.g., model or approval policy). pub(crate) fn config_ref(&self) -> &Config { diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 55aadefb6d..ade74b8908 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -2200,7 +2200,7 @@ async fn memories_settings_popup_snapshot() { chat.open_memories_popup(); - let popup = render_bottom_popup(&chat, /*width*/ 80); + let popup = strip_osc8_for_snapshot(&render_bottom_popup(&chat, /*width*/ 80)); assert_chatwidget_snapshot!("memories_settings_popup", popup); } diff --git a/codex-rs/tui/src/history_cell/base.rs b/codex-rs/tui/src/history_cell/base.rs index e3b33aa9d1..e9ade84945 100644 --- a/codex-rs/tui/src/history_cell/base.rs +++ b/codex-rs/tui/src/history_cell/base.rs @@ -22,6 +22,35 @@ impl HistoryCell for PlainHistoryCell { plain_lines(self.lines.clone()) } } + +#[derive(Debug)] +pub(crate) struct WebHyperlinkHistoryCell { + lines: Vec>, +} + +impl WebHyperlinkHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl HistoryCell for WebHyperlinkHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.lines.clone()) + } +} #[derive(Debug)] pub(crate) struct PrefixedWrappedHistoryCell { text: Text<'static>, @@ -86,6 +115,38 @@ impl HistoryCell for CompositeHistoryCell { out } + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.display_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + let mut out = Vec::new(); + let mut first = true; + for part in &self.parts { + let mut lines = part.transcript_hyperlink_lines(width); + if !lines.is_empty() { + if !first { + out.push(HyperlinkLine::from("")); + } + out.append(&mut lines); + first = false; + } + } + out + } + fn raw_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); let mut first = true; diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 7b1e8eebc4..599a44f092 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -325,14 +325,17 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}" - .underlined(), + crate::terminal_hyperlinks::osc8_hyperlink( + "https://developers.openai.com/codex/mcp", + "MCP docs", + ) + .underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), ]; - PlainHistoryCell { lines } + PlainHistoryCell::new(lines) } #[cfg(test)] diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 19a6ec2903..ed899f3b2e 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -268,12 +268,20 @@ impl HistoryCell for ReasoningSummaryCell { #[derive(Debug)] pub(crate) struct AgentMessageCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl AgentMessageCell { + #[cfg(test)] pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines: plain_hyperlink_lines(lines), + is_first_line, + } + } + + pub(crate) fn new_hyperlink_lines(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -283,6 +291,10 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let mut wrapped = Vec::new(); for (index, line) in self.lines.iter().enumerate() { let initial_indent = if index == 0 && self.is_first_line { @@ -293,20 +305,23 @@ impl HistoryCell for AgentMessageCell { let mut subsequent_indent = Line::from(" "); subsequent_indent .spans - .extend(crate::insert_history::leading_whitespace_prefix(line).spans); - let line_wrapped = adaptive_wrap_line( - line, + .extend(crate::insert_history::leading_whitespace_prefix(&line.line).spans); + wrapped.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines( + std::slice::from_ref(line), RtOptions::new(width as usize) .initial_indent(initial_indent) .subsequent_indent(subsequent_indent), - ); - push_owned_lines(&line_wrapped, &mut wrapped); + )); } wrapped } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -347,22 +362,32 @@ impl AgentMarkdownCell { impl HistoryCell for AgentMarkdownCell { fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { let Some(wrap_width) = crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) else { - return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); + return prefix_hyperlink_lines( + vec![HyperlinkLine::new(Line::default())], + "• ".dim(), + " ".into(), + ); }; - let mut lines: Vec> = Vec::new(); // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. - crate::markdown::append_markdown_agent_with_cwd( + let lines = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.markdown_source, Some(wrap_width), Some(self.cwd.as_path()), - &mut lines, ); - prefix_lines(lines, "• ".dim(), " ".into()) + prefix_hyperlink_lines(lines, "• ".dim(), " ".into()) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) } fn raw_lines(&self) -> Vec> { @@ -377,12 +402,12 @@ impl HistoryCell for AgentMarkdownCell { /// every delta and cleared when the stream finalizes. #[derive(Debug)] pub(crate) struct StreamingAgentTailCell { - lines: Vec>, + lines: Vec, is_first_line: bool, } impl StreamingAgentTailCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + pub(crate) fn new(lines: Vec, is_first_line: bool) -> Self { Self { lines, is_first_line, @@ -391,10 +416,14 @@ impl StreamingAgentTailCell { } impl HistoryCell for StreamingAgentTailCell { - fn display_lines(&self, _width: u16) -> Vec> { + fn display_lines(&self, width: u16) -> Vec> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_lines( + prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -405,8 +434,12 @@ impl HistoryCell for StreamingAgentTailCell { ) } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) + plain_lines(self.display_lines(/*width*/ u16::MAX)) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 12788a59f0..9cef3e4fa5 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -22,7 +22,6 @@ use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; -use crate::markdown::append_markdown_agent_with_cwd; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; @@ -33,6 +32,11 @@ use crate::render::renderable::Renderable; use crate::session_state::ThreadSessionState; use crate::style::proposed_plan_style; use crate::style::user_message_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::mark_buffer_hyperlinks; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::prefix_hyperlink_lines; +use crate::terminal_hyperlinks::visible_lines; #[cfg(test)] use crate::test_support::PathBufExt; #[cfg(test)] @@ -189,13 +193,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { /// Returns copy-friendly plain logical lines for raw scrollback mode. fn raw_lines(&self) -> Vec>; + /// Returns rich visible lines plus terminal hyperlink metadata. + fn display_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.display_lines(width)) + } + fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec> { match mode { - HistoryRenderMode::Rich => self.display_lines(width), + HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)), HistoryRenderMode::Raw => self.raw_lines(), } } + fn display_hyperlink_lines_for_mode( + &self, + width: u16, + mode: HistoryRenderMode, + ) -> Vec { + match mode { + HistoryRenderMode::Rich => self.display_hyperlink_lines(width), + HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()), + } + } + /// Returns the number of viewport rows needed to render this cell. /// /// The default delegates to `Paragraph::line_count` with @@ -224,13 +244,22 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { self.display_lines(width) } + /// Returns transcript-overlay lines plus terminal hyperlink metadata. + /// + /// Defaults to the plain transcript representation because some cells render different + /// display and transcript content. Rich cells whose transcript mirrors their display should + /// delegate to `display_hyperlink_lines`. + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + plain_hyperlink_lines(self.transcript_lines(width)) + } + /// Returns the number of viewport rows for the transcript overlay. /// /// Uses the same `Paragraph::line_count` measurement as /// `desired_height`. Contains a workaround for a ratatui bug where /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); + let lines = visible_lines(self.transcript_hyperlink_lines(width)); // Workaround: ratatui's line_count returns 2 for a single // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] @@ -270,7 +299,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { - let lines = self.display_lines(area.width); + let hyperlink_lines = self.display_hyperlink_lines(area.width); + let lines = visible_lines(hyperlink_lines.clone()); let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 @@ -284,6 +314,7 @@ impl Renderable for Box { // entire draw area first so stale glyphs from previous frames never linger. Clear.render(area, buf); paragraph.scroll((y, 0)).render(area, buf); + mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y)); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index 7fb994442c..d9dff4f5a0 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -71,6 +71,14 @@ impl HistoryCell for UpdateAvailableHistoryCell { Line::from("https://github.com/openai/codex/releases/latest"), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[allow(clippy::disallowed_methods)] pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { @@ -129,6 +137,14 @@ impl HistoryCell for CyberPolicyNoticeCell { Line::from(TRUSTED_ACCESS_FOR_CYBER_URL), ] } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width)) + } + + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index 6ea56195da..6b644f9e36 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -9,12 +9,12 @@ use super::*; /// preview-only during streaming. #[derive(Debug)] pub(crate) struct StreamingPlanTailCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl StreamingPlanTailCell { - pub(crate) fn new(lines: Vec>, is_stream_continuation: bool) -> Self { + pub(crate) fn new(lines: Vec, is_stream_continuation: bool) -> Self { Self { lines, is_stream_continuation, @@ -24,11 +24,19 @@ impl StreamingPlanTailCell { impl HistoryCell for StreamingPlanTailCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { @@ -58,11 +66,11 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl /// Stream cells are display fragments, not source-backed history. They should be replaced by /// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history. pub(crate) fn new_proposed_plan_stream( - lines: Vec>, + lines: Vec>, is_stream_continuation: bool, ) -> ProposedPlanStreamCell { ProposedPlanStreamCell { - lines, + lines: lines.into_iter().map(Into::into).collect(), is_stream_continuation, } } @@ -85,36 +93,43 @@ pub(crate) struct ProposedPlanCell { /// terminal resize. #[derive(Debug)] pub(crate) struct ProposedPlanStreamCell { - lines: Vec>, + lines: Vec, is_stream_continuation: bool, } impl HistoryCell for ProposedPlanCell { fn display_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = Vec::new(); - lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); - lines.push(Line::from(" ")); + visible_lines(self.display_hyperlink_lines(width)) + } - let mut plan_lines: Vec> = vec![Line::from(" ")]; + fn display_hyperlink_lines(&self, width: u16) -> Vec { + let mut lines = vec![ + HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()), + HyperlinkLine::new(Line::from(" ")), + ]; + + let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))]; let plan_style = proposed_plan_style(); let wrap_width = width.saturating_sub(4).max(1) as usize; - let mut body: Vec> = Vec::new(); - append_markdown_agent_with_cwd( + let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd( &self.plan_markdown, Some(wrap_width), Some(self.cwd.as_path()), - &mut body, ); if body.is_empty() { - body.push(Line::from("(empty)".dim().italic())); + body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic()))); } - plan_lines.extend(prefix_lines(body, " ".into(), " ".into())); - plan_lines.push(Line::from(" ")); + plan_lines.extend(prefix_hyperlink_lines(body, " ".into(), " ".into())); + plan_lines.push(HyperlinkLine::new(Line::from(" "))); lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style))); lines } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { raw_lines_from_source(&self.plan_markdown) } @@ -122,11 +137,19 @@ impl HistoryCell for ProposedPlanCell { impl HistoryCell for ProposedPlanStreamCell { fn display_lines(&self, _width: u16) -> Vec> { + visible_lines(self.lines.clone()) + } + + fn display_hyperlink_lines(&self, _width: u16) -> Vec { self.lines.clone() } + fn transcript_hyperlink_lines(&self, width: u16) -> Vec { + self.display_hyperlink_lines(width) + } + fn raw_lines(&self) -> Vec> { - plain_lines(self.lines.clone()) + plain_lines(visible_lines(self.lines.clone())) } fn is_stream_continuation(&self) -> bool { diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 6c83982a41..b4674cec8a 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -295,6 +295,47 @@ fn proposed_plan_cell_renders_markdown_table() { ); } +#[test] +fn proposed_plan_cell_preserves_wrapped_table_web_links() { + let destination = "https://example.com/a/very/long/path/to/a/table/artifact"; + let plan = new_proposed_plan( + format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n"), + &test_cwd(), + ); + + let lines = plan.display_hyperlink_lines(/*width*/ 32); + let linked_rows = lines + .iter() + .filter(|line| !line.hyperlinks.is_empty()) + .collect::>(); + + assert!(linked_rows.len() > 1); + assert!(linked_rows.iter().all(|line| { + line.hyperlinks + .iter() + .all(|link| link.destination == destination) + })); +} + +#[test] +fn composite_cell_preserves_child_web_links() { + let destination = "https://chatgpt.com/codex/settings/usage"; + let cell = CompositeHistoryCell::new(vec![ + Box::new(PlainHistoryCell::new(vec![Line::from("/status")])), + Box::new(WebHyperlinkHistoryCell::new(vec![Line::from(destination)])), + ]); + + let lines = cell.display_hyperlink_lines(/*width*/ 80); + + assert_eq!( + lines[2].hyperlinks, + vec![crate::terminal_hyperlinks::TerminalHyperlink { + columns: 0..destination.len(), + destination: destination.to_string(), + }] + ); +} + #[test] fn proposed_plan_cell_unwraps_markdown_fenced_table() { let plan = new_proposed_plan( diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 40da6ccbcc..760b516421 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -7,6 +7,11 @@ use std::fmt; use std::io; use std::io::Write; +use crate::render::line_utils::line_to_static; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::decorate_spans; +use crate::terminal_hyperlinks::plain_hyperlink_lines; +use crate::terminal_hyperlinks::remap_wrapped_line; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::line_contains_url_like; @@ -85,6 +90,23 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy( mode: InsertHistoryMode, wrap_policy: HistoryLineWrapPolicy, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal, + plain_hyperlink_lines(lines.iter().map(line_to_static).collect()), + mode, + wrap_policy, + ) +} + +pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, + wrap_policy: HistoryLineWrapPolicy, +) -> io::Result<()> where B: Backend + Write, { @@ -113,13 +135,21 @@ where let line_wrapped = match wrap_policy { HistoryLineWrapPolicy::Terminal => vec![line.clone()], HistoryLineWrapPolicy::PreWrap - if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) => + if line_contains_url_like(&line.line) + && !line_has_mixed_url_and_non_url_tokens(&line.line) => { vec![line.clone()] } - HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line( + HistoryLineWrapPolicy::PreWrap => remap_wrapped_line( line, - RtOptions::new(wrap_width).subsequent_indent(leading_whitespace_prefix(line)), + adaptive_wrap_line( + &line.line, + RtOptions::new(wrap_width) + .subsequent_indent(leading_whitespace_prefix(&line.line)), + ) + .into_iter() + .map(|line| line_to_static(&line)) + .collect(), ), }; wrapped_rows += line_wrapped @@ -249,7 +279,11 @@ pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { /// Render a single wrapped history line: clear continuation rows for wide lines, /// set foreground/background colors, and write styled spans. Caller is responsible /// for cursor positioning and any leading `\r\n`. -fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> { +fn write_history_line( + writer: &mut W, + line: &HyperlinkLine, + wrap_width: usize, +) -> io::Result<()> { let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; if physical_rows > 1 { queue!(writer, SavePosition)?; @@ -262,11 +296,13 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) queue!( writer, SetColors(Colors::new( - line.style + line.line + .style .fg .map(std::convert::Into::into) .unwrap_or(CColor::Reset), - line.style + line.line + .style .bg .map(std::convert::Into::into) .unwrap_or(CColor::Reset) @@ -276,14 +312,20 @@ fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) // Merge line-level style into each span so that ANSI colors reflect // line styles (e.g., blockquotes with green fg). let merged_spans: Vec = line + .line .spans .iter() .map(|s| Span { - style: s.style.patch(line.style), + style: s.style.patch(line.line.style), content: s.content.clone(), }) .collect(); - write_spans(writer, merged_spans.iter()) + let merged_line = HyperlinkLine { + line: Line::from(merged_spans), + hyperlinks: line.hyperlinks.clone(), + }; + let decorated = decorate_spans(&merged_line); + write_spans(writer, decorated.iter()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -470,6 +512,19 @@ mod tests { ); } + #[test] + fn writes_semantic_web_link_without_changing_visible_text() { + let destination = "https://example.com/long/path"; + let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination)); + let mut actual = Vec::new(); + + write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line"); + + let output = String::from_utf8(actual).expect("UTF-8 terminal output"); + assert!(output.contains("\x1b]8;;https://example.com/long/path\x07")); + assert_eq!(line.line.spans[0].content, destination); + } + #[test] fn vt100_blockquote_line_emits_green_fg() { // Set up a small off-screen terminal diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 34065b7df8..a90fd7a362 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -177,6 +177,7 @@ mod status; mod status_indicator_widget; mod streaming; mod style; +mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 0cabee0805..31b8d09a76 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -25,6 +25,7 @@ use std::ops::Range; use std::path::Path; use crate::table_detect; +use crate::terminal_hyperlinks::HyperlinkLine; /// Render markdown source to styled ratatui lines and append them to `lines`. /// @@ -56,20 +57,22 @@ pub(crate) fn append_markdown_agent( width: Option, lines: &mut Vec>, ) { - append_markdown_agent_with_cwd(markdown_source, width, /*cwd*/ None, lines); + let normalized = unwrap_markdown_fences(markdown_source); + let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd( + &normalized, + width, + /*cwd*/ None, + ); + crate::render::line_utils::push_owned_lines(&rendered.lines, lines); } -/// Render an agent message while resolving local file links relative to `cwd`. -pub(crate) fn append_markdown_agent_with_cwd( +pub(crate) fn render_markdown_agent_with_links_and_cwd( markdown_source: &str, width: Option, cwd: Option<&Path>, - lines: &mut Vec>, -) { +) -> Vec { let normalized = unwrap_markdown_fences(markdown_source); - let rendered = - crate::markdown_render::render_markdown_text_with_width_and_cwd(&normalized, width, cwd); - crate::render::line_utils::push_owned_lines(&rendered.lines, lines); + crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd) } /// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 87861989c0..61792b464c 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -40,8 +40,12 @@ use crate::render::highlight::foreground_style_for_scopes; use crate::render::highlight::highlight_code_to_lines; use crate::render::line_utils::line_to_static; -use crate::render::line_utils::push_owned_lines; use crate::style::table_separator_style; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::terminal_hyperlinks::annotate_web_urls_in_line; +use crate::terminal_hyperlinks::remap_wrapped_line; +use crate::terminal_hyperlinks::visible_lines; +use crate::terminal_hyperlinks::web_destination; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::word_wrap_line; @@ -66,6 +70,7 @@ use std::ops::Range; use std::path::Path; use std::path::PathBuf; use std::sync::LazyLock; +use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; use url::Url; @@ -136,7 +141,7 @@ impl IndentContext { /// `lines` are used for final rendering. #[derive(Clone, Debug, Default)] struct TableCell { - lines: Vec>, + lines: Vec, } // TableCell mutators inlined — called per-span during table event parsing. @@ -144,7 +149,7 @@ impl TableCell { #[inline] fn ensure_line(&mut self) { if self.lines.is_empty() { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } } @@ -152,13 +157,26 @@ impl TableCell { fn push_span(&mut self, span: Span<'static>) { self.ensure_line(); if let Some(line) = self.lines.last_mut() { - line.push_span(span); + line.line.push_span(span); + } + } + + fn push_annotated(&mut self, mut appended: HyperlinkLine) { + self.ensure_line(); + if let Some(line) = self.lines.last_mut() { + let shift = line.width(); + line.line.spans.append(&mut appended.line.spans); + line.hyperlinks + .extend(appended.hyperlinks.into_iter().map(|mut link| { + link.columns = link.columns.start + shift..link.columns.end + shift; + link + })); } } #[inline] fn hard_break(&mut self) { - self.lines.push(Line::default()); + self.lines.push(HyperlinkLine::new(Line::default())); } fn plain_text(&self) -> String { @@ -168,7 +186,7 @@ impl TableCell { if i > 0 { buf.push(' '); } - for span in &line.spans { + for span in &line.line.spans { let _ = write!(buf, "{}", span.content); } } @@ -220,9 +238,9 @@ impl TableState { /// `spillover_lines` are prose rows extracted from parser artifacts and should /// be routed through normal wrapping. struct RenderedTableLines { - table_lines: Vec>, + table_lines: Vec, table_lines_prewrapped: bool, - spillover_lines: Vec>, + spillover_lines: Vec, } /// Classification of a table column for width-allocation priority. @@ -287,6 +305,16 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( width: Option, cwd: Option<&Path>, ) -> Text<'static> { + Text::from(visible_lines(render_markdown_lines_with_width_and_cwd( + input, width, cwd, + ))) +} + +pub(crate) fn render_markdown_lines_with_width_and_cwd( + input: &str, + width: Option, + cwd: Option<&Path>, +) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); @@ -338,7 +366,7 @@ where { input: &'a str, iter: I, - text: Text<'static>, + text: Vec, styles: MarkdownStyles, inline_styles: Vec